#region Using declarations using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Threading; using NinjaTrader.Cbi; using NinjaTrader.NinjaScript; #endregion namespace NinjaTrader.NinjaScript.AddOns { public static class ConquerSyncVersion { public const string Number = "2.2.0"; public const string Date = "2026-04-29"; public const string Display = "v2.2.0 (2026-04-29)"; // v2.0.0 - Refactor: eliminado AccountMap, vinculacion via nt8_account_name en Supabase // v1.x - Sistema AccountMap en JSON local } // ═══════════════════════════════════════════════════════════ // MODELOS // ═══════════════════════════════════════════════════════════ public class SyncConfig { public string SupabaseUrl { get; set; } = "https://kqfevwdaqkkvybpmikpo.supabase.co"; public string ApiKey { get; set; } = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtxZmV2d2RhcWtrdnlicG1pa3BvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzUxMjI1NDYsImV4cCI6MjA5MDY5ODU0Nn0.l0NE18U0WebL_NvikN2lvjiqci8wIysdD5jdw_hXXvE"; public bool SendInstrument { get; set; } = true; public bool SendDirection { get; set; } = true; public bool SendEntryPrice { get; set; } = true; public bool SendExitPrice { get; set; } = true; public bool SendPnL { get; set; } = true; public bool SendResult { get; set; } = true; public bool SendQuantity { get; set; } = true; public bool SendMAE { get; set; } = true; public bool SendMFE { get; set; } = true; public bool SendSlippage { get; set; } = true; public bool SendCommission { get; set; } = true; public bool SendDuration { get; set; } = true; public bool SendPartialFills { get; set; } = true; } public class TradeRecord { public string Id { get; set; } = Guid.NewGuid().ToString(); public string Instrument { get; set; } public string Direction { get; set; } public string Date { get; set; } public string Time { get; set; } public string ExitTime { get; set; } public double AvgEntryPrice { get; set; } public double AvgExitPrice { get; set; } public double FirstEntryPx { get; set; } public double LastExitPx { get; set; } public int Quantity { get; set; } public double PnL { get; set; } public string Result { get; set; } public double MAE { get; set; } public double MFE { get; set; } public double Slippage { get; set; } public double Commission { get; set; } public int DurationSecs { get; set; } public int EntryFills { get; set; } public int ExitFills { get; set; } public string Notes { get; set; } public int RetryCount { get; set; } = 0; public string CreatedAt { get; set; } = DateTime.UtcNow.ToString("o"); } public class PartialFill { public double Price { get; set; } public int Qty { get; set; } public double LimitPrice { get; set; } public double Commission { get; set; } } public class OpenPosition { public string Instrument { get; set; } public string Direction { get; set; } public DateTime EntryTime { get; set; } public double PointValue { get; set; } public List Entries { get; } = new List(); public List Exits { get; } = new List(); public double MaxFavPnl { get; set; } = 0; public double MaxAdvPnl { get; set; } = 0; public int TotalQty => Entries.Sum(f => f.Qty); } // ═══════════════════════════════════════════════════════════ // ADDON PRINCIPAL // ═══════════════════════════════════════════════════════════ public class ConquerSync : AddOnBase { // ── Paths ──────────────────────────────────────────────── private static string QueuePath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "NinjaTrader 8", "ConquerSyncQueue.json"); private static string ConfigPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "NinjaTrader 8", "ConquerSyncConfig.json"); // ── Estado ─────────────────────────────────────────────── private static ConquerSyncWindow _win = null; private SyncConfig _cfg = new SyncConfig(); private readonly object _lock = new object(); private readonly Dictionary _open = new Dictionary(); private readonly List _queue = new List(); private DispatcherTimer _retryTimer; private int _syncedCount = 0; private DateTime _lastBalanceSent = DateTime.MinValue; // ── HTTP helper ────────────────────────────────────────── private HttpRequestMessage MakeReq(HttpMethod method, string url) { var req = new HttpRequestMessage(method, url); req.Headers.TryAddWithoutValidation("apikey", _cfg.ApiKey); req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _cfg.ApiKey); req.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); return req; } // ── Icono ──────────────────────────────────────────────── public void SetIconOk(bool ok) { var color = ok ? Color.FromRgb(0, 210, 100) : Color.FromRgb(220, 50, 50); var geo = new EllipseGeometry(new Point(8, 8), 7, 7); var dg = new DrawingGroup(); dg.Children.Add(new GeometryDrawing(new SolidColorBrush(color), null, geo)); Application.Current?.Dispatcher.InvokeAsync(() => { try { if (_win != null) _win.Icon = new DrawingImage(dg); } catch { } }); } // ── Ciclo de vida ──────────────────────────────────────── protected override void OnStateChange() { if (State == State.SetDefaults) { LoadConfig(); LoadQueue(); } } protected override void OnWindowCreated(Window window) { foreach (Account acc in Account.All) { acc.ExecutionUpdate -= OnExecution; acc.PositionUpdate -= OnPositionUpdate; acc.ExecutionUpdate += OnExecution; acc.PositionUpdate += OnPositionUpdate; } if (_win != null) return; Application.Current.Dispatcher.BeginInvoke(new Action(() => { foreach (Window w in new List(Application.Current.Windows.Cast())) if (w is ConquerSyncWindow old && w != _win) old.Close(); if (_win != null) return; _win = new ConquerSyncWindow(this); _win.Closed += (s, e) => _win = null; _win.Show(); foreach (Account acc in Account.All) { bool isSim = acc.Name.StartsWith("Sim") || acc.Name.StartsWith("Backtest") || acc.Name.StartsWith("Playback"); bool connected = acc.Connection?.Status == ConnectionStatus.Connected; _win.SetAccountStatus(acc.Name, isSim ? "sim" : connected ? "connected" : "disconnected"); } _win.Log($"ConquerSync {ConquerSyncVersion.Display} iniciado"); if (_queue.Count > 0) _win.Log($"Cola pendiente: {_queue.Count} trade(s)"); Task.Run(() => SyncNT8AccountsAsync()); _retryTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(2) }; _retryTimer.Tick += (s, e) => { Task.Run(() => RetryQueue()); TrySendDailyBalances(); }; _retryTimer.Start(); }), DispatcherPriority.ApplicationIdle); } protected override void OnWindowDestroyed(Window window) { foreach (Account acc in Account.All) { acc.ExecutionUpdate -= OnExecution; acc.PositionUpdate -= OnPositionUpdate; } _retryTimer?.Stop(); SaveQueue(); } // ── Tracking de ejecuciones ────────────────────────────── private void OnExecution(object sender, ExecutionEventArgs e) { Execution exec = e.Execution; if (exec?.Order == null) return; Account acc = (Account)sender; string key = acc.Name + "|" + exec.Instrument.FullName; OrderAction action = exec.Order.OrderAction; bool isEntry = action == OrderAction.Buy || action == OrderAction.SellShort; bool isExit = action == OrderAction.Sell || action == OrderAction.BuyToCover; lock (_lock) { if (isEntry) { if (!_open.TryGetValue(key, out OpenPosition pos)) { pos = new OpenPosition { Instrument = exec.Instrument.FullName, Direction = action == OrderAction.Buy ? "long" : "short", EntryTime = exec.Time, PointValue = exec.Instrument.MasterInstrument.PointValue }; _open[key] = pos; } double limitPx = exec.Order.LimitPrice > 0 ? exec.Order.LimitPrice : exec.Price; pos.Entries.Add(new PartialFill { Price = exec.Price, Qty = exec.Quantity, LimitPrice = limitPx, Commission = exec.Commission }); } else if (isExit && _open.TryGetValue(key, out OpenPosition openPos)) { double limitPx = exec.Order.LimitPrice > 0 ? exec.Order.LimitPrice : exec.Price; openPos.Exits.Add(new PartialFill { Price = exec.Price, Qty = exec.Quantity, LimitPrice = limitPx, Commission = exec.Commission }); if (openPos.Exits.Sum(f => f.Qty) >= openPos.TotalQty) { _open.Remove(key); Task.Run(() => EnqueueAndSync(BuildRecord(openPos, exec.Time))); } } } } private void OnPositionUpdate(object sender, PositionEventArgs e) { Position pos = e.Position; if (pos.MarketPosition == MarketPosition.Flat) return; string key = ((Account)sender).Name + "|" + pos.Instrument.FullName; lock (_lock) { if (!_open.TryGetValue(key, out OpenPosition open)) return; int totalQty = open.Entries.Sum(f => f.Qty); double avgEntry = totalQty > 0 ? open.Entries.Sum(f => (double)f.Qty * f.Price) / totalQty : pos.AveragePrice; int sign = open.Direction == "long" ? 1 : -1; double tickSize = pos.Instrument.MasterInstrument.TickSize; double tickValue = pos.Instrument.MasterInstrument.PointValue * tickSize; double upnl = sign * (pos.AveragePrice - avgEntry) / tickSize * tickValue * open.TotalQty; if (upnl > open.MaxFavPnl) open.MaxFavPnl = upnl; if (upnl < open.MaxAdvPnl) open.MaxAdvPnl = upnl; } } // ── Construir TradeRecord ──────────────────────────────── private TradeRecord BuildRecord(OpenPosition p, DateTime exitTime) { int entryQty = p.TotalQty; int exitQty = p.Exits.Sum(f => f.Qty); double avgEntry = p.Entries.Sum(f => f.Price * f.Qty) / entryQty; double avgExit = p.Exits.Sum(f => f.Price * f.Qty) / exitQty; double totalComm = p.Entries.Sum(f => f.Commission) + p.Exits.Sum(f => f.Commission); double entrySlip = p.Entries.Sum(f => (f.Price - f.LimitPrice) * f.Qty) / entryQty; double exitSlip = p.Exits.Sum(f => (f.LimitPrice - f.Price) * f.Qty) / exitQty; double rawPnl = p.Direction == "long" ? (avgExit - avgEntry) * entryQty * p.PointValue : (avgEntry - avgExit) * entryQty * p.PointValue; double pnl = Math.Round(rawPnl - totalComm, 2); return new TradeRecord { Instrument = p.Instrument, Direction = p.Direction, Date = p.EntryTime.ToString("yyyy-MM-dd"), Time = p.EntryTime.ToString("HH:mm"), ExitTime = exitTime.ToString("HH:mm"), AvgEntryPrice = Math.Round(avgEntry, 5), AvgExitPrice = Math.Round(avgExit, 5), FirstEntryPx = Math.Round(p.Entries.First().Price, 5), LastExitPx = Math.Round(p.Exits.Last().Price, 5), Quantity = entryQty, PnL = pnl, Result = pnl > 0 ? "win" : pnl < 0 ? "loss" : "breakeven", MAE = Math.Round(p.MaxAdvPnl, 2), MFE = Math.Round(p.MaxFavPnl, 2), Slippage = Math.Round((entrySlip + exitSlip) * entryQty * p.PointValue, 2), Commission = Math.Round(totalComm, 2), DurationSecs = (int)(exitTime - p.EntryTime).TotalSeconds, EntryFills = p.Entries.Count, ExitFills = p.Exits.Count, Notes = $"NT8 · {p.Entries.Count}E/{p.Exits.Count}X" }; } // ── Cola + Sync ────────────────────────────────────────── public async Task EnqueueAndSync(TradeRecord tr) { if (tr == null) { await RetryQueue(); return; } lock (_lock) { if (_queue.Exists(x => x.Id == tr.Id)) return; _queue.Add(tr); } SaveQueue(); _win?.Log($"Detectado: {tr.Instrument} {tr.Direction.ToUpper()} → {tr.PnL:+0.00;-0.00}$"); if (await Post(tr)) { lock (_lock) _queue.RemoveAll(x => x.Id == tr.Id); SaveQueue(); _syncedCount++; _win?.OnSynced($"✓ {tr.Instrument} {tr.Result.ToUpper()} {tr.PnL:+0.00;-0.00}$", _syncedCount, _queue.Count); TrySendDailyBalances(); } else { _win?.Log($"✗ En cola ({tr.Instrument}) — reintento en 2 min"); _win?.UpdateQueue(_queue.Count); } } public async Task RetryQueue() { List pending; lock (_lock) pending = _queue.ToList(); if (pending.Count == 0) return; _win?.Log($"Reintentando {pending.Count} pendiente(s)..."); foreach (var tr in pending) { tr.RetryCount++; if (await Post(tr)) { lock (_lock) _queue.RemoveAll(x => x.Id == tr.Id); _syncedCount++; _win?.OnSynced($"✓ Reintento OK: {tr.Instrument}", _syncedCount, _queue.Count); } } SaveQueue(); } // ── HTTP ───────────────────────────────────────────────── private async Task Post(TradeRecord t) { try { using (var client = new HttpClient()) using (var req = MakeReq(HttpMethod.Post, _cfg.SupabaseUrl + "/rest/v1/trades")) { req.Content = new StringContent(BuildJson(t), Encoding.UTF8, "application/json"); var resp = await client.SendAsync(req); if (!resp.IsSuccessStatusCode) { if ((int)resp.StatusCode == 409) { _win?.Log($"[409] Duplicado: {t.Instrument} {t.Date} {t.Time}"); return true; } string body = await resp.Content.ReadAsStringAsync(); _win?.Log($"HTTP {(int)resp.StatusCode}: {body.Substring(0, Math.Min(160, body.Length))}"); SetIconOk(false); return false; } return true; } } catch (Exception ex) { _win?.Log($"Excepción: {ex.Message}"); return false; } } public async Task TestConnection(string url, string key) { try { using (var c = new HttpClient()) { c.DefaultRequestHeaders.TryAddWithoutValidation("apikey", key); c.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "Bearer " + key); return (await c.GetAsync(url + "/rest/v1/trades?select=id&limit=1")).IsSuccessStatusCode; } } catch { return false; } } // ── Balances ───────────────────────────────────────────── public void TrySendDailyBalances() { if (DateTime.Today == _lastBalanceSent.Date) return; bool hasOpen; lock (_lock) { hasOpen = _open.Count > 0; } if (hasOpen) return; _lastBalanceSent = DateTime.Now; Task.Run(() => SendBalancesAsync()); } public async Task ForceSendBalances() => await SendBalancesAsync(); // Registra las cuentas NT8 activas en Supabase para que el journal las muestre public async Task SyncNT8AccountsAsync() { try { foreach (Account acc in Account.All) { bool isSim = acc.Name.StartsWith("Sim") || acc.Name.StartsWith("Backtest") || acc.Name.StartsWith("Playback"); if (isSim) continue; bool connected = acc.Connection?.Status == ConnectionStatus.Connected; string status = connected ? "connected" : "disconnected"; string Q = "\""; string body = "{" + Q+"name"+Q+":" + Q+J(acc.Name)+Q + "," + Q+"status"+Q+":" + Q+status+Q + "," + Q+"updated_at"+Q+":" + Q+DateTime.UtcNow.ToString("o")+Q + "}"; string url = _cfg.SupabaseUrl + "/rest/v1/nt8_accounts"; using (var client = new HttpClient()) using (var req = MakeReq(HttpMethod.Post, url)) { req.Headers.Remove("Prefer"); req.Headers.TryAddWithoutValidation("Prefer", "resolution=merge-duplicates,return=minimal"); req.Content = new StringContent(body, Encoding.UTF8, "application/json"); await client.SendAsync(req); } } _win?.Log("Cuentas NT8 registradas en Supabase."); } catch (Exception ex) { _win?.Log("Error SyncNT8Accounts: " + ex.Message); } } private async Task SendBalancesAsync() { _win?.Log("Sincronizando balances NT8..."); int sent = 0; int pending = 0; foreach (Account acc in Account.All) { try { bool isSim = acc.Name.StartsWith("Sim") || acc.Name.StartsWith("Backtest") || acc.Name.StartsWith("Playback"); if (isSim) continue; double balance = acc.Get(AccountItem.NetLiquidation, Currency.UsDollar); if (balance <= 0) continue; // 1. Obtener cuenta de Supabase para saber account_size, peak_balance y max_drawdown_limit string getUrl = _cfg.SupabaseUrl + "/rest/v1/prop_accounts?nt8_account_name=eq." + Uri.EscapeDataString(acc.Name) + "&select=id,account_size,peak_balance,max_drawdown_limit"; int accountId = 0; double accountSize = 0; double peakBalance = 0; double maxDrawdown = 0; using (var client = new HttpClient()) using (var req = MakeReq(HttpMethod.Get, getUrl)) { var resp = await client.SendAsync(req); if (resp.IsSuccessStatusCode) { string json = await resp.Content.ReadAsStringAsync(); // Parsear primer resultado del array if (json.Contains("\"id\":")) { accountId = JsonInt(json, "id", 0); accountSize = JsonDbl(json, "account_size", 0); peakBalance = JsonDbl(json, "peak_balance", accountSize); maxDrawdown = JsonDbl(json, "max_drawdown_limit", 0); } } } if (accountId <= 0) { // Cuenta no vinculada await RegisterPendingAsync(acc.Name); pending++; continue; } // 2. Calcular nuevo peak y drawdown double newPeak = Math.Max(peakBalance > 0 ? peakBalance : accountSize, balance); double drawdownUsd = Math.Round(newPeak - balance, 2); double drawdownPct = newPeak > 0 ? Math.Round((drawdownUsd / (maxDrawdown > 0 ? maxDrawdown : newPeak)) * 100, 2) : 0; string Q = "\""; string now = DateTime.UtcNow.ToString("o"); // 3. PATCH live_balance + peak_balance en prop_accounts string patchBody = "{" + Q+"live_balance"+Q+":" + N(balance) + "," + Q+"live_balance_at"+Q+":" + Q+now+Q + "," + Q+"peak_balance"+Q+":" + N(newPeak) + "}"; string patchUrl = _cfg.SupabaseUrl + "/rest/v1/prop_accounts?id=eq." + accountId; using (var client2 = new HttpClient()) using (var req2 = MakeReq(new HttpMethod("PATCH"), patchUrl)) { req2.Content = new StringContent(patchBody, Encoding.UTF8, "application/json"); await client2.SendAsync(req2); } // 4. INSERT en balance_history string histBody = "{" + Q+"account_id"+Q+":" + accountId + "," + Q+"balance"+Q+":" + N(balance) + "," + Q+"peak_balance"+Q+":" + N(newPeak) + "," + Q+"drawdown_usd"+Q+":" + N(drawdownUsd) + "," + Q+"drawdown_pct"+Q+":" + N(drawdownPct) + "," + Q+"recorded_at"+Q+":" + Q+now+Q + "}"; string histUrl = _cfg.SupabaseUrl + "/rest/v1/balance_history"; using (var client3 = new HttpClient()) using (var req3 = MakeReq(HttpMethod.Post, histUrl)) { req3.Content = new StringContent(histBody, Encoding.UTF8, "application/json"); await client3.SendAsync(req3); } _win?.Log($"Balance: {acc.Name} = ${balance:F2} | Peak: ${newPeak:F2} | DD: ${drawdownUsd:F2} ({drawdownPct:F1}%)"); sent++; } catch { } } if (sent > 0) _win?.Log($"Balances enviados: {sent} cuenta(s)."); if (pending > 0) _win?.Log($"{pending} cuenta(s) sin vincular — abre el journal para vincularlas."); if (sent == 0 && pending == 0) _win?.Log("No hay cuentas reales activas."); } private async Task RegisterPendingAsync(string nt8Name) { try { string Q = "\""; string body = "{" + Q+"nt8_name"+Q+":" + Q+J(nt8Name)+Q + "}"; using (var client = new HttpClient()) using (var req = MakeReq(HttpMethod.Post, _cfg.SupabaseUrl + "/rest/v1/pending_links")) { req.Headers.Remove("Prefer"); req.Headers.TryAddWithoutValidation("Prefer", "resolution=ignore-duplicates,return=minimal"); req.Content = new StringContent(body, Encoding.UTF8, "application/json"); await client.SendAsync(req); } } catch { } } // ── JSON builder ───────────────────────────────────────── private string BuildJson(TradeRecord t) { string Q = "\""; var f = new List { Q+"trade_type"+Q+":"+Q+"strategy"+Q, Q+"platform"+Q+":"+Q+"NT8"+Q, Q+"external_id"+Q+":"+Q+t.Id+Q, Q+"date"+Q+":"+Q+t.Date+Q, Q+"time_entry"+Q+":"+Q+t.Time+Q, Q+"time_exit"+Q+":"+Q+t.ExitTime+Q, Q+"contracts"+Q+":"+t.Quantity }; if (_cfg.SendInstrument) f.Add(Q+"instrument"+Q+":"+Q+J(t.Instrument)+Q); if (_cfg.SendDirection) f.Add(Q+"direction"+Q+":"+Q+t.Direction+Q); if (_cfg.SendResult) f.Add(Q+"result"+Q+":"+Q+t.Result+Q); if (_cfg.SendPnL) f.Add(Q+"pnl_usd"+Q+":"+N(t.PnL)); if (_cfg.SendEntryPrice) f.Add(Q+"entry_price"+Q+":"+N(t.AvgEntryPrice)); if (_cfg.SendExitPrice) f.Add(Q+"exit_price"+Q+":"+N(t.AvgExitPrice)); if (_cfg.SendMAE) f.Add(Q+"mae"+Q+":"+N(t.MAE)); if (_cfg.SendMFE) f.Add(Q+"mfe"+Q+":"+N(t.MFE)); if (_cfg.SendSlippage) f.Add(Q+"slippage"+Q+":"+N(t.Slippage)); if (_cfg.SendCommission) f.Add(Q+"commission"+Q+":"+N(t.Commission)); if (_cfg.SendDuration) f.Add(Q+"duration_seconds"+Q+":"+t.DurationSecs); if (_cfg.SendPartialFills) { f.Add(Q+"entry_fills"+Q+":"+t.EntryFills); f.Add(Q+"exit_fills"+Q+":"+t.ExitFills); } f.Add(Q+"notes"+Q+":"+Q+J(t.Notes)+Q); f.Add(Q+"connector_version"+Q+":"+Q+ConquerSyncVersion.Number+Q); return "{" + string.Join(",", f) + "}"; } // ── Persistencia ───────────────────────────────────────── public void SaveConfig() { try { string Q = "\""; string B(bool v) => v ? "true" : "false"; string KV(string k, string v) => Q+k+Q+":"+Q+v+Q+","; string KB(string k, bool v) => Q+k+Q+":"+B(v)+","; var sb = new StringBuilder("{"); sb.Append(KV("SupabaseUrl", J(_cfg.SupabaseUrl))); sb.Append(KV("ApiKey", J(_cfg.ApiKey))); sb.Append(KB("SendInstrument", _cfg.SendInstrument)); sb.Append(KB("SendDirection", _cfg.SendDirection)); sb.Append(KB("SendEntryPrice", _cfg.SendEntryPrice)); sb.Append(KB("SendExitPrice", _cfg.SendExitPrice)); sb.Append(KB("SendPnL", _cfg.SendPnL)); sb.Append(KB("SendResult", _cfg.SendResult)); sb.Append(KB("SendQuantity", _cfg.SendQuantity)); sb.Append(KB("SendMAE", _cfg.SendMAE)); sb.Append(KB("SendMFE", _cfg.SendMFE)); sb.Append(KB("SendSlippage", _cfg.SendSlippage)); sb.Append(KB("SendCommission", _cfg.SendCommission)); sb.Append(KB("SendDuration", _cfg.SendDuration)); sb.Append(Q+"SendPartialFills"+Q+":"+B(_cfg.SendPartialFills)); sb.Append("}"); File.WriteAllText(ConfigPath, sb.ToString(), Encoding.UTF8); } catch { } } private void LoadConfig() { try { if (!File.Exists(ConfigPath)) return; string json = File.ReadAllText(ConfigPath, Encoding.UTF8); _cfg = new SyncConfig { SupabaseUrl = JsonStr(json, "SupabaseUrl", "https://kqfevwdaqkkvybpmikpo.supabase.co"), ApiKey = JsonStr(json, "ApiKey", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtxZmV2d2RhcWtrdnlicG1pa3BvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzUxMjI1NDYsImV4cCI6MjA5MDY5ODU0Nn0.l0NE18U0WebL_NvikN2lvjiqci8wIysdD5jdw_hXXvE"), SendInstrument = JsonBool(json, "SendInstrument", true), SendDirection = JsonBool(json, "SendDirection", true), SendEntryPrice = JsonBool(json, "SendEntryPrice", true), SendExitPrice = JsonBool(json, "SendExitPrice", true), SendPnL = JsonBool(json, "SendPnL", true), SendResult = JsonBool(json, "SendResult", true), SendQuantity = JsonBool(json, "SendQuantity", true), SendMAE = JsonBool(json, "SendMAE", true), SendMFE = JsonBool(json, "SendMFE", true), SendSlippage = JsonBool(json, "SendSlippage", true), SendCommission = JsonBool(json, "SendCommission", true), SendDuration = JsonBool(json, "SendDuration", true), SendPartialFills = JsonBool(json, "SendPartialFills", true), }; } catch { _cfg = new SyncConfig(); } } public void SaveQueue() { try { List snap; lock (_lock) snap = _queue.ToList(); var sb = new StringBuilder("["); for (int i = 0; i < snap.Count; i++) { if (i > 0) sb.Append(","); sb.Append(SerializeTR(snap[i])); } sb.Append("]"); File.WriteAllText(QueuePath, sb.ToString(), Encoding.UTF8); } catch { } } private void LoadQueue() { try { if (!File.Exists(QueuePath)) return; string json = File.ReadAllText(QueuePath, Encoding.UTF8).Trim(); if (string.IsNullOrEmpty(json) || json == "[]") return; lock (_lock) foreach (var r in ParseTRArray(json)) _queue.Add(r); } catch { } } // ── Serialización TradeRecord ───────────────────────────── private static string SerializeTR(TradeRecord t) { string Q = "\""; string KS(string k, string v) => Q+k+Q+":"+Q+v+Q+","; string KN(string k, double v) => Q+k+Q+":"+N(v)+","; string KI(string k, int v) => Q+k+Q+":"+v+","; var sb = new StringBuilder("{"); sb.Append(KS("Id", J(t.Id))); sb.Append(KS("Instrument", J(t.Instrument))); sb.Append(KS("Direction", J(t.Direction))); sb.Append(KS("Date", J(t.Date))); sb.Append(KS("Time", J(t.Time))); sb.Append(KS("ExitTime", J(t.ExitTime))); sb.Append(KN("AvgEntryPrice", t.AvgEntryPrice)); sb.Append(KN("AvgExitPrice", t.AvgExitPrice)); sb.Append(KN("FirstEntryPx", t.FirstEntryPx)); sb.Append(KN("LastExitPx", t.LastExitPx)); sb.Append(KI("Quantity", t.Quantity)); sb.Append(KN("PnL", t.PnL)); sb.Append(KS("Result", J(t.Result))); sb.Append(KN("MAE", t.MAE)); sb.Append(KN("MFE", t.MFE)); sb.Append(KN("Slippage", t.Slippage)); sb.Append(KN("Commission", t.Commission)); sb.Append(KI("DurationSecs", t.DurationSecs)); sb.Append(KI("EntryFills", t.EntryFills)); sb.Append(KI("ExitFills", t.ExitFills)); sb.Append(KS("Notes", J(t.Notes))); sb.Append(KI("RetryCount", t.RetryCount)); sb.Append(Q+"CreatedAt"+Q+":"+Q+J(t.CreatedAt)+Q); sb.Append("}"); return sb.ToString(); } private static List ParseTRArray(string json) { var result = new List(); json = json.Trim().TrimStart('[').TrimEnd(']').Trim(); if (string.IsNullOrEmpty(json)) return result; int depth = 0, start = 0; for (int i = 0; i < json.Length; i++) { if (json[i] == '{') { if (depth == 0) start = i; depth++; } else if (json[i] == '}') { depth--; if (depth == 0) result.Add(DeserializeTR(json.Substring(start, i - start + 1))); } } return result; } private static TradeRecord DeserializeTR(string json) => new TradeRecord { Id = JsonStr(json, "Id", Guid.NewGuid().ToString()), Instrument = JsonStr(json, "Instrument", ""), Direction = JsonStr(json, "Direction", ""), Date = JsonStr(json, "Date", ""), Time = JsonStr(json, "Time", ""), ExitTime = JsonStr(json, "ExitTime", ""), Result = JsonStr(json, "Result", ""), Notes = JsonStr(json, "Notes", ""), CreatedAt = JsonStr(json, "CreatedAt", DateTime.UtcNow.ToString("o")), AvgEntryPrice = JsonDbl(json, "AvgEntryPrice", 0), AvgExitPrice = JsonDbl(json, "AvgExitPrice", 0), FirstEntryPx = JsonDbl(json, "FirstEntryPx", 0), LastExitPx = JsonDbl(json, "LastExitPx", 0), PnL = JsonDbl(json, "PnL", 0), MAE = JsonDbl(json, "MAE", 0), MFE = JsonDbl(json, "MFE", 0), Slippage = JsonDbl(json, "Slippage", 0), Commission = JsonDbl(json, "Commission", 0), Quantity = JsonInt(json, "Quantity", 0), DurationSecs = JsonInt(json, "DurationSecs", 0), EntryFills = JsonInt(json, "EntryFills", 0), ExitFills = JsonInt(json, "ExitFills", 0), RetryCount = JsonInt(json, "RetryCount", 0), }; // ── JSON parsers ────────────────────────────────────────── private static string JsonStr(string json, string key, string def) { string pat = "\"" + key + "\":\""; int idx = json.IndexOf(pat); if (idx < 0) return def; idx += pat.Length; int end = idx; while (end < json.Length && json[end] != '"') { if (json[end] == '\\') end++; end++; } return json.Substring(idx, end - idx); } private static bool JsonBool(string json, string key, bool def) { string pat = "\"" + key + "\":"; int idx = json.IndexOf(pat); if (idx < 0) return def; idx += pat.Length; return json.Substring(idx, Math.Min(4, json.Length - idx)).StartsWith("true"); } private static double JsonDbl(string json, string key, double def) { string pat = "\"" + key + "\":"; int idx = json.IndexOf(pat); if (idx < 0) return def; idx += pat.Length; int end = idx; while (end < json.Length && json[end] != ',' && json[end] != '}') end++; double v; return double.TryParse(json.Substring(idx, end - idx), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out v) ? v : def; } private static int JsonInt(string json, string key, int def) { string pat = "\"" + key + "\":"; int idx = json.IndexOf(pat); if (idx < 0) return def; idx += pat.Length; int end = idx; while (end < json.Length && json[end] != ',' && json[end] != '}') end++; int v; return int.TryParse(json.Substring(idx, end - idx).Trim(), out v) ? v : def; } // ── Histórico (reenvío desde cola) ──────────────────────── public void SendHistorical(List accounts, DateTime from) { List toSend; lock (_lock) { toSend = _queue.Where(tr => { DateTime d; return DateTime.TryParse(tr.Date, out d) && d.Date >= from.Date; }).ToList(); } if (toSend.Count == 0) { _win?.Log("No hay trades en cola para el rango seleccionado."); _win?.Log("El envío histórico aplica solo a trades en cola local."); return; } _win?.Log($"Reintentando {toSend.Count} trade(s)..."); Task.Run(() => RetryQueue()); } public SyncConfig Config => _cfg; public List Queue => _queue; public int SyncedCount => _syncedCount; private static string J(string s) => s?.Replace("\\", "\\\\").Replace("\"", "\\\"") ?? ""; private static string N(double d) => d.ToString(System.Globalization.CultureInfo.InvariantCulture); } // ═══════════════════════════════════════════════════════════ // MODELOS UI // ═══════════════════════════════════════════════════════════ public class AccountRow { public string Name { get; set; } public bool Selected { get; set; } public CheckBox ChkBox { get; set; } public Label LblStatus { get; set; } public Label LblLast { get; set; } } // ═══════════════════════════════════════════════════════════ // VENTANA WPF // ═══════════════════════════════════════════════════════════ public class ConquerSyncWindow : Window { private readonly ConquerSync _addon; private TextBox _logBox; private Label _lblSynced, _lblQueue; private TextBox _tbUrl, _tbKey; private ComboBox _cbRange; private Button _btnSend; private readonly Dictionary _checks = new Dictionary(); private readonly List _accountRows = new List(); private bool _sendBlocked = false; private readonly string _lastSyncPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "NinjaTrader 8", "ConquerSyncLastSync.json"); private static readonly SolidColorBrush C_BG = Brush(8, 12, 16); private static readonly SolidColorBrush C_BG2 = Brush(13, 19, 24); private static readonly SolidColorBrush C_BG3 = Brush(17, 24, 32); private static readonly SolidColorBrush C_BORDER = Brush(30, 45, 61); private static readonly SolidColorBrush C_ACCENT = Brush(0, 212, 255); private static readonly SolidColorBrush C_TEXT = Brush(226, 234, 242); private static readonly SolidColorBrush C_TEXT2 = Brush(143, 163, 184); private static readonly SolidColorBrush C_TEXT3 = Brush(74, 98, 120); private static readonly SolidColorBrush C_GREEN = Brush(0, 230, 118); private static readonly SolidColorBrush C_YELLOW = Brush(255, 200, 0); private static readonly SolidColorBrush C_RED = Brush(255, 68, 68); private static readonly FontFamily MONO = new FontFamily("JetBrains Mono, Consolas"); public ConquerSyncWindow(ConquerSync addon) { _addon = addon; Title = "ConquerSync — TradingJournal Pro"; Width = 620; Height = 700; MinWidth = 520; MinHeight = 560; Background = C_BG; Foreground = C_TEXT; FontFamily = MONO; WindowStartupLocation = WindowStartupLocation.CenterScreen; ResizeMode = ResizeMode.CanResize; Content = Build(); } private UIElement Build() { var root = new DockPanel { Background = C_BG }; var hdr = new Border { Background = C_BG2, Padding = new Thickness(20, 12, 20, 12), BorderBrush = C_BORDER, BorderThickness = new Thickness(0, 0, 0, 1) }; var hdrRow = new StackPanel { Orientation = Orientation.Horizontal }; hdrRow.Children.Add(TB("CONQUER", 14, C_ACCENT, FontWeights.Bold)); hdrRow.Children.Add(TB("SYNC — TradingJournal Pro", 14, C_TEXT3)); hdrRow.Children.Add(TB(" " + ConquerSyncVersion.Display, 11, C_TEXT3)); hdr.Child = hdrRow; DockPanel.SetDock(hdr, Dock.Top); root.Children.Add(hdr); var sbar = new Border { Background = C_BG2, Padding = new Thickness(16, 5, 16, 5), BorderBrush = C_BORDER, BorderThickness = new Thickness(0, 0, 0, 1) }; var srow = new StackPanel { Orientation = Orientation.Horizontal }; srow.Children.Add(TB("●", 10, C_GREEN)); srow.Children.Add(TB(" ACTIVO", 10, C_TEXT3)); _lblSynced = Lbl(" Sincronizados: 0", 10, C_TEXT3); _lblQueue = Lbl(" Cola: 0", 10, C_TEXT3); srow.Children.Add(_lblSynced); srow.Children.Add(_lblQueue); sbar.Child = srow; DockPanel.SetDock(sbar, Dock.Top); root.Children.Add(sbar); var tabs = new TabControl { Background = C_BG, BorderThickness = new Thickness(0) }; tabs.Items.Add(TabSync()); tabs.Items.Add(TabConfig()); root.Children.Add(tabs); return root; } // ── TAB SINCRONIZAR ────────────────────────────────────── private TabItem TabSync() { var tab = MakeTab("Sincronizar"); var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled }; var outer = new StackPanel { Margin = new Thickness(16) }; var logLabel = TB("LOG DE ACTIVIDAD", 9, C_TEXT3); logLabel.Margin = new Thickness(0, 0, 0, 6); outer.Children.Add(logLabel); _logBox = new TextBox { IsReadOnly = true, Background = C_BG2, Foreground = C_TEXT2, BorderBrush = C_BORDER, BorderThickness = new Thickness(1), FontFamily = MONO, FontSize = 10, TextWrapping = TextWrapping.Wrap, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(10), Height = 180 }; outer.Children.Add(_logBox); outer.Children.Add(Sep()); outer.Children.Add(BuildSendPanel()); outer.Children.Add(Sep()); outer.Children.Add(BuildAccountHeader()); outer.Children.Add(BuildAccountList()); scroll.Content = outer; tab.Content = scroll; Log("Addon iniciado. Escuchando trades en tiempo real..."); if (_addon.Queue.Count > 0) Log(_addon.Queue.Count + " trade(s) pendientes en cola."); return tab; } private UIElement BuildSendPanel() { var panel = new Border { Background = C_BG3, Padding = new Thickness(12, 10, 12, 10), BorderBrush = C_BORDER, BorderThickness = new Thickness(1) }; var inner = new StackPanel(); inner.Children.Add(TB("SINCRONIZACIÓN", 9, C_TEXT3)); inner.Children.Add(new Border { Height = 8 }); var row = new StackPanel { Orientation = Orientation.Horizontal }; _cbRange = new ComboBox { Background = C_BG2, Foreground = C_TEXT2, BorderBrush = C_BORDER, FontFamily = MONO, FontSize = 11, Padding = new Thickness(10, 6, 10, 6), Width = 160, VerticalAlignment = VerticalAlignment.Center }; foreach (var r in new[] { "Hoy", "Ayer y hoy", "Ultimos 7 dias", "Ultimos 30 dias", "Desde el inicio" }) _cbRange.Items.Add(r); _cbRange.SelectedIndex = 1; row.Children.Add(_cbRange); _btnSend = Btn(" SINCRONIZAR DATOS ", C_ACCENT, C_BG, C_BORDER); _btnSend.Margin = new Thickness(10, 0, 0, 0); _btnSend.Click += OnSendClicked; row.Children.Add(_btnSend); var btnRetry = Btn("↺ COLA", C_TEXT2, C_BG2, C_BORDER); btnRetry.Margin = new Thickness(8, 0, 0, 0); btnRetry.Click += async (s, e) => { btnRetry.IsEnabled = false; await Task.Run(() => _addon.RetryQueue()); btnRetry.IsEnabled = true; }; row.Children.Add(btnRetry); var btnClear = Btn("✕ LOG", C_TEXT3, C_BG2, C_BORDER); btnClear.Margin = new Thickness(8, 0, 0, 0); btnClear.Click += (s, e) => _logBox?.Clear(); row.Children.Add(btnClear); inner.Children.Add(row); inner.Children.Add(new TextBlock { Text = "Sincroniza trades de la cola y actualiza balances desde NT8.", FontSize = 9, Foreground = C_TEXT3, FontFamily = MONO, Margin = new Thickness(0, 6, 0, 0) }); panel.Child = inner; return panel; } private UIElement BuildAccountHeader() { var header = new Border { Background = C_BG3, Padding = new Thickness(10, 6, 10, 6), BorderBrush = C_BORDER, BorderThickness = new Thickness(1, 1, 1, 0) }; var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(30) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) }); var chkAll = new CheckBox { IsChecked = true, VerticalAlignment = VerticalAlignment.Center }; chkAll.Checked += (s, e) => { foreach (var r in _accountRows) { r.Selected = true; if (r.ChkBox != null) r.ChkBox.IsChecked = true; } }; chkAll.Unchecked += (s, e) => { foreach (var r in _accountRows) { r.Selected = false; if (r.ChkBox != null) r.ChkBox.IsChecked = false; } }; Grid.SetColumn(chkAll, 0); grid.Children.Add(chkAll); AddCol(grid, "CUENTA", 1, C_TEXT3, FontWeights.Bold); AddCol(grid, "ESTADO", 2, C_TEXT3, FontWeights.Bold); AddCol(grid, "ULTIMO ENVIO", 3, C_TEXT3, FontWeights.Bold); header.Child = grid; return header; } private UIElement BuildAccountList() { var dict = LoadLastSyncDict(); var container = new StackPanel { Margin = new Thickness(0, 0, 0, 12) }; foreach (Account acc in Account.All) { bool isSim = acc.Name.StartsWith("Sim") || acc.Name.StartsWith("Backtest") || acc.Name.StartsWith("Playback"); bool isReal = !isSim; string lastSync = dict.ContainsKey(acc.Name) ? dict[acc.Name] : "-"; string status = isSim ? "sim" : (acc.Connection?.Status == ConnectionStatus.Connected ? "connected" : "disconnected"); var row = new AccountRow { Name = acc.Name, Selected = isReal }; _accountRows.Add(row); var border = new Border { Background = C_BG2, Padding = new Thickness(10, 7, 10, 7), BorderBrush = C_BORDER, BorderThickness = new Thickness(1, 0, 1, 1) }; var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(30) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) }); var chk = new CheckBox { IsChecked = isReal, VerticalAlignment = VerticalAlignment.Center }; chk.Checked += (s2, e2) => row.Selected = true; chk.Unchecked += (s2, e2) => row.Selected = false; row.ChkBox = chk; Grid.SetColumn(chk, 0); grid.Children.Add(chk); var lblName = new TextBlock { Text = acc.Name, FontSize = 11, Foreground = C_TEXT, FontFamily = MONO, VerticalAlignment = VerticalAlignment.Center }; Grid.SetColumn(lblName, 1); grid.Children.Add(lblName); SolidColorBrush sc = status == "connected" ? C_GREEN : status == "sim" ? C_YELLOW : C_RED; string st = status == "connected" ? "● Conectado" : status == "sim" ? "● Simulado" : "● Desconectado"; var lblSt = new Label { Content = st, FontSize = 10, Foreground = sc, FontFamily = MONO, Padding = new Thickness(0), VerticalAlignment = VerticalAlignment.Center }; row.LblStatus = lblSt; Grid.SetColumn(lblSt, 2); grid.Children.Add(lblSt); var lblLast = new Label { Content = lastSync, FontSize = 10, Foreground = C_TEXT3, FontFamily = MONO, Padding = new Thickness(0), VerticalAlignment = VerticalAlignment.Center }; row.LblLast = lblLast; Grid.SetColumn(lblLast, 3); grid.Children.Add(lblLast); border.Child = grid; container.Children.Add(border); } return container; } private async void OnSendClicked(object sender, RoutedEventArgs e) { if (_sendBlocked) return; var selected = _accountRows.Where(r => r.Selected).Select(r => r.Name).ToList(); if (selected.Count == 0) { Log("Selecciona al menos una cuenta."); return; } string range = _cbRange.SelectedItem?.ToString() ?? "Hoy"; DateTime from = range == "Hoy" ? DateTime.Today : range == "Ayer y hoy" ? DateTime.Today.AddDays(-1) : range == "Ultimos 7 dias" ? DateTime.Today.AddDays(-7) : range == "Ultimos 30 dias" ? DateTime.Today.AddDays(-30) : new DateTime(2000, 1, 1); _btnSend.IsEnabled = false; _btnSend.Content = "Enviando..."; _sendBlocked = true; Log($"Sincronizando {selected.Count} cuenta(s) — rango: {range}"); await Task.Run(() => _addon.SendHistorical(selected, from)); await Task.Run(() => _addon.ForceSendBalances()); string nowStr = DateTime.Now.ToString("yyyy-MM-dd HH:mm"); SaveLastSync(selected, nowStr); foreach (var r in _accountRows.Where(r => selected.Contains(r.Name))) { var cap = r; Dispatcher.InvokeAsync(() => { if (cap.LblLast != null) cap.LblLast.Content = nowStr; if (cap.LblStatus != null) { cap.LblStatus.Content = "● Conectado"; cap.LblStatus.Foreground = C_GREEN; } }); } for (int i = 60; i > 0; i--) { int c = i; Dispatcher.InvokeAsync(() => { _btnSend.Content = $"Bloqueado {c}s"; }); await Task.Delay(1000); } _sendBlocked = false; _btnSend.IsEnabled = true; _btnSend.Content = " SINCRONIZAR DATOS "; } // ── TAB CONFIGURACIÓN ──────────────────────────────────── private TabItem TabConfig() { var tab = MakeTab("Configuracion"); var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto }; var panel = new StackPanel { Margin = new Thickness(16) }; panel.Children.Add(SectionLbl("CONEXION SUPABASE")); panel.Children.Add(FieldLbl("URL")); _tbUrl = Input(_addon.Config.SupabaseUrl); panel.Children.Add(_tbUrl); panel.Children.Add(FieldLbl("API KEY (anon)")); _tbKey = Input(_addon.Config.ApiKey); _tbKey.FontSize = 10; panel.Children.Add(_tbKey); var btnTest = Btn("PROBAR CONEXION", C_ACCENT, C_BG, C_BORDER); btnTest.Margin = new Thickness(0, 8, 0, 0); btnTest.Click += async (s, e) => { btnTest.IsEnabled = false; btnTest.Content = "Probando..."; bool ok = await _addon.TestConnection(_tbUrl.Text.Trim(), _tbKey.Text.Trim()); btnTest.Content = ok ? "✓ CONEXION OK" : "✗ ERROR — ver log"; btnTest.Foreground = ok ? C_GREEN : C_RED; Log(ok ? "Conexion a Supabase exitosa." : "Error de conexion."); _addon.SetIconOk(ok); btnTest.IsEnabled = true; }; panel.Children.Add(btnTest); panel.Children.Add(Sep()); panel.Children.Add(SectionLbl("CAMPOS A SINCRONIZAR")); var fieldDefs = new[] { ("SendInstrument","Instrumento"),("SendDirection","Direccion"),("SendEntryPrice","Precio entrada"), ("SendExitPrice","Precio salida"),("SendPnL","P&L (USD)"),("SendResult","Resultado"), ("SendQuantity","Contratos"),("SendMAE","MAE"),("SendMFE","MFE"),("SendSlippage","Slippage"), ("SendCommission","Comision"),("SendDuration","Duracion (seg)"),("SendPartialFills","Fills parciales"), }; var cfgType = typeof(SyncConfig); var wrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) }; foreach (var fd in fieldDefs) { string key = fd.Item1; string lbl = fd.Item2; var prop = cfgType.GetProperty(key); bool val = prop != null ? (bool)prop.GetValue(_addon.Config) : true; var cb = new CheckBox { Content = lbl, IsChecked = val, Foreground = C_TEXT2, FontFamily = MONO, FontSize = 11, Margin = new Thickness(0, 4, 24, 4), Width = 170 }; _checks[key] = cb; wrap.Children.Add(cb); } panel.Children.Add(wrap); panel.Children.Add(Sep()); panel.Children.Add(SectionLbl("VINCULACION DE CUENTAS")); panel.Children.Add(new TextBlock { Text = "La vinculacion se gestiona desde el Trading Journal.\nEn la seccion Cuentas, edita cada cuenta y rellena el campo 'Nombre en NT8'\ncon el nombre exacto que aparece en la lista de arriba.", FontSize = 10, Foreground = C_TEXT2, FontFamily = MONO, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) }); panel.Children.Add(Sep()); var btnSave = Btn("GUARDAR CONFIGURACION", C_GREEN, C_BG, C_BORDER); btnSave.Click += (s, e) => { _addon.Config.SupabaseUrl = _tbUrl.Text.Trim(); _addon.Config.ApiKey = _tbKey.Text.Trim(); var t = typeof(SyncConfig); foreach (var kv in _checks) { var p = t.GetProperty(kv.Key); p?.SetValue(_addon.Config, kv.Value.IsChecked == true); } _addon.SaveConfig(); Log("Configuracion guardada."); btnSave.Content = "✓ GUARDADO"; btnSave.Foreground = C_GREEN; }; panel.Children.Add(btnSave); scroll.Content = panel; tab.Content = scroll; return tab; } // ── Métodos públicos ────────────────────────────────────── public void Log(string msg) => Dispatcher.InvokeAsync(() => { _logBox?.AppendText($"[{DateTime.Now:HH:mm:ss}] {msg}\n"); _logBox?.ScrollToEnd(); }); public void OnSynced(string msg, int total, int queueCount) => Dispatcher.InvokeAsync(() => { Log(msg); if (_lblSynced != null) _lblSynced.Content = $" Sincronizados: {total}"; if (_lblQueue != null) _lblQueue.Content = $" Cola: {queueCount}"; _addon.SetIconOk(true); }); public void UpdateQueue(int count) => Dispatcher.InvokeAsync(() => { if (_lblQueue != null) _lblQueue.Content = $" Cola: {count}"; }); public void SetAccountStatus(string accName, string status) => Dispatcher.InvokeAsync(() => { foreach (var row in _accountRows.Where(r => r.Name == accName)) { SolidColorBrush col = status == "connected" ? C_GREEN : status == "sim" ? C_YELLOW : C_RED; string txt = status == "connected" ? "● Conectado" : status == "sim" ? "● Simulado" : "● Desconectado"; if (row.LblStatus != null) { row.LblStatus.Content = txt; row.LblStatus.Foreground = col; } } }); // ── Persistencia timestamps ─────────────────────────────── private Dictionary LoadLastSyncDict() { var dict = new Dictionary(); try { if (!File.Exists(_lastSyncPath)) return dict; foreach (var part in File.ReadAllText(_lastSyncPath).Trim('{', '}').Split(',')) { var kv = part.Split(':'); if (kv.Length < 2) continue; dict[kv[0].Trim().Trim('"')] = string.Join(":", kv, 1, kv.Length - 1).Trim().Trim('"'); } } catch { } return dict; } private void SaveLastSync(List accounts, string timestamp) { try { var dict = LoadLastSyncDict(); foreach (var a in accounts) dict[a] = timestamp; File.WriteAllText(_lastSyncPath, "{" + string.Join(",", dict.Select(kv => $"\"{kv.Key}\":\"{kv.Value}\"")) + "}"); } catch { } } // ── UI helpers ──────────────────────────────────────────── private static void AddCol(Grid g, string text, int col, SolidColorBrush fg, FontWeight fw) { var tb = new TextBlock { Text = text, FontSize = 9, Foreground = fg, FontWeight = fw, FontFamily = new FontFamily("JetBrains Mono, Consolas"), VerticalAlignment = VerticalAlignment.Center }; Grid.SetColumn(tb, col); g.Children.Add(tb); } private static SolidColorBrush Brush(byte r, byte g, byte b) => new SolidColorBrush(Color.FromRgb(r, g, b)); private static TextBlock TB(string t, int s, SolidColorBrush fg, FontWeight? fw = null) => new TextBlock { Text = t, FontSize = s, Foreground = fg, FontFamily = new FontFamily("JetBrains Mono, Consolas"), FontWeight = fw ?? FontWeights.Normal, VerticalAlignment = VerticalAlignment.Center }; private static Label Lbl(string t, int s, SolidColorBrush fg) => new Label { Content = t, FontSize = s, Foreground = fg, FontFamily = new FontFamily("JetBrains Mono, Consolas"), Padding = new Thickness(0), VerticalAlignment = VerticalAlignment.Center }; private static TextBlock SectionLbl(string t) => new TextBlock { Text = t, FontSize = 9, Foreground = C_TEXT3, FontFamily = new FontFamily("JetBrains Mono, Consolas"), Margin = new Thickness(0, 0, 0, 8) }; private static TextBlock FieldLbl(string t) => new TextBlock { Text = t, FontSize = 10, Foreground = C_TEXT2, FontFamily = new FontFamily("JetBrains Mono, Consolas"), Margin = new Thickness(0, 10, 0, 4) }; private static TextBox Input(string v) => new TextBox { Text = v, Background = C_BG3, Foreground = C_TEXT, BorderBrush = C_BORDER, BorderThickness = new Thickness(1), FontFamily = new FontFamily("JetBrains Mono, Consolas"), FontSize = 11, Padding = new Thickness(10, 7, 10, 7), CaretBrush = C_ACCENT }; private static Button Btn(string t, SolidColorBrush fg, SolidColorBrush bg, SolidColorBrush border) => new Button { Content = t, Background = bg, Foreground = fg, BorderBrush = border, BorderThickness = new Thickness(1), FontFamily = new FontFamily("JetBrains Mono, Consolas"), FontSize = 10, FontWeight = FontWeights.Bold, Padding = new Thickness(12, 7, 12, 7), Cursor = System.Windows.Input.Cursors.Hand }; private static UIElement Sep() => new Border { Height = 1, Background = C_BORDER, Margin = new Thickness(0, 14, 0, 14) }; private static TabItem MakeTab(string header) => new TabItem { Header = header, Background = C_BG2, Foreground = C_TEXT2, FontFamily = new FontFamily("JetBrains Mono, Consolas"), FontSize = 11 }; } }