#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.4.0"; public const string Date = "2026-05-09"; public const string Display = "v" + Number + " (" + Date + ")"; // v2.4.0 - Sistema de linking codes: cada usuario configura su codigo y el connector // resuelve automaticamente su user_id en Supabase. Soporte multi-usuario completo. // v2.3.1 - Filtrado por status de prop_accounts (demo/evaluation/funded vs archived/burned/paused) // v2.3.0 - Fix: resolver account_id desde nt8_account_name + normalizar instrumento // v2.2.0 - (snapshot anterior) // 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 string LinkingCode { get; set; } = ""; // v2.4.0 - Codigo de vinculacion del usuario } 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 string Nt8AccountName { get; set; } = ""; public int AccountId { get; set; } = 0; } 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 string Nt8AccountName { 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; // v2.4.0 — user_id resuelto desde el linking code private string _resolvedUserId = null; // ── 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)"); // v2.4.0 — Resolver linking code antes de cualquier otra cosa Task.Run(async () => { bool ok = await ResolveLinkingCodeAsync(); if (ok) await 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", Nt8AccountName = acc.Name, 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, Nt8AccountName = p.Nt8AccountName, 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(); } // ═══════════════════════════════════════════════════════════ // v2.4.0 — RESOLUCION DE LINKING CODE // ═══════════════════════════════════════════════════════════ public async Task ResolveLinkingCodeAsync() { if (string.IsNullOrWhiteSpace(_cfg.LinkingCode)) { _win?.Log("⚠ Linking Code no configurado. Ve a Configuracion para introducirlo."); _resolvedUserId = null; SetIconOk(false); return false; } try { string url = _cfg.SupabaseUrl + "/rest/v1/linking_codes?code=eq." + Uri.EscapeDataString(_cfg.LinkingCode.Trim().ToUpper()) + "&is_active=eq.true&select=user_id"; using (var client = new HttpClient()) using (var req = MakeReq(HttpMethod.Get, url)) { var resp = await client.SendAsync(req); if (!resp.IsSuccessStatusCode) { _win?.Log($"✗ Error resolviendo Linking Code: HTTP {(int)resp.StatusCode}"); SetIconOk(false); return false; } string json = await resp.Content.ReadAsStringAsync(); if (!json.Contains("\"user_id\":")) { _win?.Log($"⚠ Linking Code '{_cfg.LinkingCode}' no encontrado o inactivo. Genera uno nuevo en la web."); _resolvedUserId = null; SetIconOk(false); return false; } _resolvedUserId = JsonStr(json, "user_id", ""); _win?.Log($"✓ Linking Code OK. Vinculado al usuario {_resolvedUserId.Substring(0, 8)}..."); // Actualizar last_seen_at en linking_codes Task.Run(() => UpdateLinkingCodeLastSeenAsync()); SetIconOk(true); return true; } } catch (Exception ex) { _win?.Log($"✗ Error resolviendo Linking Code: {ex.Message}"); SetIconOk(false); return false; } } private async Task UpdateLinkingCodeLastSeenAsync() { try { string Q = "\""; string body = "{" + Q+"last_seen_at"+Q+":"+Q+DateTime.UtcNow.ToString("o")+Q + "}"; string url = _cfg.SupabaseUrl + "/rest/v1/linking_codes?code=eq." + Uri.EscapeDataString(_cfg.LinkingCode.Trim().ToUpper()); using (var client = new HttpClient()) using (var req = MakeReq(new HttpMethod("PATCH"), url)) { req.Content = new StringContent(body, Encoding.UTF8, "application/json"); await client.SendAsync(req); } } catch { } } // ── Helpers de resolucion de cuenta NT8 ───────────────── private class AccountInfo { public int Id; public string Status; } private async Task ResolveAccountAsync(string nt8AccountName) { var info = new AccountInfo { Id = 0, Status = null }; if (string.IsNullOrEmpty(nt8AccountName)) return info; try { string url = _cfg.SupabaseUrl + "/rest/v1/prop_accounts?nt8_account_name=eq." + Uri.EscapeDataString(nt8AccountName) + "&select=id,status"; using (var client = new HttpClient()) using (var req = MakeReq(HttpMethod.Get, url)) { var resp = await client.SendAsync(req); if (!resp.IsSuccessStatusCode) return info; string json = await resp.Content.ReadAsStringAsync(); if (!json.Contains("\"id\":")) return info; info.Id = JsonInt(json, "id", 0); info.Status = JsonStr(json, "status", ""); return info; } } catch { return info; } } private static readonly HashSet ActiveStatuses = new HashSet { "evaluation", "funded", "demo" }; private static string CleanInstrument(string fullName) { if (string.IsNullOrEmpty(fullName)) return fullName; string cleaned = System.Text.RegularExpressions.Regex.Replace(fullName.Trim(), @"\s+\d{2}-\d{2}$", ""); return cleaned.ToUpper(); } // ── HTTP ───────────────────────────────────────────────── private async Task Post(TradeRecord t) { // v2.4.0 — Sin user_id resuelto, no enviamos if (string.IsNullOrEmpty(_resolvedUserId)) { _win?.Log("✗ Trade no enviado: Linking Code no resuelto. Configura tu codigo."); return false; } try { if (t.AccountId <= 0 && !string.IsNullOrEmpty(t.Nt8AccountName)) { AccountInfo info = await ResolveAccountAsync(t.Nt8AccountName); t.AccountId = info.Id; if (info.Id <= 0) { await RegisterPendingAsync(t.Nt8AccountName); _win?.Log($"Cuenta '{t.Nt8AccountName}' sin vincular. Trade enviado sin account_id."); } else if (!ActiveStatuses.Contains(info.Status)) { _win?.Log($"Cuenta '{t.Nt8AccountName}' en estado '{info.Status}'. Trade ignorado."); return true; } } 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($"Excepcion: {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; } } // v2.4.0 — Test del linking code public async Task TestLinkingCode(string code) { if (string.IsNullOrWhiteSpace(code)) return false; try { string url = _cfg.SupabaseUrl + "/rest/v1/linking_codes?code=eq." + Uri.EscapeDataString(code.Trim().ToUpper()) + "&is_active=eq.true&select=user_id"; using (var c = new HttpClient()) using (var req = MakeReq(HttpMethod.Get, url)) { var resp = await c.SendAsync(req); if (!resp.IsSuccessStatusCode) return false; string json = await resp.Content.ReadAsStringAsync(); return json.Contains("\"user_id\":"); } } 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(); public async Task SyncNT8AccountsAsync() { if (string.IsNullOrEmpty(_resolvedUserId)) { _win?.Log("⚠ Skipping SyncNT8Accounts: sin Linking Code resuelto."); return; } 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+"user_id"+Q+":" + Q+_resolvedUserId+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() { if (string.IsNullOrEmpty(_resolvedUserId)) { _win?.Log("⚠ Skipping balances: sin Linking Code resuelto."); return; } _win?.Log("Sincronizando balances NT8..."); int sent = 0; int pending = 0; int skipped = 0; foreach (Account acc in Account.All) { try { double balance = acc.Get(AccountItem.NetLiquidation, Currency.UsDollar); if (balance <= 0) continue; 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,status"; int accountId = 0; double accountSize = 0; double peakBalance = 0; double maxDrawdown = 0; string accountStatus = ""; 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(); 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); accountStatus = JsonStr(json, "status", ""); } } } if (accountId <= 0) { await RegisterPendingAsync(acc.Name); pending++; continue; } if (accountStatus != "evaluation" && accountStatus != "funded") { skipped++; continue; } 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"); 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); } string histBody = "{" + Q+"account_id"+Q+":" + accountId + "," + Q+"user_id"+Q+":" + Q+_resolvedUserId+Q + "," + 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 (skipped > 0) _win?.Log($"{skipped} cuenta(s) en estado inactivo."); if (sent == 0 && pending == 0 && skipped == 0) _win?.Log("No hay cuentas reales activas."); } private async Task RegisterPendingAsync(string nt8Name) { if (string.IsNullOrEmpty(_resolvedUserId)) return; try { string Q = "\""; string body = "{" + Q+"nt8_name"+Q+":" + Q+J(nt8Name)+Q + "," + Q+"user_id"+Q+":" + Q+_resolvedUserId+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 }; // v2.4.0 — Siempre incluimos user_id (resuelto desde linking code) f.Add(Q+"user_id"+Q+":"+Q+_resolvedUserId+Q); if (t.AccountId > 0) f.Add(Q+"account_id"+Q+":"+t.AccountId); f.Add(Q+"instrument"+Q+":"+Q+J(CleanInstrument(t.Instrument))+Q); f.Add(Q+"direction"+Q+":"+Q+t.Direction+Q); f.Add(Q+"result"+Q+":"+Q+t.Result+Q); f.Add(Q+"pnl_usd"+Q+":"+N(t.PnL)); f.Add(Q+"entry_price"+Q+":"+N(t.AvgEntryPrice)); f.Add(Q+"exit_price"+Q+":"+N(t.AvgExitPrice)); f.Add(Q+"mae"+Q+":"+N(t.MAE)); f.Add(Q+"mfe"+Q+":"+N(t.MFE)); f.Add(Q+"slippage"+Q+":"+N(t.Slippage)); f.Add(Q+"commission"+Q+":"+N(t.Commission)); f.Add(Q+"duration_seconds"+Q+":"+t.DurationSecs); 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 = "\""; var sb = new StringBuilder("{"); sb.Append(Q+"SupabaseUrl"+Q+":"+Q+J(_cfg.SupabaseUrl)+Q+","); sb.Append(Q+"ApiKey"+Q+":"+Q+J(_cfg.ApiKey)+Q+","); sb.Append(Q+"LinkingCode"+Q+":"+Q+J(_cfg.LinkingCode)+Q); 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"), LinkingCode = JsonStr(json, "LinkingCode", "") }; } 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 { } } // ── Serializacion 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(KS("Nt8AccountName", J(t.Nt8AccountName ?? ""))); sb.Append(KI("AccountId", t.AccountId)); 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), Nt8AccountName = JsonStr(json, "Nt8AccountName", ""), AccountId = JsonInt(json, "AccountId", 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; } public SyncConfig Config => _cfg; public List Queue => _queue; public int SyncedCount => _syncedCount; public string ResolvedUserId => _resolvedUserId; public bool IsLinked => !string.IsNullOrEmpty(_resolvedUserId); 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 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, _tbLinkingCode; private Button _btnSend; 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 = 720; MinWidth = 520; MinHeight = 580; 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", 15, C_ACCENT, FontWeights.Bold)); hdrRow.Children.Add(TB("SYNC — TradingJournal Pro", 15, C_TEXT3)); hdrRow.Children.Add(TB(" " + ConquerSyncVersion.Display, 12, 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("●", 11, C_GREEN)); srow.Children.Add(TB(" ACTIVO", 11, C_TEXT3)); _lblSynced = Lbl(" Sincronizados: 0", 11, C_TEXT3); _lblQueue = Lbl(" Cola: 0", 11, 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", 10, 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 = 11, 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("SINCRONIZACION", 10, C_TEXT3)); inner.Children.Add(new Border { Height = 8 }); var row = new StackPanel { Orientation = Orientation.Horizontal }; _btnSend = Btn(" SINCRONIZAR AHORA ", C_ACCENT, C_BG, C_BORDER); _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 = "Fuerza el envio de balances actuales y reintenta la cola pendiente.", FontSize = 10, 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(1, GridUnitType.Star) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) }); AddCol(grid, "CUENTA", 0, C_TEXT3, FontWeights.Bold); AddCol(grid, "ESTADO", 1, C_TEXT3, FontWeights.Bold); AddCol(grid, "ULTIMO ENVIO", 2, 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"); 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 }; _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(1, GridUnitType.Star) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) }); var lblName = new TextBlock { Text = acc.Name, FontSize = 12, Foreground = C_TEXT, FontFamily = MONO, VerticalAlignment = VerticalAlignment.Center }; Grid.SetColumn(lblName, 0); 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 = 11, Foreground = sc, FontFamily = MONO, Padding = new Thickness(0), VerticalAlignment = VerticalAlignment.Center }; row.LblStatus = lblSt; Grid.SetColumn(lblSt, 1); grid.Children.Add(lblSt); var lblLast = new Label { Content = lastSync, FontSize = 11, Foreground = C_TEXT3, FontFamily = MONO, Padding = new Thickness(0), VerticalAlignment = VerticalAlignment.Center }; row.LblLast = lblLast; Grid.SetColumn(lblLast, 2); grid.Children.Add(lblLast); border.Child = grid; container.Children.Add(border); } return container; } private async void OnSendClicked(object sender, RoutedEventArgs e) { if (_sendBlocked) return; _btnSend.IsEnabled = false; _btnSend.Content = "Sincronizando..."; _sendBlocked = true; Log("Forzando sincronizacion: balances + cola pendiente."); await Task.Run(() => _addon.RetryQueue()); await Task.Run(() => _addon.ForceSendBalances()); string nowStr = DateTime.Now.ToString("yyyy-MM-dd HH:mm"); var allNames = _accountRows.Select(r => r.Name).ToList(); SaveLastSync(allNames, nowStr); foreach (var r in _accountRows) { var cap = r; Dispatcher.InvokeAsync(() => { if (cap.LblLast != null) cap.LblLast.Content = nowStr; }); } for (int i = 10; i > 0; i--) { int c = i; Dispatcher.InvokeAsync(() => { _btnSend.Content = $"Espera {c}s"; }); await Task.Delay(1000); } _sendBlocked = false; _btnSend.IsEnabled = true; _btnSend.Content = " SINCRONIZAR AHORA "; } // ── TAB CONFIGURACION ──────────────────────────────────── private TabItem TabConfig() { var tab = MakeTab("Configuracion"); var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto }; var panel = new StackPanel { Margin = new Thickness(16) }; // ── LINKING CODE (lo más importante, va arriba) ── panel.Children.Add(SectionLbl("CODIGO DE VINCULACION")); var hint = new TextBlock { Text = "Genera tu codigo en la web (Conectores → NinjaTrader 8) y pegalo aqui.", FontSize = 11, Foreground = C_TEXT3, FontFamily = MONO, Margin = new Thickness(0, 0, 0, 8), TextWrapping = TextWrapping.Wrap }; panel.Children.Add(hint); _tbLinkingCode = Input(_addon.Config.LinkingCode); _tbLinkingCode.FontSize = 14; _tbLinkingCode.FontWeight = FontWeights.Bold; panel.Children.Add(_tbLinkingCode); var btnVerify = Btn("VERIFICAR Y GUARDAR", C_ACCENT, C_BG, C_BORDER); btnVerify.Margin = new Thickness(0, 12, 0, 0); btnVerify.Click += async (s, e) => { btnVerify.IsEnabled = false; btnVerify.Content = "Verificando..."; string code = _tbLinkingCode.Text.Trim().ToUpper(); bool valid = await _addon.TestLinkingCode(code); if (valid) { _addon.Config.LinkingCode = code; _addon.SaveConfig(); btnVerify.Content = "✓ CODIGO VALIDO Y GUARDADO"; btnVerify.Foreground = C_GREEN; Log("Linking Code valido. Resolviendo user_id..."); await Task.Run(() => _addon.ResolveLinkingCodeAsync()); } else { btnVerify.Content = "✗ CODIGO INVALIDO O INACTIVO"; btnVerify.Foreground = C_RED; Log("Linking Code invalido. Verifica que sea correcto y este activo."); } btnVerify.IsEnabled = true; }; panel.Children.Add(btnVerify); scroll.Content = panel; tab.Content = scroll; return tab; } // ── Metodos publicos ────────────────────────────────────── 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 = 10, 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 = 10, 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 = 11, 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 = 12, 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 = 11, 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 = 12 }; } }