@@ -196,6 +196,179 @@ static void test_cancel_unchanged() {
196196 CHECK (p.find (2 , " A" ) == nullptr );
197197}
198198
199+ // Cross-group isolation: when Group A's CANCEL sibling fills, Group
200+ // B's REDUCE sibling must remain untouched (different oca_name). Both
201+ // the cancel_oca_group helper and the reduce_oca_group helper already
202+ // scope by oca_name, so this is a regression guard for the
203+ // post-fill OCA dispatch in apply_filled_order_to_state.
204+ static void test_cross_group_isolation () {
205+ std::printf (" test_cross_group_isolation\n " );
206+ class CrossGroupProbe : public BacktestEngine {
207+ public:
208+ struct PendingSnap { std::string id; double qty; std::string oca_name; };
209+ std::vector<std::vector<PendingSnap>> pending_per_bar;
210+
211+ CrossGroupProbe () {
212+ initial_capital_ = 1'000'000 ;
213+ default_qty_type_ = QtyType::FIXED ;
214+ default_qty_value_ = 1.0 ;
215+ slippage_ = 0 ;
216+ commission_value_ = 0 ;
217+ pyramiding_ = 100 ;
218+ }
219+ void on_bar (const Bar& bar) override {
220+ (void )bar;
221+ if (bar_index_ == 1 ) {
222+ // Group A: CANCEL siblings (each qty=2)
223+ strategy_order (" A_TP" , true , 2.0 , /* limit=*/ 100.0 ,
224+ std::numeric_limits<double >::quiet_NaN (),
225+ " GRP_A" , /* cancel=*/ 1 );
226+ strategy_order (" A_SL" , true , 2.0 ,
227+ std::numeric_limits<double >::quiet_NaN (),
228+ /* stop=*/ 120.0 ,
229+ " GRP_A" , 1 );
230+ // Group B: REDUCE siblings (each qty=2)
231+ strategy_order (" B_TP" , true , 2.0 , /* limit=*/ 95.0 ,
232+ std::numeric_limits<double >::quiet_NaN (),
233+ " GRP_B" , /* reduce=*/ 2 );
234+ strategy_order (" B_SL" , true , 2.0 ,
235+ std::numeric_limits<double >::quiet_NaN (),
236+ /* stop=*/ 130.0 ,
237+ " GRP_B" , 2 );
238+ }
239+ std::vector<PendingSnap> snap;
240+ for (const auto & po : pending_orders_) {
241+ snap.push_back ({po.id , po.qty , po.oca_name });
242+ }
243+ pending_per_bar.push_back (std::move (snap));
244+ }
245+ const PendingSnap* find (int bar, const std::string& id) const {
246+ if (bar < 0 || bar >= (int )pending_per_bar.size ()) return nullptr ;
247+ for (const auto & s : pending_per_bar[bar]) {
248+ if (s.id == id) return &s;
249+ }
250+ return nullptr ;
251+ }
252+ };
253+ CrossGroupProbe p;
254+ Bar bars[5 ] = {
255+ {100 , 105 , 95 , 100 , 1000 , 60'000 },
256+ {100 , 108 , 95 , 105 , 1000 , 120'000 }, // place orders
257+ {100 , 110 , 90 , 105 , 1000 , 180'000 }, // A_TP @100 fills (qty 2)
258+ {100 , 110 , 85 , 105 , 1000 , 240'000 },
259+ {100 , 110 , 85 , 105 , 1000 , 300'000 },
260+ };
261+ p.run (bars, 5 );
262+
263+ // After bar 2: A_TP filled → A_SL cancelled (Group A).
264+ CHECK (p.find (2 , " A_TP" ) == nullptr );
265+ CHECK (p.find (2 , " A_SL" ) == nullptr );
266+ // Group B siblings MUST still be alive — A's fill is in a different
267+ // OCA group and must not touch them. Note: B_TP's limit at 95 is
268+ // not yet touched (low=90 on bar 2 fills A_TP @ 100 first; B_TP
269+ // would fill on the SAME bar but our run executes the priced
270+ // entries one per bar, so B_TP fires on a later bar).
271+ auto * b_tp = p.find (2 , " B_TP" );
272+ auto * b_sl = p.find (2 , " B_SL" );
273+ if (b_tp == nullptr ) {
274+ // B_TP may have fired same bar; check Group B's cross-group
275+ // isolation by inspecting whether B_SL still has its full qty.
276+ CHECK (b_sl != nullptr );
277+ } else {
278+ // B_TP still pending — verify its qty unchanged (Group A's fill
279+ // doesn't reduce or cancel B's siblings).
280+ CHECK (near (b_tp->qty , 2.0 ));
281+ CHECK (b_sl != nullptr );
282+ CHECK (near (b_sl->qty , 2.0 ));
283+ }
284+ }
285+
286+ // OCA-CANCEL full-fill gate: when a CANCEL-group order fires for less
287+ // qty than its requested ``order.qty`` (because the position is smaller
288+ // than the order size), TV does NOT cancel the remaining siblings until
289+ // the originating order fully fills. The engine guards this by
290+ // comparing ``filled_qty`` against ``order.qty`` in
291+ // apply_filled_order_to_state.
292+ //
293+ // In our engine, RAW_ORDER opposite-direction fills close the FULL
294+ // position regardless of order.qty (apply_raw_order_fill, line 494),
295+ // so for OCA-cancel siblings of size > position the fill IS partial
296+ // and ``filled_qty < order.qty``. With the gate, A_SL stays alive.
297+ // Without the gate, A_SL is wiped immediately.
298+ static void test_cancel_oca_partial_fill_keeps_sibling () {
299+ std::printf (" test_cancel_oca_partial_fill_keeps_sibling\n " );
300+ class PartialFillProbe : public BacktestEngine {
301+ public:
302+ struct PendingSnap { std::string id; double qty; };
303+ std::vector<std::vector<PendingSnap>> pending_per_bar;
304+
305+ PartialFillProbe () {
306+ initial_capital_ = 1'000'000 ;
307+ default_qty_type_ = QtyType::FIXED ;
308+ default_qty_value_ = 1.0 ;
309+ slippage_ = 0 ;
310+ commission_value_ = 0 ;
311+ pyramiding_ = 100 ;
312+ }
313+ void on_bar (const Bar& bar) override {
314+ (void )bar;
315+ // Bar 1: open long qty 2.
316+ if (bar_index_ == 1 ) {
317+ strategy_entry (" L" , true ,
318+ std::numeric_limits<double >::quiet_NaN (),
319+ std::numeric_limits<double >::quiet_NaN (),
320+ 2.0 , " long entry" );
321+ }
322+ // Bar 2: place A_TP (limit, short, qty=4 — bigger than
323+ // position) and A_SL (stop, short, qty=4) in OCA-CANCEL.
324+ // A_SL has stop BELOW the bar range (80) so only A_TP
325+ // (limit=100) is touched at fire time. When A_TP fires,
326+ // the engine fills a SHORT order against the long position
327+ // — this closes the position (qty=2), not the full order
328+ // qty 4. filled_qty (2) < order.qty (4) → fully_filled =
329+ // false → cancel_oca_group should NOT run. A_SL remains
330+ // pending.
331+ if (bar_index_ == 2 && position_side_ == PositionSide::LONG ) {
332+ strategy_order (" A_TP" , false , 4.0 , /* limit=*/ 100.0 ,
333+ std::numeric_limits<double >::quiet_NaN (),
334+ " GRP_A" , /* cancel=*/ 1 );
335+ strategy_order (" A_SL" , false , 4.0 ,
336+ std::numeric_limits<double >::quiet_NaN (),
337+ /* stop=*/ 80.0 ,
338+ " GRP_A" , 1 );
339+ }
340+ std::vector<PendingSnap> snap;
341+ for (const auto & po : pending_orders_) {
342+ snap.push_back ({po.id , po.qty });
343+ }
344+ pending_per_bar.push_back (std::move (snap));
345+ }
346+ const PendingSnap* find (int bar, const std::string& id) const {
347+ if (bar < 0 || bar >= (int )pending_per_bar.size ()) return nullptr ;
348+ for (const auto & s : pending_per_bar[bar]) {
349+ if (s.id == id) return &s;
350+ }
351+ return nullptr ;
352+ }
353+ };
354+ PartialFillProbe p;
355+ Bar bars[5 ] = {
356+ {100 , 105 , 95 , 100 , 1000 , 60'000 },
357+ {100 , 105 , 95 , 100 , 1000 , 120'000 }, // place L
358+ {100 , 105 , 95 , 100 , 1000 , 180'000 }, // place A_TP/A_SL while long
359+ {100 , 110 , 90 , 105 , 1000 , 240'000 }, // A_TP @100 fires; partial fill (only qty 2)
360+ {100 , 110 , 90 , 105 , 1000 , 300'000 },
361+ };
362+ p.run (bars, 5 );
363+
364+ // After bar 3: A_TP filled qty=2 against position size 2. order.qty
365+ // was 4 — so fully_filled=false. A_SL must remain pending.
366+ CHECK (p.find (3 , " A_TP" ) == nullptr ); // A_TP itself is gone (consumed)
367+ auto * a_sl = p.find (3 , " A_SL" );
368+ CHECK (a_sl != nullptr );
369+ if (a_sl) CHECK (near (a_sl->qty , 4.0 )); // unchanged
370+ }
371+
199372// Sanity: oca.none leaves siblings untouched on fill.
200373static void test_none_unchanged () {
201374 std::printf (" test_none_unchanged\n " );
@@ -223,6 +396,8 @@ int main() {
223396 test_reduce_three_sibling ();
224397 test_reduce_full_fill_cascade ();
225398 test_cancel_unchanged ();
399+ test_cross_group_isolation ();
400+ test_cancel_oca_partial_fill_keeps_sibling ();
226401 test_none_unchanged ();
227402
228403 std::printf (" \n %d passed, %d failed\n " , tests_passed, tests_failed);
0 commit comments