// PropSyncNT.cs — PropSync for NinjaTrader 8, version 2.00 // // Copies a LEAD account onto FOLLOWER accounts connected in the same NinjaTrader 8 — Sim accounts and prop // firm accounts that clear through Tradovate or Rithmic (Apex, Tradeify, Take Profit Trader, MyFundedFutures, // Lucid…) — and sends every closed trade of every account to your Propbook journal. Some firms forbid copiers: // check yours at https://propbooktrading.com/firms before copying. Setup guide: https://propbooktrading.com/propsync/ninjatrader // // Install (once): copy this file to Documents\NinjaTrader 8\bin\Custom\Strategies\, then in NinjaTrader open // New → NinjaScript Editor and press F5 to compile. Put propbook-sync.txt (Propbook → PropSync → Futures → // Settings → NinjaTrader) in Documents\NinjaTrader 8\. // Use: open any chart → Strategies → add "PropSyncNT" → Enable. Keep that chart open. Leave "Set in Propbook" // on: every NinjaTrader account shows up in Propbook → PropSync → Futures within 15 seconds, and you pick the // lead, the followers, their size and their guard there. Simulate first, then Live. // // What it copies (per instrument): the lead's net position — followers hold lead × size, capped at their max // contracts, same direction — and, if "Copy stops & targets" is on, the lead's working stop and limit orders at // the same prices. Positions the lead already holds when copying starts are left alone until the lead is flat. // The guard blocks new or larger positions on a follower at or below its cash floor, or once its loss today // reaches its limit; it never blocks a reduction, a close or a stop. // Lead lock: when the lead's loss today reaches the limit set in Propbook, every position of the lead and its // followers is closed and nothing is copied until tomorrow. // No password ever leaves NinjaTrader: Propbook only receives account names, balances, positions and trades. #region Using declarations using System; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Text; using NinjaTrader.Cbi; using NinjaTrader.NinjaScript; #endregion namespace NinjaTrader.NinjaScript.Strategies { public class PropSyncNT : Strategy { private const string Version = "2.00"; private const int ModeOff = 0, ModeSim = 1, ModeLive = 2; private static readonly CultureInfo Inv = CultureInfo.InvariantCulture; private class Follower { public string Name; public double Mult = 1; public int MaxQty = 10; public double Floor; public double DailyMax; public bool CopySlTp = true; } // one open trade per account and instrument, built from executions, sent to the journal when it is flat again private class OpenTrade { public string Id; public int Net; public int MaxQty; public List Lots = new List(); public double Pnl; public double Comm; public DateTime Entered; public string Side; public double Mae; public double Mfe; } private class ClosedTrade { public string Id, Symbol, Side; public int Size; public double Pnl, Mae, Mfe; public DateTime Entered, Exited; public bool HasExcursion; } private readonly object gate = new object(); private readonly HashSet hooked = new HashSet(); private System.Threading.Timer timer; private int ticking; // settings in use (from Propbook, or from the properties when "Set in Propbook" is off) private string leadName = ""; private readonly List followers = new List(); private int mode = ModeOff; private bool copyExisting; private double leadDailyMax; private string syncUrl = "", hookUrl = ""; private DateTime nextSync = DateTime.MinValue, nextJournal = DateTime.MinValue, nextReconcile = DateTime.MinValue; private int pollSec = 15; private bool synced; private string syncErr = "", status = "starting…"; private readonly HashSet baseline = new HashSet(); // lead instruments held when copying started private readonly Dictionary> copies = new Dictionary>(); // lead order id → follower → copy private readonly Dictionary sentAt = new Dictionary(); // follower|instrument → last market order private readonly Dictionary sentTarget = new Dictionary(); private readonly HashSet touched = new HashSet(); // follower|instrument the copier holds private readonly Dictionary simLast = new Dictionary(); // simulation: follower|instrument → what it would hold private DateTime lockedDay = DateTime.MinValue; private readonly HashSet seenExec = new HashSet(); private readonly Dictionary open = new Dictionary(); // account|instrument private readonly Dictionary> outbox = new Dictionary>(); private readonly List events = new List(); // at, kind, message, account protected override void OnStateChange() { if (State == State.SetDefaults) { Name = "PropSyncNT"; Description = "PropSync 2: copies a lead account onto follower accounts, each at its own size, with a guard — set in Propbook — and sends closed trades to your Propbook journal."; Calculate = Calculate.OnEachTick; IsExitOnSessionCloseStrategy = false; SetInPropbook = true; PropbookLink = ""; Journal = true; LeadAccount = "Sim101"; Followers = "Sim102=1"; MaxContracts = 10; GuardFloor = 0; CopyStopsTargets = true; CopyExisting = false; } else if (State == State.DataLoaded) { if (Account == null || Account.Name == "Backtest") return; // never from the Strategy Analyzer try { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; } catch { } if (SetInPropbook) { string u = (PropbookLink ?? "").Trim(); if (u.IndexOf("/api/hook?t=", StringComparison.Ordinal) < 0) u = UrlFromFile(); if (u.IndexOf("/api/hook?t=", StringComparison.Ordinal) < 0) { Print("PropSync: put propbook-sync.txt (Propbook → PropSync → Futures → Settings → NinjaTrader) in " + NinjaTrader.Core.Globals.UserDataDir + ", then enable PropSyncNT again."); status = "propbook-sync.txt not found"; return; } hookUrl = u; syncUrl = u.Replace("/api/hook?t=", "/api/mt5?p=nt&t="); } else UseProperties(); HookAccounts(); timer = new System.Threading.Timer(_ => Tick(), null, 500, 500); Print("PropSync NT " + Version + " started — " + (SetInPropbook ? "set in Propbook" : "lead " + LeadAccount)); } else if (State == State.Terminated) { if (timer != null) { timer.Dispose(); timer = null; } lock (gate) { foreach (Account a in hooked) { a.PositionUpdate -= OnPosition; a.OrderUpdate -= OnOrder; a.ExecutionUpdate -= OnExecution; } hooked.Clear(); } } } protected override void OnBarUpdate() { } // ---- accounts ------------------------------------------------------------------------------------------ private static bool Usable(Account a) { return a != null && a.Name != "Backtest" && !a.Name.StartsWith("Playback", StringComparison.Ordinal); } private void HookAccounts() { List all; lock (Account.All) all = Account.All.Where(Usable).ToList(); lock (gate) foreach (Account a in all) { if (hooked.Contains(a)) continue; hooked.Add(a); a.PositionUpdate += OnPosition; a.OrderUpdate += OnOrder; a.ExecutionUpdate += OnExecution; // today's executions build the open trades; trades already sent are ignored by Propbook List past; lock (a.Executions) past = a.Executions.ToList(); foreach (Execution x in past.OrderBy(x => x.Time)) Record(a, x); Adopt(a); } } // the replayed executions must end where the account really is: a position opened before them (yesterday) // becomes an open trade at its average price, and a replay that doesn't match is dropped private void Adopt(Account a) { List ps; lock (a.Positions) ps = a.Positions.ToList(); var held = new HashSet(); foreach (Position p in ps) { int n = Net(p); if (n == 0) continue; string key = a.Name + "|" + p.Instrument.FullName; held.Add(key); OpenTrade t; if (open.TryGetValue(key, out t) && t.Net == n) continue; t = new OpenTrade { Id = Hash(a.Name + "|adopt|" + p.Instrument.FullName + "|" + p.AveragePrice.ToString(Inv) + "|" + n), Net = n, MaxQty = Math.Abs(n), Entered = DateTime.UtcNow, Side = n > 0 ? "L" : "S" }; t.Lots.Add(new double[] { n, p.AveragePrice }); open[key] = t; } foreach (string key in open.Keys.Where(k => k.StartsWith(a.Name + "|", StringComparison.Ordinal) && !held.Contains(k)).ToList()) open.Remove(key); } private Account Find(string name) { if (string.IsNullOrEmpty(name)) return null; lock (Account.All) return Account.All.FirstOrDefault(a => a.Name == name); } private static int Net(Position p) { return p == null ? 0 : p.MarketPosition == MarketPosition.Long ? p.Quantity : p.MarketPosition == MarketPosition.Short ? -p.Quantity : 0; } private static int NetOf(Account a, Instrument inst) { Position p; lock (a.Positions) p = a.Positions.FirstOrDefault(x => x.Instrument.FullName == inst.FullName); return Net(p); } private double Item(Account a, AccountItem item) { try { return a.Get(item, Currency.UsDollar); } catch { return 0; } } private double TodayPnl(Account a) { return Item(a, AccountItem.RealizedProfitLoss) + Item(a, AccountItem.UnrealizedProfitLoss); } // ---- the 500 ms loop: Propbook sync, lock, reconcile, journal ---------------------------------------------- private void Tick() { if (System.Threading.Interlocked.Exchange(ref ticking, 1) == 1) return; try { DateTime now = DateTime.UtcNow; if (SetInPropbook && syncUrl != "" && now >= nextSync) { HookAccounts(); AppSync(); } lock (gate) { TrackExcursion(); CheckLock(); if (now >= nextReconcile) { nextReconcile = now.AddSeconds(2); Reconcile(); } } if (SetInPropbook && Journal && hookUrl != "" && now >= nextJournal) JournalSync(); } catch (Exception e) { Print("PropSync: " + e.Message); } finally { System.Threading.Interlocked.Exchange(ref ticking, 0); } } // ---- SET IN PROPBOOK: report every account, read back the lead, followers, mode and Flatten counter ----------- private void AppSync() { nextSync = DateTime.UtcNow.AddSeconds(pollSec); string body; lock (gate) body = ReportJson(); string resp; try { resp = Post(syncUrl, body); } catch (WebException e) { var r = e.Response as HttpWebResponse; Problem(r != null && r.StatusCode == HttpStatusCode.Forbidden ? "this propbook-sync.txt is not valid any more — download it again from Propbook" : "can't reach Propbook right now — copying goes on with the last settings"); return; } catch (Exception) { Problem("can't reach Propbook right now — copying goes on with the last settings"); return; } if (resp.IndexOf("ok=1", StringComparison.Ordinal) < 0) { Problem("unexpected answer from Propbook — copying goes on with the last settings"); return; } syncErr = ""; lock (gate) Apply(resp); } private void Problem(string msg) { if (msg == syncErr) return; syncErr = msg; Print("PropSync: " + msg); if (!synced) status = msg; } private void Apply(string resp) { string lead = "", m = "sim"; long flat = 0; double ldm = 0; bool ce = false; var fl = new List(); foreach (string raw in resp.Split('\n')) { string line = raw.Trim(); int eq = line.IndexOf('='); if (eq <= 0) continue; string k = line.Substring(0, eq), v = line.Substring(eq + 1); if (k == "lead") lead = v; else if (k == "mode") m = v; else if (k == "flatten") long.TryParse(v, NumberStyles.Integer, Inv, out flat); else if (k == "poll") { int p; if (int.TryParse(v, NumberStyles.Integer, Inv, out p)) pollSec = Math.Max(10, Math.Min(120, p)); } else if (k == "lead_daily_max") double.TryParse(v, NumberStyles.Float, Inv, out ldm); else if (k == "copy_existing") ce = v == "1"; else if (k == "f") { string[] p = v.Split('|'); if (p.Length < 6 || p[0] == "") continue; var f = new Follower { Name = p[0] }; double.TryParse(p[1], NumberStyles.Float, Inv, out f.Mult); if (f.Mult <= 0) f.Mult = 1; int.TryParse(p[2], NumberStyles.Integer, Inv, out f.MaxQty); if (f.MaxQty <= 0) f.MaxQty = 1; double.TryParse(p[3], NumberStyles.Float, Inv, out f.Floor); double.TryParse(p[4], NumberStyles.Float, Inv, out f.DailyMax); f.CopySlTp = p[5] == "1"; if (f.Name != lead) fl.Add(f); } } int newMode = m == "live" ? ModeLive : m == "sim" ? ModeSim : ModeOff; bool roles = lead != leadName || string.Join(",", fl.Select(f => f.Name)) != string.Join(",", followers.Select(f => f.Name)); if (lead != leadName) AddEvent("role", lead == "" ? "No lead picked in Propbook" : "Lead: " + lead, lead); leadName = lead; followers.Clear(); followers.AddRange(fl); copyExisting = ce; leadDailyMax = Math.Max(0, ldm); if (newMode != mode || roles) StartClean(newMode); // "Flatten all" in Propbook: a counter, remembered on disk so a restart never replays it long done = ReadFlattenSeq(); if (done < 0) WriteFlattenSeq(flat); else if (flat > done) { WriteFlattenSeq(flat); FlattenLinked("Flatten all"); } synced = true; status = leadName == "" ? "no lead yet — pick one in Propbook → PropSync → Futures" : "lead " + leadName + " · " + followers.Count + " follower(s) · " + (mode == ModeLive ? "LIVE" : mode == ModeSim ? "SIMULATION (no order sent)" : "off"); } // a new mode or new roles start clean: what the lead already holds is not copied late private void StartClean(int m) { if (m != mode) AddEvent("mode", m == ModeLive ? "Live: copying for real" : m == ModeSim ? "Simulation: nothing is sent" : "Off: nothing is copied", leadName); mode = m; baseline.Clear(); simLast.Clear(); Account lead = Find(leadName); if (lead != null && !copyExisting) lock (lead.Positions) foreach (Position p in lead.Positions) if (p.MarketPosition != MarketPosition.Flat) baseline.Add(p.Instrument.FullName); } private void UseProperties() { leadName = LeadAccount ?? ""; followers.Clear(); foreach (string part in (Followers ?? "").Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries)) { string[] kv = part.Split('='); var f = new Follower { Name = kv[0].Trim(), MaxQty = MaxContracts, Floor = GuardFloor, CopySlTp = CopyStopsTargets }; if (kv.Length > 1) double.TryParse(kv[1].Trim(), NumberStyles.Any, Inv, out f.Mult); if (f.Mult <= 0) f.Mult = 1; if (f.Name != "" && f.Name != leadName) followers.Add(f); } copyExisting = CopyExisting; mode = ModeLive; StartClean(ModeLive); synced = true; status = "lead " + leadName + " · " + followers.Count + " follower(s) · set in the strategy"; } // ---- copying: positions ------------------------------------------------------------------------------------ private void OnPosition(object sender, PositionEventArgs e) { lock (gate) { Account a = sender as Account; if (a == null || a.Name != leadName) return; string key = e.Position.Instrument.FullName; int leadNet = e.MarketPosition == MarketPosition.Long ? e.Quantity : e.MarketPosition == MarketPosition.Short ? -e.Quantity : 0; if (Locked()) { if (leadNet != 0) FlattenAccount(a, "lead locked for today"); return; } if (baseline.Contains(key)) { if (leadNet != 0) return; baseline.Remove(key); } // flat again: from now on this instrument is copied if (mode == ModeOff) return; foreach (Follower f in followers) SyncPosition(f, e.Position.Instrument, leadNet, true); } } // every 2 s: what an event missed (a refused order, a follower reconnected) is put right private void Reconcile() { if (mode == ModeOff || Locked()) return; Account lead = Find(leadName); if (lead == null) return; var held = new Dictionary(); lock (lead.Positions) foreach (Position p in lead.Positions) if (p.MarketPosition != MarketPosition.Flat) held[p.Instrument.FullName] = p.Instrument; foreach (Follower f in followers) { foreach (Instrument inst in held.Values) if (!baseline.Contains(inst.FullName)) SyncPosition(f, inst, NetOf(lead, inst), false); // instruments this follower copied that the lead no longer holds: close them Account fa = Find(f.Name); if (fa == null) continue; List mine; lock (fa.Positions) mine = fa.Positions.ToList(); foreach (Position p in mine) if (p.MarketPosition != MarketPosition.Flat && touched.Contains(f.Name + "|" + p.Instrument.FullName) && !held.ContainsKey(p.Instrument.FullName)) SyncPosition(f, p.Instrument, 0, false); } } // fromLead: the lead itself just changed this instrument (a flat lead then closes the follower's position in it, // as it always has); the 2-second check only closes positions the copier opened private void SyncPosition(Follower f, Instrument inst, int leadNet, bool fromLead) { Account fa = Find(f.Name); if (fa == null) return; string k = f.Name + "|" + inst.FullName; int target = Math.Sign(leadNet) * Math.Min(f.MaxQty, (int)Math.Round(Math.Abs(leadNet) * f.Mult, MidpointRounding.AwayFromZero)); if (leadNet != 0 && target == 0) target = Math.Sign(leadNet); // a copy is never rounded away if (mode == ModeSim) // logged once per change, nothing sent { int last; simLast.TryGetValue(k, out last); if (last == target) return; AddEvent("sim", "Simulation: " + (target == 0 ? "would close the copy of " + inst.FullName : "would hold " + (target > 0 ? "long " : "short ") + Math.Abs(target) + " " + inst.FullName) + " (lead " + leadNet + ")", f.Name); if (target == 0) simLast.Remove(k); else simLast[k] = target; return; } int have = NetOf(fa, inst); // an order sent less than 2 s ago may not show in the position yet: count it as done DateTime at; int was; if (sentAt.TryGetValue(k, out at) && (DateTime.UtcNow - at).TotalMilliseconds < 2000 && sentTarget.TryGetValue(k, out was)) have = was; if (target == have) { if (target == 0) touched.Remove(k); return; } if (leadNet == 0 && !fromLead && !touched.Contains(k)) return; // a reversal is close-then-open, so the guard still applies to the new side if (have != 0 && target != 0 && Math.Sign(have) != Math.Sign(target)) { Send(fa, inst, have > 0 ? OrderAction.Sell : OrderAction.BuyToCover, Math.Abs(have), "reverse: close"); have = 0; } bool grows = Math.Abs(target) > Math.Abs(have); if (grows) { string why = GuardBlocks(f, fa); if (why != null) { if (!sentAt.ContainsKey(k + "|guard")) AddEvent("guard", "Guard: " + why + " — new " + inst.FullName + " exposure skipped", f.Name); sentAt[k + "|guard"] = DateTime.UtcNow; return; } sentAt.Remove(k + "|guard"); } int diff = target - have; if (diff == 0) return; OrderAction act = diff > 0 ? (have < 0 ? OrderAction.BuyToCover : OrderAction.Buy) : (have > 0 ? OrderAction.Sell : OrderAction.SellShort); Send(fa, inst, act, Math.Abs(diff), "lead " + leadNet); sentAt[k] = DateTime.UtcNow; sentTarget[k] = target; if (target != 0) touched.Add(k); else touched.Remove(k); } // null when a new entry is allowed, else the reason private string GuardBlocks(Follower f, Account fa) { if (f.Floor > 0 && Item(fa, AccountItem.CashValue) <= f.Floor) return "cash value at or below " + f.Floor.ToString("0", Inv); if (f.DailyMax > 0 && -TodayPnl(fa) >= f.DailyMax) return "loss today at its " + f.DailyMax.ToString("0", Inv) + " limit"; return null; } private void Send(Account acct, Instrument inst, OrderAction act, int qty, string why) { if (qty <= 0) return; try { Order o = acct.CreateOrder(inst, act, OrderType.Market, OrderEntry.Automated, TimeInForce.Day, qty, 0, 0, string.Empty, "PropSync", Core.Globals.MaxDate, null); acct.Submit(new[] { o }); AddEvent("copy", act + " " + qty + " " + inst.FullName + " (" + why + ")", acct.Name); } catch (Exception e) { AddEvent("error", "order refused: " + act + " " + qty + " " + inst.FullName + " — " + e.Message, acct.Name); } } // ---- copying: stops and targets ---------------------------------------------------------------------------- private void OnOrder(object sender, OrderEventArgs e) { lock (gate) { Account a = sender as Account; Order lo = e.Order; if (a == null) return; // a follower's order refused by the broker: say so in Propbook (and on Telegram) if (a.Name != leadName) { if (lo.OrderState == OrderState.Rejected && lo.Name == "PropSync" && followers.Any(f => f.Name == a.Name)) AddEvent("error", "order rejected: " + lo.OrderAction + " " + lo.Quantity + " " + lo.Instrument.FullName, a.Name); return; } if (mode != ModeLive || Locked() || lo.OrderType == OrderType.Market || baseline.Contains(lo.Instrument.FullName)) return; string id = lo.OrderId; bool done = lo.OrderState == OrderState.Filled || lo.OrderState == OrderState.Cancelled || lo.OrderState == OrderState.Rejected; Dictionary mine; copies.TryGetValue(id, out mine); if (done) // the lead's order is gone: cancel its copies (fills are handled by the position sync) { if (mine != null) foreach (Order c in mine.Values) if (!Order.IsTerminalState(c.OrderState)) c.Account.Cancel(new[] { c }); copies.Remove(id); return; } if (lo.OrderState != OrderState.Working && lo.OrderState != OrderState.Accepted) return; if (mine == null) { mine = new Dictionary(); copies[id] = mine; } foreach (Follower f in followers) { if (!f.CopySlTp) continue; Account fa = Find(f.Name); if (fa == null) continue; int qty = Math.Max(1, Math.Min(f.MaxQty, (int)Math.Round(lo.Quantity * f.Mult, MidpointRounding.AwayFromZero))); Order c; if (!mine.TryGetValue(f.Name, out c)) // new working stop / limit on the lead → the same on the follower { try { c = fa.CreateOrder(lo.Instrument, lo.OrderAction, lo.OrderType, OrderEntry.Automated, lo.TimeInForce, qty, lo.LimitPrice, lo.StopPrice, string.Empty, "PropSync", Core.Globals.MaxDate, null); fa.Submit(new[] { c }); mine[f.Name] = c; } catch (Exception ex) { AddEvent("error", "stop/target refused: " + ex.Message, f.Name); } continue; } // moved or resized on the lead → change the copy in place (keeps its queue position) if (Order.IsTerminalState(c.OrderState)) continue; if (c.LimitPrice == lo.LimitPrice && c.StopPrice == lo.StopPrice && c.Quantity == qty) continue; c.LimitPriceChanged = lo.LimitPrice; c.StopPriceChanged = lo.StopPrice; c.QuantityChanged = qty; fa.Change(new[] { c }); } } } // ---- lead lock and Flatten all -------------------------------------------------------------------------------- private bool Locked() { return lockedDay == DateTime.Now.Date; } private void CheckLock() { if (leadDailyMax <= 0 || leadName == "" || Locked()) return; Account lead = Find(leadName); if (lead == null) return; double pnl = TodayPnl(lead); if (-pnl < leadDailyMax) return; lockedDay = DateTime.Now.Date; AddEvent("lock", "Lead lock: loss today " + pnl.ToString("0.00", Inv) + " reached the " + leadDailyMax.ToString("0", Inv) + " limit — everything closed, no copying until tomorrow", leadName); FlattenLinked("lead lock"); } private void FlattenLinked(string why) { var names = new List { leadName }; names.AddRange(followers.Select(f => f.Name)); foreach (string n in names.Distinct()) { Account a = Find(n); if (a != null) FlattenAccount(a, why); } touched.Clear(); copies.Clear(); } private void FlattenAccount(Account a, string why) { var list = new Dictionary(); lock (a.Positions) foreach (Position p in a.Positions) if (p.MarketPosition != MarketPosition.Flat) list[p.Instrument.FullName] = p.Instrument; lock (a.Orders) foreach (Order o in a.Orders) if (!Order.IsTerminalState(o.OrderState)) list[o.Instrument.FullName] = o.Instrument; if (list.Count == 0) return; try { a.Flatten(list.Values.ToList()); AddEvent("stop", why + ": " + list.Count + " instrument(s) flattened", a.Name); } catch (Exception e) { AddEvent("error", why + ": flatten refused — " + e.Message, a.Name); } } private string SeqFile() { return Path.Combine(NinjaTrader.Core.Globals.UserDataDir, "propbook-nt-flatten.txt"); } private long ReadFlattenSeq() { try { long v; return File.Exists(SeqFile()) && long.TryParse(File.ReadAllText(SeqFile()).Trim(), out v) ? v : -1; } catch { return -1; } } private void WriteFlattenSeq(long v) { try { File.WriteAllText(SeqFile(), v.ToString(Inv)); } catch { } } // ---- journal: every account's executions become trades ------------------------------------------------------ private void OnExecution(object sender, ExecutionEventArgs e) { lock (gate) { Account a = sender as Account; if (a != null && e.Execution != null) Record(a, e.Execution); } } private void Record(Account a, Execution x) { if (x.Instrument == null || x.Quantity <= 0) return; string xid = a.Name + "|" + (string.IsNullOrEmpty(x.ExecutionId) ? x.Time.Ticks.ToString(Inv) + "|" + x.Price.ToString(Inv) : x.ExecutionId); if (!seenExec.Add(xid)) return; // an amended execution comes back: count it once string key = a.Name + "|" + x.Instrument.FullName; double pv = x.Instrument.MasterInstrument.PointValue; int signed = x.MarketPosition == MarketPosition.Long ? x.Quantity : -x.Quantity; OpenTrade t; if (!open.TryGetValue(key, out t)) { t = new OpenTrade { Id = Hash(xid), Entered = x.Time.ToUniversalTime(), Side = signed > 0 ? "L" : "S" }; open[key] = t; } t.Comm += x.Commission; int left = signed; // closing part: matched against the oldest lots (FIFO) while (left != 0 && t.Net != 0 && Math.Sign(left) != Math.Sign(t.Net) && t.Lots.Count > 0) { double[] lot = t.Lots[0]; // [signed qty, price] int q = Math.Min(Math.Abs(left), Math.Abs((int)lot[0])); t.Pnl += (x.Price - lot[1]) * q * pv * Math.Sign(lot[0]); lot[0] -= Math.Sign(lot[0]) * q; if (lot[0] == 0) t.Lots.RemoveAt(0); t.Net += Math.Sign(left) * q; left -= Math.Sign(left) * q; } if (t.Net == 0 && t.Lots.Count == 0 && (t.Pnl != 0 || t.MaxQty > 0)) { Queue(a.Name, new ClosedTrade { Id = t.Id, Symbol = x.Instrument.MasterInstrument.Name, Side = t.Side, Size = t.MaxQty, Pnl = Math.Round(t.Pnl - t.Comm, 2), Mae = Math.Round(t.Mae, 2), Mfe = Math.Round(t.Mfe, 2), HasExcursion = t.Mae != 0 || t.Mfe != 0, Entered = t.Entered, Exited = x.Time.ToUniversalTime() }); open.Remove(key); if (left != 0) // a reversal opens a new trade with what is left { t = new OpenTrade { Id = Hash(xid + "|r"), Entered = x.Time.ToUniversalTime(), Side = left > 0 ? "L" : "S" }; open[key] = t; } else return; } if (left != 0) { t.Lots.Add(new double[] { left, x.Price }); t.Net += left; t.MaxQty = Math.Max(t.MaxQty, Math.Abs(t.Net)); } } // worst and best open P&L of each open trade, from NinjaTrader's own position P&L private void TrackExcursion() { foreach (Account a in hooked) { List ps; lock (a.Positions) ps = a.Positions.ToList(); foreach (Position p in ps) { if (p.MarketPosition == MarketPosition.Flat) continue; OpenTrade t; if (!open.TryGetValue(a.Name + "|" + p.Instrument.FullName, out t)) continue; double u; try { u = p.GetUnrealizedProfitLoss(PerformanceUnit.Currency); } catch { continue; } if (double.IsNaN(u) || double.IsInfinity(u)) continue; double total = t.Pnl + u; // a partly closed trade counts what it banked if (total < t.Mae) t.Mae = total; if (total > t.Mfe) t.Mfe = total; } } } private void Queue(string acct, ClosedTrade t) { if (!SetInPropbook || !Journal || hookUrl == "") return; List l; if (!outbox.TryGetValue(acct, out l)) { l = new List(); outbox[acct] = l; } l.Add(t); nextJournal = DateTime.UtcNow.AddSeconds(2); } private void JournalSync() { nextJournal = DateTime.UtcNow.AddSeconds(60); Dictionary> batch; lock (gate) { if (outbox.Count == 0) return; batch = outbox.ToDictionary(k => k.Key, k => k.Value.Take(100).ToList()); } foreach (var kv in batch) { Account a = Find(kv.Key); string conn = a != null && a.Connection != null && a.Connection.Options != null ? a.Connection.Options.Name : "NinjaTrader"; var items = new StringBuilder(); foreach (ClosedTrade t in kv.Value) { if (items.Length > 0) items.Append(','); items.Append("{\"id\":\"").Append(t.Id).Append("\",\"symbol\":\"").Append(Esc(t.Symbol)).Append("\",\"side\":\"").Append(t.Side) .Append("\",\"size\":").Append(t.Size.ToString(Inv)).Append(",\"pnl\":").Append(t.Pnl.ToString("0.00", Inv)) .Append(t.HasExcursion ? ",\"mae\":" + t.Mae.ToString("0.00", Inv) + ",\"mfe\":" + t.Mfe.ToString("0.00", Inv) : "") .Append(",\"entered_at\":\"").Append(t.Entered.ToString("yyyy-MM-ddTHH:mm:ssZ", Inv)).Append("\",\"exited_at\":\"").Append(t.Exited.ToString("yyyy-MM-ddTHH:mm:ssZ", Inv)).Append("\"}"); } string body = "{\"source\":\"nt\",\"account\":\"" + Esc(kv.Key) + "\",\"broker\":\"" + Esc(conn) + "\",\"trades\":[" + items + "]}"; try { Post(hookUrl, body); } catch (Exception) { nextJournal = DateTime.UtcNow.AddSeconds(30); return; } // kept: retried in 30 s lock (gate) { List l; if (outbox.TryGetValue(kv.Key, out l)) { l.RemoveRange(0, Math.Min(kv.Value.Count, l.Count)); if (l.Count == 0) outbox.Remove(kv.Key); } AddEvent("journal", kv.Value.Count + " closed trade" + (kv.Value.Count == 1 ? "" : "s") + " sent to your journal", kv.Key); } } lock (gate) if (outbox.Count > 0) nextJournal = DateTime.UtcNow.AddSeconds(2); } // ---- report and helpers -------------------------------------------------------------------------------------- private string ReportJson() { var sb = new StringBuilder("{\"version\":\"" + Version + "\",\"status\":\"" + Esc(status) + "\",\"accounts\":["); bool first = true; foreach (Account a in hooked) { if (!first) sb.Append(','); first = false; string conn = a.Connection != null && a.Connection.Options != null ? a.Connection.Options.Name : ""; sb.Append("{\"name\":\"").Append(Esc(a.Name)).Append("\",\"conn\":\"").Append(Esc(conn)).Append("\",\"currency\":\"").Append(Esc(a.Denomination.ToString())) .Append("\",\"cash\":").Append(Item(a, AccountItem.CashValue).ToString("0.00", Inv)).Append(",\"realized\":").Append(Item(a, AccountItem.RealizedProfitLoss).ToString("0.00", Inv)).Append(",\"positions\":["); bool fp = true; List ps; lock (a.Positions) ps = a.Positions.ToList(); foreach (Position p in ps) { int n = Net(p); if (n == 0) continue; double u = 0; try { u = p.GetUnrealizedProfitLoss(PerformanceUnit.Currency); } catch { } if (double.IsNaN(u) || double.IsInfinity(u)) u = 0; bool copy = touched.Contains(a.Name + "|" + p.Instrument.FullName); if (!fp) sb.Append(','); fp = false; sb.Append("{\"s\":\"").Append(Esc(p.Instrument.FullName)).Append("\",\"q\":").Append(n.ToString(Inv)).Append(",\"avg\":").Append(p.AveragePrice.ToString("0.#####", Inv)) .Append(",\"p\":").Append(u.ToString("0.00", Inv)).Append(",\"c\":").Append(copy ? "1" : "0").Append('}'); } sb.Append("]}"); } sb.Append("],\"events\":["); for (int i = 0; i < events.Count; i++) { if (i > 0) sb.Append(','); sb.Append("{\"at\":").Append(events[i][0]).Append(",\"k\":\"").Append(Esc(events[i][1])).Append("\",\"m\":\"").Append(Esc(events[i][2])).Append("\",\"a\":\"").Append(Esc(events[i][3])).Append("\"}"); } sb.Append("]}"); return sb.ToString(); } private void AddEvent(string kind, string msg, string acct) { long at = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds; events.Add(new[] { at.ToString(Inv), kind, msg.Length > 150 ? msg.Substring(0, 150) : msg, acct ?? "" }); if (events.Count > 40) events.RemoveAt(0); Print("PropSync: " + (string.IsNullOrEmpty(acct) ? "" : acct + " — ") + msg); } private static string Post(string url, string body) { using (var wc = new WebClient()) { wc.Encoding = Encoding.UTF8; wc.Headers[HttpRequestHeader.ContentType] = "application/json"; return wc.UploadString(url, "POST", body); } } private static string Esc(string s) { if (string.IsNullOrEmpty(s)) return ""; var sb = new StringBuilder(); foreach (char c in s) { if (c == '\\' || c == '"') sb.Append('\\').Append(c); else if (c >= 32) sb.Append(c); } return sb.ToString(); } // a short, stable trade id: the same executions always give the same id, so Propbook never counts a trade twice private static string Hash(string s) { ulong h = 14695981039346656037UL; foreach (char c in s) { h ^= c; h *= 1099511628211UL; } return h.ToString("x16"); } // first line of propbook-sync.txt in Documents\NinjaTrader 8 private static string UrlFromFile() { try { string f = Path.Combine(NinjaTrader.Core.Globals.UserDataDir, "propbook-sync.txt"); if (!File.Exists(f)) return ""; string line = File.ReadAllLines(f).FirstOrDefault() ?? ""; return line.Trim(); } catch { return ""; } } #region Properties [NinjaScriptProperty, Display(Name = "Set in Propbook", Description = "Pick the lead, the followers, their size and guard in Propbook → PropSync → Futures (recommended).", Order = 1, GroupName = "PropSync")] public bool SetInPropbook { get; set; } [NinjaScriptProperty, Display(Name = "Propbook link", Description = "Leave empty: read from propbook-sync.txt in Documents\\NinjaTrader 8.", Order = 2, GroupName = "PropSync")] public string PropbookLink { get; set; } [NinjaScriptProperty, Display(Name = "Send closed trades to my journal", Description = "Set in Propbook: every closed trade of every account goes to your Propbook journal.", Order = 3, GroupName = "PropSync")] public bool Journal { get; set; } [NinjaScriptProperty, Display(Name = "Lead account", Description = "Without Propbook only: the account you trade — exactly as named in NinjaTrader.", Order = 11, GroupName = "Without Propbook")] public string LeadAccount { get; set; } [NinjaScriptProperty, Display(Name = "Followers", Description = "Without Propbook only: accounts that copy the lead, with their size: Sim102=1; APEX-123=2", Order = 12, GroupName = "Without Propbook")] public string Followers { get; set; } [NinjaScriptProperty, Range(1, 200), Display(Name = "Max contracts", Description = "Without Propbook only: never hold more than this on a follower.", Order = 13, GroupName = "Without Propbook")] public int MaxContracts { get; set; } [NinjaScriptProperty, Display(Name = "Guard: cash floor ($)", Description = "Without Propbook only: no new or larger position on a follower at or below this cash value. 0 = off.", Order = 14, GroupName = "Without Propbook")] public double GuardFloor { get; set; } [NinjaScriptProperty, Display(Name = "Copy stops & targets", Description = "Without Propbook only: place the lead's working stop and limit orders on each follower too.", Order = 15, GroupName = "Without Propbook")] public bool CopyStopsTargets { get; set; } [NinjaScriptProperty, Display(Name = "Copy positions already open", Description = "Without Propbook only: also copy what the lead already holds when you enable it.", Order = 16, GroupName = "Without Propbook")] public bool CopyExisting { get; set; } #endregion } }