#region Using declarations using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using NinjaTrader.Cbi; using NinjaTrader.Gui.Tools; using NinjaTrader.NinjaScript; #endregion namespace NinjaTrader.NinjaScript.AddOns { public class ProfitPlusRecorder : AddOnBase { private const string DEFAULT_API_URL = "https://profit-plus-journal.netlify.app/api/webhook-ninja"; private static readonly HttpClient httpClient = new HttpClient(); private List subscribedAccounts = new List(); private Timer heartbeatTimer; private static CommunityConfig config = new CommunityConfig(); private static readonly string ConfigPath = Path.Combine( NinjaTrader.Core.Globals.UserDataDir, "ProfitPlusRecorder_Config.json" ); private class PositionSession { public string AccountName; public string InstrumentName; public string Direction; public int CurrentQty; public int TotalEntryQty; public double TotalEntryValue; public int TotalExitQty; public double TotalExitValue; public double TotalCommission; public DateTime EntryTime; public DateTime ExitTime; } private static readonly Dictionary activeSessions = new Dictionary(); private static readonly object syncLock = new object(); public class CommunityConfig { public string ApiKey { get; set; } = ""; public string ApiUrl { get; set; } = DEFAULT_API_URL; public string TargetAccount { get; set; } = ""; public bool OnlyConnectedAccounts { get; set; } = true; public bool FilterBlownAccounts { get; set; } = true; public double MinBalanceThreshold { get; set; } = 100.0; } protected override void OnStateChange() { if (State == State.SetDefaults) { Description = "Auto-syncs trades to Profit Plus | TTG Community. Playback trades blocked."; Name = "ProfitPlusRecorder"; } else if (State == State.Active) { LoadConfig(); if (string.IsNullOrWhiteSpace(config.ApiKey)) NinjaTrader.Core.Globals.RandomDispatcher.InvokeAsync(() => ShowSettingsDialog(true)); else StartEngine(); } else if (State == State.Terminated) { StopEngine(); } } private void StartEngine() { if (string.IsNullOrWhiteSpace(config.ApiKey)) return; // ✅ Hook into general connections and individual account status changes Connection.ConnectionStatusUpdate += OnConnectionStatusUpdate; Account.AccountStatusUpdate += OnAccountStatusUpdate; RefreshAccountSubscriptions(); if (heartbeatTimer != null) heartbeatTimer.Dispose(); // ✅ Starts in 2 seconds, then ticks every 7 MINUTES (420,000 ms) heartbeatTimer = new Timer(OnTimerTick, null, 2000, 420000); // ✅ FAST-RETRY SEQUENCE: Catch broker logins during initial startup without pressing F5 Task.Delay(5000).ContinueWith(t => SafeInvoke(() => { RefreshAccountSubscriptions(); SendHeartbeat(); })); Task.Delay(10000).ContinueWith(t => SafeInvoke(() => { RefreshAccountSubscriptions(); SendHeartbeat(); })); Task.Delay(15000).ContinueWith(t => SafeInvoke(() => { RefreshAccountSubscriptions(); SendHeartbeat(); })); Print(string.Format("[Profit Plus] Active! Playback/Replay trades BLOCKED. API Key: {0}...", config.ApiKey.Length > 8 ? config.ApiKey.Substring(0, 8) : "******")); } private void StopEngine() { Connection.ConnectionStatusUpdate -= OnConnectionStatusUpdate; Account.AccountStatusUpdate -= OnAccountStatusUpdate; if (heartbeatTimer != null) { heartbeatTimer.Dispose(); heartbeatTimer = null; } lock (syncLock) { foreach (Account acc in subscribedAccounts) { acc.ExecutionUpdate -= OnExecutionUpdate; SendDisconnectSignal(acc.Name); } subscribedAccounts.Clear(); } Print("[Profit Plus] Stopped."); } // ============ ASYNCHRONOUS SAFE DISPATCHER INVOKER ============ private void SafeInvoke(Action action) { try { NinjaTrader.Core.Globals.RandomDispatcher.InvokeAsync(action); } catch { } } private void OnConnectionStatusUpdate(object sender, ConnectionStatusEventArgs e) { SafeInvoke(() => { RefreshAccountSubscriptions(); SendHeartbeat(); }); } private void OnAccountStatusUpdate(object sender, AccountStatusEventArgs e) { SafeInvoke(() => { RefreshAccountSubscriptions(); SendHeartbeat(); }); } // ============ ZERO-DEPENDENCY JSON CONFIG ============ private static void LoadConfig() { try { config = new CommunityConfig(); if (File.Exists(ConfigPath)) { string text = File.ReadAllText(ConfigPath); config.ApiKey = ExtractJsonString(text, "ApiKey", ""); config.ApiUrl = ExtractJsonString(text, "ApiUrl", DEFAULT_API_URL); config.TargetAccount = ExtractJsonString(text, "TargetAccount", ""); config.OnlyConnectedAccounts = ExtractJsonBool(text, "OnlyConnectedAccounts", true); config.FilterBlownAccounts = ExtractJsonBool(text, "FilterBlownAccounts", true); config.MinBalanceThreshold = ExtractJsonDouble(text, "MinBalanceThreshold", 100.0); } } catch (Exception ex) { NinjaTrader.Code.Output.Process(string.Format("[Profit Plus] Config Load Error: {0}", ex.Message), PrintTo.OutputTab1); } } private static void SaveConfig() { try { StringBuilder sb = new StringBuilder(); sb.AppendLine("{"); sb.AppendLine(string.Format(" \"ApiKey\": \"{0}\",", EscapeJson(config.ApiKey ?? ""))); sb.AppendLine(string.Format(" \"ApiUrl\": \"{0}\",", EscapeJson(config.ApiUrl ?? DEFAULT_API_URL))); sb.AppendLine(string.Format(" \"TargetAccount\": \"{0}\",", EscapeJson(config.TargetAccount ?? ""))); sb.AppendLine(string.Format(" \"OnlyConnectedAccounts\": {0},", config.OnlyConnectedAccounts.ToString().ToLower())); sb.AppendLine(string.Format(" \"FilterBlownAccounts\": {0},", config.FilterBlownAccounts.ToString().ToLower())); sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " \"MinBalanceThreshold\": {0:F2}", config.MinBalanceThreshold)); sb.AppendLine("}"); File.WriteAllText(ConfigPath, sb.ToString()); } catch (Exception ex) { NinjaTrader.Code.Output.Process(string.Format("[Profit Plus] Save Error: {0}", ex.Message), PrintTo.OutputTab1); } } public void ShowSettingsDialog(bool isFirstTime = false) { try { Window dialog = new Window { Title = isFirstTime ? "Profit Plus - First Time Setup" : "Profit Plus Settings", Width = 480, Height = 300, WindowStartupLocation = WindowStartupLocation.CenterScreen, ResizeMode = ResizeMode.NoResize, Background = new SolidColorBrush(Color.FromRgb(15, 20, 25)), Topmost = true }; StackPanel panel = new StackPanel { Margin = new Thickness(24) }; panel.Children.Add(new TextBlock { Text = "Profit Plus Setup", FontSize = 18, FontWeight = FontWeights.Bold, Foreground = new SolidColorBrush(Color.FromRgb(59, 130, 246)), Margin = new Thickness(0, 0, 0, 16) }); panel.Children.Add(new TextBlock { Text = "Enter your Trader API Key:", Foreground = new SolidColorBrush(Color.FromRgb(200, 200, 200)), FontSize = 12, Margin = new Thickness(0, 0, 0, 6) }); TextBox keyInput = new TextBox { Text = config.ApiKey ?? "", Height = 32, Padding = new Thickness(6, 4, 6, 4), Background = new SolidColorBrush(Color.FromRgb(26, 31, 46)), Foreground = new SolidColorBrush(Colors.White), BorderBrush = new SolidColorBrush(Color.FromRgb(59, 130, 246)), Margin = new Thickness(0, 0, 0, 16) }; panel.Children.Add(keyInput); Button saveBtn = new Button { Content = "Save & Connect", Height = 36, Background = new SolidColorBrush(Color.FromRgb(16, 185, 129)), Foreground = new SolidColorBrush(Colors.White), FontWeight = FontWeights.Bold }; saveBtn.Click += (s, ev) => { string inputKey = keyInput.Text.Trim(); if (string.IsNullOrEmpty(inputKey)) { MessageBox.Show("Please enter a valid API Key.", "Error"); return; } config.ApiKey = inputKey; SaveConfig(); dialog.Close(); StartEngine(); }; panel.Children.Add(saveBtn); dialog.Content = panel; dialog.ShowDialog(); } catch (Exception ex) { Print(string.Format("[Profit Plus] Dialog Error: {0}", ex.Message)); } } // ============ PLAYBACK DETECTION ============ private bool IsPlaybackAccount(Account acc) { if (acc == null) return false; try { string accName = acc.Name?.ToLower() ?? ""; if (accName.Contains("playback") || accName.Contains("replay")) return true; if (acc.Connection != null) { string providerName = acc.Connection.Options?.Provider.ToString()?.ToLower() ?? ""; if (providerName.Contains("playback") || providerName.Contains("replay")) return true; } } catch { } return false; } private bool IsAccountValid(Account acc) { if (acc == null) return false; if (IsPlaybackAccount(acc)) return false; if (!string.IsNullOrEmpty(config.TargetAccount) && !acc.Name.Equals(config.TargetAccount, StringComparison.OrdinalIgnoreCase)) return false; if (config.OnlyConnectedAccounts && acc.ConnectionStatus != ConnectionStatus.Connected) return false; if (config.FilterBlownAccounts) { try { if (acc.Get(AccountItem.CashValue, Currency.UsDollar) < config.MinBalanceThreshold) return false; } catch { } } return true; } private void RefreshAccountSubscriptions() { if (string.IsNullOrWhiteSpace(config.ApiKey)) return; lock (syncLock) { lock (Account.All) { foreach (Account acc in Account.All) { bool isValid = IsAccountValid(acc); bool isSubscribed = subscribedAccounts.Contains(acc); if (isValid && !isSubscribed) { acc.ExecutionUpdate += OnExecutionUpdate; subscribedAccounts.Add(acc); Print(string.Format("[Profit Plus] Monitoring Account: {0}", acc.Name)); } else if (!isValid && isSubscribed) { acc.ExecutionUpdate -= OnExecutionUpdate; subscribedAccounts.Remove(acc); SendDisconnectSignal(acc.Name); Print(string.Format("[Profit Plus] Ignored/Blocked Account: {0}", acc.Name)); } } } } } private void OnTimerTick(object state) { SafeInvoke(() => { RefreshAccountSubscriptions(); SendHeartbeat(); }); } private void SendDisconnectSignal(string accountName) { string json = string.Format( "{{\"event_type\":\"account_disconnected\",\"data\":{{\"account\":\"{0}\",\"timestamp\":\"{1}\"}}}}", EscapeJson(accountName), DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ") ); SendRawJsonToApi(json); } private void OnExecutionUpdate(object sender, ExecutionEventArgs e) { try { if (e.Execution == null || e.Execution.Instrument == null || string.IsNullOrWhiteSpace(config.ApiKey)) return; var execution = e.Execution; var account = execution.Account; var accountName = account != null ? account.Name : "Sim101"; // FAILSAFE PLAYBACK BLOCK if (account != null && IsPlaybackAccount(account)) { Print(string.Format("[Profit Plus] ⚠️ Playback trade blocked and ignored: {0}", accountName)); return; } if (account != null && !IsAccountValid(account)) return; string instKey = string.Format("{0}_{1}", accountName, execution.Instrument.FullName); lock (syncLock) { bool isBuy = execution.MarketPosition == MarketPosition.Long || (execution.Order != null && (execution.Order.OrderAction == OrderAction.Buy || execution.Order.OrderAction == OrderAction.BuyToCover)); double pointValue = execution.Instrument.MasterInstrument.PointValue; if (pointValue <= 0) pointValue = 1.0; if (!activeSessions.ContainsKey(instKey)) { activeSessions[instKey] = new PositionSession { AccountName = accountName, InstrumentName = execution.Instrument.FullName, Direction = isBuy ? "long" : "short", CurrentQty = execution.Quantity, TotalEntryQty = execution.Quantity, TotalEntryValue = execution.Price * execution.Quantity, TotalExitQty = 0, TotalExitValue = 0, TotalCommission = execution.Commission, EntryTime = execution.Time }; Print(string.Format("[Profit Plus] Opened {0} {1} x{2} @ {3:F2}", activeSessions[instKey].Direction.ToUpper(), execution.Instrument.FullName, execution.Quantity, execution.Price)); } else { var session = activeSessions[instKey]; bool sameDir = (isBuy && session.Direction == "long") || (!isBuy && session.Direction == "short"); if (sameDir) { session.CurrentQty += execution.Quantity; session.TotalEntryQty += execution.Quantity; session.TotalEntryValue += execution.Price * execution.Quantity; session.TotalCommission += execution.Commission; } else { int closeQty = Math.Min(session.CurrentQty, execution.Quantity); int flipQty = execution.Quantity - closeQty; session.TotalExitQty += closeQty; session.TotalExitValue += execution.Price * closeQty; session.CurrentQty -= closeQty; session.TotalCommission += execution.Commission; session.ExitTime = execution.Time; if (session.CurrentQty <= 0) { SendConsolidatedTrade(session, pointValue); activeSessions.Remove(instKey); if (flipQty > 0) { activeSessions[instKey] = new PositionSession { AccountName = accountName, InstrumentName = execution.Instrument.FullName, Direction = isBuy ? "long" : "short", CurrentQty = flipQty, TotalEntryQty = flipQty, TotalEntryValue = execution.Price * flipQty, TotalExitQty = 0, TotalExitValue = 0, TotalCommission = 0, EntryTime = execution.Time }; } } } } } } catch (Exception ex) { Print(string.Format("[Profit Plus] Tracker Error: {0}", ex.Message)); } } // ============ PROPER UTC TIME CONVERSION ============ private static string FormatDateTimeToUtc(DateTime dt) { if (dt == DateTime.MinValue) return DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); DateTime utcDt = dt.Kind == DateTimeKind.Utc ? dt : DateTime.SpecifyKind(dt, DateTimeKind.Local).ToUniversalTime(); return utcDt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); } private void SendConsolidatedTrade(PositionSession session, double pointValue) { try { double avgEntry = session.TotalEntryValue / session.TotalEntryQty; double avgExit = session.TotalExitQty > 0 ? (session.TotalExitValue / session.TotalExitQty) : avgEntry; double priceDiff = session.Direction == "long" ? (avgExit - avgEntry) : (avgEntry - avgExit); double grossPnl = priceDiff * pointValue * session.TotalEntryQty; double netPnl = grossPnl - session.TotalCommission; string json = string.Format( CultureInfo.InvariantCulture, "{{\"event_type\":\"trade_closed\",\"data\":{{" + "\"tradeId\":\"{0}\"," + "\"instrument\":\"{1}\"," + "\"direction\":\"{2}\"," + "\"quantity\":{3}," + "\"entryPrice\":{4:F4}," + "\"exitPrice\":{5:F4}," + "\"pnl\":{6:F2}," + "\"commission\":{7:F2}," + "\"net_pnl\":{8:F2}," + "\"entryTime\":\"{9}\"," + "\"exitTime\":\"{10}\"," + "\"account\":\"{11}\"" + "}}}}", Guid.NewGuid().ToString("N").Substring(0, 16), EscapeJson(session.InstrumentName), EscapeJson(session.Direction), session.TotalEntryQty, avgEntry, avgExit, grossPnl, session.TotalCommission, netPnl, FormatDateTimeToUtc(session.EntryTime), FormatDateTimeToUtc(session.ExitTime), EscapeJson(session.AccountName) ); SendRawJsonToApi(json); Print(string.Format("[Profit Plus] TRADE: {0} {1} {2} x{3} | Net: {4:C2}", session.AccountName, session.InstrumentName, session.Direction.ToUpper(), session.TotalEntryQty, netPnl)); } catch (Exception ex) { Print(string.Format("[Profit Plus] Send Error: {0}", ex.Message)); } } private async void SendRawJsonToApi(string jsonPayload) { if (string.IsNullOrWhiteSpace(config.ApiKey)) return; try { string url = config.ApiUrl; if (!url.Contains("?key=")) url = url.TrimEnd('/') + "?key=" + config.ApiKey.Trim(); using (var req = new HttpRequestMessage(HttpMethod.Post, url)) { req.Headers.Add("X-API-Key", config.ApiKey.Trim()); req.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); var res = await httpClient.SendAsync(req); if (!res.IsSuccessStatusCode) Print(string.Format("[Profit Plus] API Response: {0}", res.StatusCode)); } } catch (Exception ex) { Print(string.Format("[Profit Plus] Net Error: {0}", ex.Message)); } } private void SendHeartbeat() { lock (syncLock) { foreach (Account acc in subscribedAccounts) { try { double cash = acc.Get(AccountItem.CashValue, Currency.UsDollar); string json = string.Format( CultureInfo.InvariantCulture, "{{\"event_type\":\"heartbeat\",\"data\":{{\"account\":\"{0}\",\"balance\":{1:F2},\"timestamp\":\"{2}\"}}}}", EscapeJson(acc.Name), cash, DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ") ); SendRawJsonToApi(json); } catch { } } } } // ============ JSON STRING HELPERS ============ private static string ExtractJsonString(string json, string key, string fallback) { var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\"([^\"]*)\""); return match.Success ? match.Groups[1].Value : fallback; } private static bool ExtractJsonBool(string json, string key, bool fallback) { var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(true|false)", RegexOptions.IgnoreCase); return match.Success ? bool.Parse(match.Groups[1].Value) : fallback; } private static double ExtractJsonDouble(string json, string key, double fallback) { var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*([0-9.-]+)"); if (match.Success && double.TryParse(match.Groups[1].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double val)) return val; return fallback; } private static string EscapeJson(string s) { if (string.IsNullOrEmpty(s)) return ""; return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n"); } } }