From 1a5692611fde69cba0f4ab2021f2e32d5db2cab8 Mon Sep 17 00:00:00 2001 From: mkalhitti-cloud Date: Wed, 4 Mar 2026 20:44:41 -0800 Subject: [PATCH 1/2] Build 950: OCO Cascade Fix -- Resilient Bracket Replacement FSM - Add TargetSnapshot class for bracket state capture - Extend PendingStopReplacement with CapturedTargets - Fix follower stop-cancel black hole in HandleMatchedFollowerOrder - Add RestoreCascadedTargets via TriggerCustomEvent - Fix CreateNewStopOrder to re-link OcoGroupId - Add bracket restore to V8.30 timeout fallback path Co-Authored-By: Claude Sonnet 4.6 --- docs/brain/walkthrough.md | 43 ++++++++++++++ src/V12_002.Orders.Callbacks.cs | 38 +++++++++++++ src/V12_002.Orders.Management.cs | 96 +++++++++++++++++++++++++++++++- src/V12_002.Trailing.cs | 52 +++++++++++++++++ src/V12_002.cs | 14 ++++- 5 files changed, 240 insertions(+), 3 deletions(-) diff --git a/docs/brain/walkthrough.md b/docs/brain/walkthrough.md index ec0d5c1f..7267e471 100644 --- a/docs/brain/walkthrough.md +++ b/docs/brain/walkthrough.md @@ -45,3 +45,46 @@ The 1,806-line `Entries.cs` has been surgically partitioned into 6 mode-specific 1. **Compile**: Press F5 in NinjaTrader to verify Phase 7 partial class structure compiles. 2. **Live Deployment**: Deploy to a single PA account for "Live Smoke Test." 3. **Performance Audit**: Begin tracking P/L symmetry across the 20-account fleet. + +## Build 950: OCO Cascade Fix -- Resilient Bracket Replacement FSM + +### Problem +When UpdateStopOrder cancelled a follower stop for BE/trail replacement, broker-native OCO +(OcoGroupId shared across stop + all targets) auto-cancelled T1/T2/T3. Simultaneously, +follower stop-cancel events arrive via OnAccountOrderUpdate -- which only checked +_followerReplaceSpecs (entry FSM), NOT pendingStopReplacements. Result: no new stop for +followers, no targets either. V8.30 5-second timeout eventually fired an emergency stop but +with no OCO group and no targets -- naked bracket. + +### Fix: Two-Part Resilient Bracket Replacement FSM + +**Part 1 -- Follower stop black hole (HandleMatchedFollowerOrder):** +Added pendingStopReplacements lookup in HandleMatchedFollowerOrder (Orders.Callbacks.cs). +When a follower stop cancel matches OldOrder, CreateNewStopOrder is called immediately -- +same logic as HandleOrderCancelled does for master accounts. + +**Part 2 -- OCO cascade target restoration (RestoreCascadedTargets):** +Extended PendingStopReplacement with CapturedTargets[] (TargetSnapshot array: TargetNum, +Price, Qty, Order ref). Populated in UpdateStopOrder before cancel is issued. +After new stop is created (on any path: normal callback, follower callback, V8.30 timeout), +RestoreCascadedTargets() is scheduled via TriggerCustomEvent. It checks each captured +Order.OrderState -- if Cancelled, the target was OCO-cascade-killed and is re-submitted +with the same OcoGroupId and same price/qty. + +**Part 3 -- CreateNewStopOrder OcoGroupId fix:** +New stop now includes pos.OcoGroupId so it re-enters the broker OCO bracket. Restored +targets also use OcoGroupId -- full bracket linkage is restored. + +### Files Changed +- src/V12_002.cs -- TargetSnapshot class, PendingStopReplacement extended, BUILD_TAG = "950" +- src/V12_002.Trailing.cs -- target snapshot in UpdateStopOrder, restore in V8.30 timeout +- src/V12_002.Orders.Callbacks.cs -- follower stop handler + master bracket restore +- src/V12_002.Orders.Management.cs -- RestoreCascadedTargets(), CreateNewStopOrder OcoId fix + +### Verification +1. Sim session: Enter 4-contract position, verify bracket (Stop + T1/T2/T3) all Working +2. Send BE_CUSTOM via IPC -- confirm logs show "[B950] Target T1 restored", "[B950] Target T2 restored" +3. Confirm new stop in stopOrders[entryName], new targets in target1Orders/target2Orders +4. REAPER must NOT fire emergency stop (no naked position) +5. Let T1 fill -- confirm stop reduces to 3 contracts, T2/T3 still Working +6. Let stop fill -- confirm remaining targets cancelled by existing manual OCO loop diff --git a/src/V12_002.Orders.Callbacks.cs b/src/V12_002.Orders.Callbacks.cs index 29525d6e..d76699ff 100644 --- a/src/V12_002.Orders.Callbacks.cs +++ b/src/V12_002.Orders.Callbacks.cs @@ -370,7 +370,16 @@ private bool HandleOrderCancelled(Order order) if (kvp.Value.OldOrder == order && activePositions.TryGetValue(kvp.Key, out var pos)) { if (pos.RemainingContracts > 0) + { CreateNewStopOrder(kvp.Key, pos.RemainingContracts, kvp.Value.StopPrice, kvp.Value.Direction); + // Build 950: Restore OCO-cascade-cancelled targets after stop replacement. + if (kvp.Value.BracketRestorationNeeded && kvp.Value.CapturedTargets != null) + { + TargetSnapshot[] _mSnap = kvp.Value.CapturedTargets; + string _mKey = kvp.Key; + TriggerCustomEvent(o => RestoreCascadedTargets(_mKey, _mSnap), null); + } + } if (pendingStopReplacements.TryRemove(kvp.Key, out _)) Interlocked.Decrement(ref pendingReplacementCount); handled = true; break; @@ -578,6 +587,35 @@ private void HandleMatchedFollowerOrder(string matchedEntry, PositionInfo matche } else { + // Build 950: Follower stop replacement -- mirrors HandleOrderCancelled master path. + // Follower stop cancels arrive via OnAccountOrderUpdate (not OnOrderUpdate), so + // HandleOrderCancelled never fires for them. Match pendingStopReplacements here. + // This block is in the else branch because stop orders are not in entryOrders. + if (order.Name.StartsWith("Stop_") || order.Name.StartsWith("S_")) + { + foreach (var _psr in pendingStopReplacements.ToArray()) + { + if (_psr.Value.OldOrder == order) + { + PositionInfo _rPos; + if (activePositions.TryGetValue(_psr.Key, out _rPos) && _rPos.RemainingContracts > 0) + { + int _rQty; + lock (stateLock) { _rQty = _rPos.RemainingContracts; } + CreateNewStopOrder(_psr.Key, _rQty, _psr.Value.StopPrice, _psr.Value.Direction); + if (_psr.Value.BracketRestorationNeeded && _psr.Value.CapturedTargets != null) + { + TargetSnapshot[] _snap = _psr.Value.CapturedTargets; + string _rKey = _psr.Key; + TriggerCustomEvent(o => RestoreCascadedTargets(_rKey, _snap), null); + } + } + if (pendingStopReplacements.TryRemove(_psr.Key, out _)) + Interlocked.Decrement(ref pendingReplacementCount); + return; + } + } + } Print(string.Format("[SIMA] Follower order terminal: {0} on {1} ({2}) | Id={3}", order.Name, acctName, reason, order.OrderId)); RemoveGhostOrderRef(order, reason); } diff --git a/src/V12_002.Orders.Management.cs b/src/V12_002.Orders.Management.cs index 79f4bf55..249b4fd3 100644 --- a/src/V12_002.Orders.Management.cs +++ b/src/V12_002.Orders.Management.cs @@ -519,20 +519,26 @@ private void CreateNewStopOrder(string entryName, int quantity, double stopPrice // V12.3: Route to correct account (fleet follower vs local) if (activePositions.TryGetValue(entryName, out var pos) && pos.IsFollower && pos.ExecutingAccount != null) { + // Build 950: Re-link replacement stop to broker OCO bracket. + string _b950OcoId; + lock (stateLock) { _b950OcoId = pos.OcoGroupId ?? string.Empty; } // Fleet follower: use Account API string sigName = "S_" + entryName; if (sigName.Length > 50) sigName = sigName.Substring(0, 50); newStop = pos.ExecutingAccount.CreateOrder(Instrument, exitAction, - OrderType.StopMarket, TimeInForce.Gtc, quantity, 0, stopPrice, sigName, sigName, null); + OrderType.StopMarket, TimeInForce.Gtc, quantity, 0, stopPrice, _b950OcoId, sigName, null); pos.ExecutingAccount.Submit(new[] { newStop }); } else { + // Build 950: Re-link replacement stop to broker OCO bracket. + string _b950OcoId; + lock (stateLock) { _b950OcoId = pos != null ? (pos.OcoGroupId ?? string.Empty) : string.Empty; } // Local: use SubmitOrderUnmanaged with truncated signal name string suffix = (DateTime.Now.Ticks % 100000000).ToString(); string sigName = "S_" + entryName + "_" + suffix; if (sigName.Length > 50) sigName = sigName.Substring(0, 50); - newStop = SubmitOrderUnmanaged(0, exitAction, OrderType.StopMarket, quantity, 0, stopPrice, "", sigName); + newStop = SubmitOrderUnmanaged(0, exitAction, OrderType.StopMarket, quantity, 0, stopPrice, _b950OcoId, sigName); } if (newStop == null) @@ -568,6 +574,92 @@ private void CreateNewStopOrder(string entryName, int quantity, double stopPrice } } + // Build 950: Re-submit profit targets that were OCO-cascade-cancelled during stop replacement. + // Runs on strategy thread via TriggerCustomEvent. Checks Order.OrderState directly on the + // captured Order object -- avoids dict-timing races with RemoveGhostOrderRef. + private void RestoreCascadedTargets(string entryName, TargetSnapshot[] capturedTargets) + { + if (capturedTargets == null || capturedTargets.Length == 0) return; + + PositionInfo pos; + if (!activePositions.TryGetValue(entryName, out pos)) return; + + bool entryFilled; + int remainingContracts; + MarketPosition direction; + bool isFollower; + Account executingAccount; + string ocoGroupId; + + lock (stateLock) + { + entryFilled = pos.EntryFilled; + remainingContracts = pos.RemainingContracts; + direction = pos.Direction; + isFollower = pos.IsFollower; + executingAccount = pos.ExecutingAccount; + ocoGroupId = pos.OcoGroupId; + } + + if (!entryFilled || remainingContracts <= 0) return; + + OrderAction exitAction = direction == MarketPosition.Long + ? OrderAction.Sell : OrderAction.BuyToCover; + string bracketOcoId = ocoGroupId ?? string.Empty; + + foreach (TargetSnapshot snap in capturedTargets) + { + if (snap == null || snap.CapturedOrder == null) continue; + + // Only restore targets the broker OCO cascade-cancelled. + // Filled targets have OrderState.Filled -- skip them. + if (snap.CapturedOrder.OrderState != OrderState.Cancelled + && snap.CapturedOrder.OrderState != OrderState.Rejected) + continue; + + double restoredPrice = Instrument.MasterInstrument.RoundToTickSize(snap.Price); + Order newTarget = null; + + if (isFollower && executingAccount != null) + { + string tSig = SymmetryTrim("T" + snap.TargetNum + "_" + entryName, 40); + Order tOrd = executingAccount.CreateOrder( + Instrument, exitAction, OrderType.Limit, TimeInForce.Gtc, + snap.Qty, restoredPrice, 0, bracketOcoId, tSig, null); + if (tOrd != null) + { + executingAccount.Submit(new[] { tOrd }); + newTarget = tOrd; + } + } + else + { + string tSig = "T" + snap.TargetNum + "_" + entryName; + newTarget = direction == MarketPosition.Long + ? SubmitOrderUnmanaged(0, OrderAction.Sell, OrderType.Limit, + snap.Qty, restoredPrice, 0, bracketOcoId, tSig) + : SubmitOrderUnmanaged(0, OrderAction.BuyToCover, OrderType.Limit, + snap.Qty, restoredPrice, 0, bracketOcoId, tSig); + } + + var tDict = GetTargetOrdersDictionary(snap.TargetNum); + if (tDict != null) + { + if (newTarget != null) + { + tDict[entryName] = newTarget; + Print(string.Format("[B950] Target T{0} restored for {1} @ {2:F2} qty={3}", + snap.TargetNum, entryName, restoredPrice, snap.Qty)); + } + else + { + Print(string.Format("[B950] WARN: Target T{0} restore NULL for {1}", + snap.TargetNum, entryName)); + } + } + } + } + private double ValidateStopPrice(MarketPosition direction, double desiredStopPrice, int level = 0, double entryPrice = 0) { // V12.41: Use real-time price instead of stale bar Close[0] diff --git a/src/V12_002.Trailing.cs b/src/V12_002.Trailing.cs index 671cff46..c539b696 100644 --- a/src/V12_002.Trailing.cs +++ b/src/V12_002.Trailing.cs @@ -471,6 +471,13 @@ private void CleanupStalePendingReplacements() replacementQty = pos.RemainingContracts; } CreateNewStopOrder(kvp.Key, replacementQty, pending.StopPrice, pending.Direction); + // Build 950: Also restore bracket targets after V8.30 emergency stop. + if (pending.BracketRestorationNeeded && pending.CapturedTargets != null) + { + TargetSnapshot[] _tSnap = pending.CapturedTargets; + string _tKey = kvp.Key; + TriggerCustomEvent(o => RestoreCascadedTargets(_tKey, _tSnap), null); + } } } } @@ -534,6 +541,36 @@ private void UpdateStopOrder(string entryName, PositionInfo pos, double newStopP { // Just update the pending price pending.StopPrice = validatedStopPrice; + // Build 950: Refresh CapturedTargets on the live pending record if not yet populated. + if (!pending.BracketRestorationNeeded) + { + var _b950Refresh = new System.Collections.Generic.List(); + for (int _t2 = 1; _t2 <= 5; _t2++) + { + var _tD2 = GetTargetOrdersDictionary(_t2); + Order _tO2; + if (_tD2 != null && _tD2.TryGetValue(entryName, out _tO2) && _tO2 != null + && (_tO2.OrderState == OrderState.Working || _tO2.OrderState == OrderState.Accepted)) + _b950Refresh.Add(new TargetSnapshot { TargetNum = _t2, Price = _tO2.LimitPrice, Qty = _tO2.Quantity, CapturedOrder = _tO2 }); + } + pending.CapturedTargets = _b950Refresh.Count > 0 ? _b950Refresh.ToArray() : null; + pending.BracketRestorationNeeded = _b950Refresh.Count > 0; + } + } + + // Build 950: Snapshot Working/Accepted targets before cancel for OCO cascade restoration. + { + var _b950Targets = new System.Collections.Generic.List(); + for (int _t = 1; _t <= 5; _t++) + { + var _tD = GetTargetOrdersDictionary(_t); + Order _tO; + if (_tD != null && _tD.TryGetValue(entryName, out _tO) && _tO != null + && (_tO.OrderState == OrderState.Working || _tO.OrderState == OrderState.Accepted)) + _b950Targets.Add(new TargetSnapshot { TargetNum = _t, Price = _tO.LimitPrice, Qty = _tO.Quantity, CapturedOrder = _tO }); + } + newPending.CapturedTargets = _b950Targets.Count > 0 ? _b950Targets.ToArray() : null; + newPending.BracketRestorationNeeded = _b950Targets.Count > 0; } pos.CurrentStopPrice = validatedStopPrice; @@ -566,6 +603,21 @@ private void UpdateStopOrder(string entryName, PositionInfo pos, double newStopP } } + // Build 950: Snapshot Working/Accepted targets before cancel for OCO cascade restoration. + { + var _b950Targets = new System.Collections.Generic.List(); + for (int _t = 1; _t <= 5; _t++) + { + var _tD = GetTargetOrdersDictionary(_t); + Order _tO; + if (_tD != null && _tD.TryGetValue(entryName, out _tO) && _tO != null + && (_tO.OrderState == OrderState.Working || _tO.OrderState == OrderState.Accepted)) + _b950Targets.Add(new TargetSnapshot { TargetNum = _t, Price = _tO.LimitPrice, Qty = _tO.Quantity, CapturedOrder = _tO }); + } + newPending.CapturedTargets = _b950Targets.Count > 0 ? _b950Targets.ToArray() : null; + newPending.BracketRestorationNeeded = _b950Targets.Count > 0; + } + if (pos.ExecutingAccount != null) { pos.ExecutingAccount.Cancel(new[] { currentStop }); diff --git a/src/V12_002.cs b/src/V12_002.cs index b6ab9b8b..f78fdc7c 100644 --- a/src/V12_002.cs +++ b/src/V12_002.cs @@ -41,7 +41,7 @@ namespace NinjaTrader.NinjaScript.Strategies { public partial class V12_002 : Strategy { - public const string BUILD_TAG = "948"; // V12.948: Freeze Prevention (GTC Sweep + Order Adoption + Burst Cap) + public const string BUILD_TAG = "950"; // V12.950: OCO Cascade Fix (bracket restore on V8.30 timeout path) #region Variables @@ -279,6 +279,15 @@ private readonly ConcurrentDictionary private readonly ConcurrentDictionary _citNudgedKeys = new ConcurrentDictionary(); + // Build 950: Target snapshot for OCO cascade detection during stop replacement. + private class TargetSnapshot + { + public int TargetNum; // 1-5 + public double Price; // LimitPrice at snapshot time + public int Qty; // Quantity at snapshot time + public Order CapturedOrder; // Order ref -- check .OrderState for cascade detection + } + #endregion #region Position Info Class @@ -560,6 +569,9 @@ private class PendingStopReplacement public MarketPosition Direction; public Order OldOrder; // Track the old order being cancelled public DateTime CreatedTime; // V8.30: Timeout support - clean up stale replacements + // Build 950: Bracket restoration -- populated before stop cancel is sent. + public TargetSnapshot[] CapturedTargets; // null if no Working targets at cancel time + public bool BracketRestorationNeeded; // true when CapturedTargets is non-null } // V8.22: Thread-Safe UI Snapshot Struct From 7fc6b4ba922005f87eac522b3718d943c353c719 Mon Sep 17 00:00:00 2001 From: "AI M. Khalid" Date: Fri, 6 Mar 2026 13:09:45 -0800 Subject: [PATCH 2/2] fix(954): IPC ghost conn stub, RETEST null state leak, deprecated mode fallback Fix 1 (V12_001.cs): Stub ConnectToStrategy() with early return -- strategy no longer hosts IPC server after Phase 6 pruning. Panel runs in standalone UI mode; SendCommand calls safely no-op via tcpStream null guard. Fix 2 (V12_002.Entries.Retest.cs): Insert return; inside null-order guard after SubmitOrderUnmanaged fails. Prevents retestFiredThisSession latch and SIMA dispatch from arming on a failed order submission. Fix 3 (V12_001.cs): Add else branch in LoadConfig() to normalize both activeMode and selectedConfigMode to RMA when a deprecated/pruned mode is found in saved config. Prevents GetSettings() from loading garbage data from a deleted slot. Build tag incremented to 954. Co-Authored-By: Claude Sonnet 4.6 --- src/V12_001.cs | 17 ++++++++++++++++- src/V12_002.Entries.Retest.cs | 1 + src/V12_002.cs | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/V12_001.cs b/src/V12_001.cs index 3fb09247..2fb036c7 100644 --- a/src/V12_001.cs +++ b/src/V12_001.cs @@ -785,7 +785,19 @@ private void LoadConfig() selectedTargetCount = activeCount; Button modeBtn = GetModeButton(activeMode); - if (modeBtn != null) HighlightModeButton(modeBtn); + if (modeBtn != null) + { + HighlightModeButton(modeBtn); + } + else + { + // [Build 954]: Saved mode is deprecated/unrecognized -- normalize both vars to RMA baseline. + Print("[WARN][954] Unrecognized saved mode '" + activeMode + "' -- falling back to RMA."); + activeMode = "RMA"; + selectedConfigMode = "RMA"; + modeBtn = GetModeButton("RMA"); + if (modeBtn != null) HighlightModeButton(modeBtn); + } // Apply active mode+count settings to UI ApplySettings(fullConfig.GetSettings(activeMode, activeCount)); @@ -2986,6 +2998,9 @@ private void SyncAll_Click(object sender, RoutedEventArgs e) private void ConnectToStrategy() { + // [Build 954]: IPC deprecated -- strategy no longer hosts IPC server (Phase 6 pruning). + // Panel operates in standalone UI mode. All SendCommand calls are safely no-oped via tcpStream null guard. + return; try { lock (tcpLock) diff --git a/src/V12_002.Entries.Retest.cs b/src/V12_002.Entries.Retest.cs index e14f9d31..2d4e6646 100644 --- a/src/V12_002.Entries.Retest.cs +++ b/src/V12_002.Entries.Retest.cs @@ -186,6 +186,7 @@ private void ExecuteRetestEntry(int contracts) { AddExpectedPositionDeltaLocked(ExpKey(Account.Name), -masterDeltaRetest); Print("[ERROR][1102Y-V3] RETEST SubmitOrderUnmanaged NULL for " + entryName + " -- rolled back."); + return; // [Build 954]: Do not latch session or dispatch SIMA for a failed order. } entryOrders[entryName] = entryOrder; diff --git a/src/V12_002.cs b/src/V12_002.cs index f78fdc7c..e9ecbd84 100644 --- a/src/V12_002.cs +++ b/src/V12_002.cs @@ -41,7 +41,7 @@ namespace NinjaTrader.NinjaScript.Strategies { public partial class V12_002 : Strategy { - public const string BUILD_TAG = "950"; // V12.950: OCO Cascade Fix (bracket restore on V8.30 timeout path) + public const string BUILD_TAG = "954"; // V12.954: Bot Audit Fixes (IPC ghost conn, RETEST null state, deprecated mode fallback) #region Variables