Skip to content

Commit 53f4477

Browse files
luisleo526claude
andcommitted
fix(fills): OCA cancel sweep gated on same name + full fill
cancel_oca_group already scopes by oca_name (correct cross-group isolation), but it was firing on ANY fill of a CANCEL-group order regardless of how much of the order's qty was actually transacted. TradingView cancels CANCEL-group siblings only after the originating order fully fills; with partial fills (e.g. RAW_ORDER opposite-direction sibling whose qty exceeds the open position size, so the engine fills only position_qty contracts and leaves order.qty - position_qty unmet) the siblings must stay armed until the originating order fully fills on a later touch. Compares filled_qty (signed-position delta) against order.qty in apply_filled_order_to_state and skips cancel_oca_group when filled_qty < order.qty. NaN order.qty (default-sized) keeps the prior blanket-cancel behaviour since residual qty is undefined. Adds tests/test_strategy_oca.cpp::test_cross_group_isolation (Group A CANCEL fill must not touch Group B siblings) and ::test_cancel_oca_partial_fill_keeps_sibling (qty=4 short OCA-cancel fired against a qty=2 long position keeps the SL sibling armed for the remaining qty 2). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f43dbae commit 53f4477

2 files changed

Lines changed: 190 additions & 1 deletion

File tree

src/engine_fills.cpp

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,22 @@ void BacktestEngine::apply_filled_order_to_state(
350350
// (type 2, Pine v6 strategy.oca.reduce) reduces siblings' remaining
351351
// qty by the qty just filled — only siblings whose qty drops to 0
352352
// are removed. See TradingView Pine v6 docs strategy.oca.reduce.
353+
//
354+
// OCA-cancel full-fill gate (validation_oca/oca-three-way-probe-02):
355+
// TV cancels CANCEL-group siblings only after the originating order
356+
// is FULLY filled, not after the first contract fills. With qty=4
357+
// long + qty=2 sibling A_TP: A_TP fills qty=2, position=2 remaining,
358+
// A_SL stays alive until the second sibling fires. We compare the
359+
// qty actually transacted (``filled_qty``) against the order's
360+
// explicit qty. If the request was default-sized (qty == NaN), we
361+
// can't compute a residual so we conservatively cancel siblings on
362+
// any fill (matches the prior, blanket-cancel behaviour for that
363+
// subset). The OCA group name scoping inside cancel_oca_group /
364+
// reduce_oca_group already isolates groups from each other.
353365
if (!order.oca_name.empty()) {
354-
if (order.oca_type == 1) {
366+
bool fully_filled = std::isnan(order.qty)
367+
|| filled_qty + 1e-12 >= order.qty;
368+
if (order.oca_type == 1 && fully_filled) {
355369
cancel_oca_group(order.oca_name, order.id);
356370
} else if (order.oca_type == 2) {
357371
reduce_oca_group(order.oca_name, order.id, filled_qty);

tests/test_strategy_oca.cpp

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
200373
static 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

Comments
 (0)