diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 4a7c213..a81569f 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -199,6 +199,16 @@ struct PendingOrder { // expires unless an ordinary execution reissues it; priced GTC orders // become ordinary carried orders on the next bar. bool coof_born_at_close_recalc = false; + // KI-67 cascade provenance: true when this order was placed by a MID-BAR + // fill recalc (a recalc chain triggered by a fill that did NOT occur at the + // bar-open tick). Such "cascade" orders are eligible ONLY at the remaining + // EXTREME waypoints (W1/W2) of the historical 4-tick path — never + // intra-segment at an exact level and never at C — for the bar they are + // born on; at bar end they convert to ordinary resting orders. Orders born + // in the BAR-OPEN recalc (or resting at bar start) keep standard exact-level + // semantics and leave this false. Scoped to the historical path; the + // magnifier path (bar_magnifier_enabled_) ignores this bit entirely. + bool coof_born_mid_bar = false; PositionSide created_position_side = PositionSide::FLAT; // True when a successful strategy.close/close_all call earlier in this // same on_bar already targeted live quantity. This remains distinct from @@ -1424,6 +1434,18 @@ class BacktestEngine { bool coof_fill_recalc_active_ = false; bool coof_cursor_is_bar_close_ = false; bool coof_evaluating_path_segment_ = false; + // KI-67: true while the active fill recalc chain was triggered by a fill AT + // the bar-open tick (O). Orders placed while this holds keep STANDARD + // exact-level semantics; orders placed while it is false are MID-BAR cascade + // orders (see PendingOrder::coof_born_mid_bar). + bool coof_recalc_at_bar_open_ = false; + // KI-67: true only during a point-bar evaluation that sits AT an extreme + // waypoint (W1 or W2) of the historical 4-tick path. Cascade orders born + // this bar may fill only while this holds; on segments, at O, at C, and on + // the ordinary-close / POOC-C / margin passes it is false so cascade orders + // are held (they convert to ordinary resting orders at bar end). Set only by + // the historical dispatch; the magnifier path never sets it. + bool coof_at_extreme_waypoint_ = false; bool coof_checkpoint_contains_current_bar_ = false; double coof_cursor_price_ = std::numeric_limits::quiet_NaN(); // Direct strategy.close / POOC fills can occur inside on_bar rather than @@ -2053,10 +2075,12 @@ class BacktestEngine { double broker_cursor_price, bool is_fill_recalc, bool cursor_is_bar_close, + bool recalc_at_bar_open, uint64_t direct_fill_event_budget); uint64_t run_coof_recalc_chain(const Bar& script_bar, double broker_cursor_price, bool cursor_is_bar_close, + bool recalc_at_bar_open, uint64_t triggering_events, uint64_t max_events, uint64_t events_already); diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 7fdc4a9..11b0111 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -191,6 +191,22 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( continue; } + // KI-67 cascade eligibility (historical 4-tick path only). An order + // born in a MID-BAR fill recalc is eligible ONLY at the remaining + // extreme waypoints (W1/W2) of the bar it was born on: never + // intra-segment at an exact level, never at C. Hold it otherwise — + // it stays pending and, once bar_index_ advances past its creation + // bar, becomes an ordinary resting order (a market cascade rolls to + // the next-bar open; a priced one resumes standard semantics). The + // magnifier path (bar_magnifier_enabled_) owns its own tick model + // and is scoped out. + if (!bar_magnifier_enabled_ && coof_scheduler_active_ + && order.coof_born_mid_bar + && order.created_bar == bar_index_ + && !coof_at_extreme_waypoint_) { + continue; + } + auto fill = evaluate_fill_price( order, i, bar, opposing_pass, trail_best_path_state, pass0_opposing_skip_ids); diff --git a/src/engine_run.cpp b/src/engine_run.cpp index af82ea7..419cc3a 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -118,6 +118,7 @@ uint64_t BacktestEngine::execute_coof_script_body( double broker_cursor_price, bool is_fill_recalc, bool cursor_is_bar_close, + bool recalc_at_bar_open, uint64_t direct_fill_event_budget) { restore_coof_script_state(); current_bar_ = script_bar; @@ -136,6 +137,10 @@ uint64_t BacktestEngine::execute_coof_script_body( coof_scheduler_active_ = true; coof_fill_recalc_active_ = is_fill_recalc; coof_cursor_is_bar_close_ = cursor_is_bar_close; + // KI-67: a fill recalc is "bar-open" only when its triggering fill occurred + // at the open tick; orders it places keep standard semantics. Any other + // recalc (segment/extreme/close fill) is mid-bar and places cascade orders. + coof_recalc_at_bar_open_ = is_fill_recalc && recalc_at_bar_open; coof_cursor_price_ = broker_cursor_price; coof_direct_fill_events_remaining_ = direct_fill_event_budget; const uint64_t before = broker_fill_event_seq_; @@ -147,6 +152,7 @@ uint64_t BacktestEngine::execute_coof_script_body( flush_same_bar_close(); } coof_fill_recalc_active_ = false; + coof_recalc_at_bar_open_ = false; coof_direct_fill_events_remaining_ = 0; return broker_fill_event_seq_ - before; } @@ -155,6 +161,7 @@ uint64_t BacktestEngine::run_coof_recalc_chain( const Bar& script_bar, double broker_cursor_price, bool cursor_is_bar_close, + bool recalc_at_bar_open, uint64_t triggering_events, uint64_t max_events, uint64_t events_already) { @@ -169,7 +176,7 @@ uint64_t BacktestEngine::run_coof_recalc_chain( used < max_events ? max_events - used : 0; const uint64_t direct = execute_coof_script_body( script_bar, broker_cursor_price, /*is_fill_recalc=*/true, - cursor_is_bar_close, + cursor_is_bar_close, recalc_at_bar_open, direct_budget); total_events += direct; pending_recalcs += direct; @@ -201,7 +208,18 @@ Bar coof_segment_bar(const Bar& script_bar, double from, double to) { void BacktestEngine::dispatch_bar_calc_on_order_fills() { const Bar script_bar = current_bar_; - constexpr uint64_t kHistoricalPathFillEvents = 4; + // KI-67: TradingView applies NO per-bar fill-event budget. The old fixed + // cap of 4 produced the right ~2-cycle depth by accident but the wrong + // reach (it exact-level-filled cascade brackets on the W2->C segment AND + // truncated legitimate busy-bar resting-order fills). The natural depth cap + // now comes from cascade eligibility: mid-bar cascade orders may fill only + // at the two remaining extreme waypoints, so a bar terminates on its own. + // kNoFillEventBudget disables the direct-fill deferral that the old + // "budget == 0" test used; kCoofLoopGuard is a pure infinite-loop backstop + // (never reached in correct operation — the monotonic waypoint advance plus + // finite fillable-order set guarantee termination), NOT a semantic budget. + constexpr uint64_t kNoFillEventBudget = std::numeric_limits::max(); + constexpr int kCoofLoopGuard = 1 << 20; uint64_t fill_events = 0; int exit_closed_from_bar = -1; bool exit_closed_was_long = false; @@ -210,6 +228,7 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { coof_scheduler_active_ = true; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; + coof_at_extreme_waypoint_ = false; double path[4]; fill_bar_path_points(script_bar, path); @@ -222,9 +241,12 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { bool filled_at_bar_open_point) { const uint64_t before = fill_events; cursor = fill.fill_price; + // A recalc chain triggered by a fill AT the open tick is "bar-open" and + // places STANDARD orders; any other fill point triggers a MID-BAR + // recalc that places cascade orders (PendingOrder::coof_born_mid_bar). fill_events += run_coof_recalc_chain( - script_bar, cursor, cursor_is_close, fill.fill_events, - kHistoricalPathFillEvents, fill_events); + script_bar, cursor, cursor_is_close, filled_at_bar_open_point, + fill.fill_events, kNoFillEventBudget, fill_events); // The carried order's open fill triggers one execution at O, and the // order born in that first execution may also fill at O. Every later // fill—including the first fill when it occurs inside a path segment— @@ -233,9 +255,15 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { filled_at_bar_open_point && before == 0 && fill_events == 1; }; - while (fill_events < kHistoricalPathFillEvents) { + int loop_guard = 0; + while (++loop_guard <= kCoofLoopGuard) { if (evaluate_current_point) { const bool cursor_is_close = next_waypoint >= 4; + // Cascade orders fill only AT an extreme waypoint (W1 = next_waypoint + // 2, W2 = next_waypoint 3); the O point (1) and the C point (>=4) do + // not admit them. + coof_at_extreme_waypoint_ = + (next_waypoint == 2 || next_waypoint == 3); const Bar point = coof_point_bar(script_bar, cursor); current_bar_ = point; CoofFillResult fill = process_next_pending_order( @@ -256,6 +284,8 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { const Bar segment = coof_segment_bar(script_bar, cursor, target); current_bar_ = segment; coof_evaluating_path_segment_ = true; + // No intra-segment exact-level fills for cascade orders. + coof_at_extreme_waypoint_ = false; CoofFillResult fill = process_next_pending_order( segment, /*allow_market_orders=*/false, exit_closed_from_bar, exit_closed_was_long); @@ -280,28 +310,32 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { evaluate_current_point = true; } + // Past the extreme waypoints: neither the ordinary close execution nor the + // POOC-C / margin passes admit cascade orders (they hold to the next bar). + coof_at_extreme_waypoint_ = false; + // The regular historical close execution is still required after all // fill-triggered executions. It starts from the prior committed checkpoint // and becomes this bar's committed Pine state. cursor = path[3]; uint64_t direct = execute_coof_script_body( script_bar, cursor, /*is_fill_recalc=*/false, - /*cursor_is_bar_close=*/true, - fill_events < kHistoricalPathFillEvents - ? kHistoricalPathFillEvents - fill_events : 0); + /*cursor_is_bar_close=*/true, /*recalc_at_bar_open=*/false, + kNoFillEventBudget); // C is the terminal historical tick. Direct fills produced by this - // ordinary-close execution count against the broker-event budget, but do - // not trigger another script body after the bar has ended. + // ordinary-close execution are real broker fills, but do not trigger + // another script body after the bar has ended. commit_coof_script_state(); fill_events += direct; // POOC's close-time market/priced orders share C and must never replay the // already-consumed high/low. Process every ordinary-C sibling at that same - // broker epoch, without a fill-triggered body between siblings, until the - // four historical events are exhausted. + // broker epoch, without a fill-triggered body between siblings, until no + // eligible order remains. if (process_orders_on_close_) { const Bar close_point = coof_point_bar(script_bar, cursor); - while (fill_events < kHistoricalPathFillEvents) { + int c_guard = 0; + while (++c_guard <= kCoofLoopGuard) { current_bar_ = close_point; CoofFillResult fill = process_next_pending_order( close_point, /*allow_market_orders=*/true, @@ -313,7 +347,7 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { // Preserve the existing once-per-script-bar liquidation placement. A // liquidation is itself a broker fill and therefore triggers a C-point - // historical recalc when event budget remains. + // historical recalc. current_bar_ = script_bar; const size_t trades_before_mc = trades_.size(); const uint64_t fill_seq_before_mc = broker_fill_event_seq_; @@ -322,10 +356,11 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { refresh_frozen_default_sizing_after_margin_call(); } const uint64_t margin_events = broker_fill_event_seq_ - fill_seq_before_mc; - if (margin_events > 0 && fill_events < kHistoricalPathFillEvents) { + if (margin_events > 0) { fill_events += run_coof_recalc_chain( - script_bar, cursor, /*cursor_is_bar_close=*/true, margin_events, - kHistoricalPathFillEvents, fill_events); + script_bar, cursor, /*cursor_is_bar_close=*/true, + /*recalc_at_bar_open=*/false, margin_events, + kNoFillEventBudget, fill_events); } // Broker fills and eligible priced GTC orders persist. A margin-call @@ -334,8 +369,10 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { restore_coof_script_state(); coof_scheduler_active_ = false; coof_fill_recalc_active_ = false; + coof_recalc_at_bar_open_ = false; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; + coof_at_extreme_waypoint_ = false; coof_direct_fill_events_remaining_ = 0; coof_checkpoint_contains_current_bar_ = false; history_slot_is_new_ = true; @@ -403,8 +440,10 @@ void BacktestEngine::reset_run_state() { broker_fill_event_seq_ = 0; coof_scheduler_active_ = false; coof_fill_recalc_active_ = false; + coof_recalc_at_bar_open_ = false; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; + coof_at_extreme_waypoint_ = false; coof_cursor_price_ = std::numeric_limits::quiet_NaN(); coof_direct_fill_events_remaining_ = 0; coof_checkpoint_contains_current_bar_ = false; @@ -747,9 +786,12 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( bool filled_at_first_tick) { const uint64_t before = fill_events; cursor = fill.fill_price; + // Magnifier path: coof_born_mid_bar is inert here (the cascade gate is + // guarded by !bar_magnifier_enabled_), but keep provenance consistent — + // a first-tick fill is the magnifier analogue of a bar-open recalc. fill_events += run_coof_recalc_chain( - script_bar, cursor, cursor_is_close, fill.fill_events, - max_fill_events, fill_events); + script_bar, cursor, cursor_is_close, filled_at_first_tick, + fill.fill_events, max_fill_events, fill_events); evaluate_current_point = filled_at_first_tick && before == 0 && fill_events == 1; }; @@ -819,7 +861,7 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( cursor = ticks.back().price; uint64_t direct = execute_coof_script_body( script_bar, cursor, /*is_fill_recalc=*/false, - /*cursor_is_bar_close=*/true, + /*cursor_is_bar_close=*/true, /*recalc_at_bar_open=*/false, fill_events < max_fill_events ? max_fill_events - fill_events : 0); commit_coof_script_state(); // The last real lower-TF close is also terminal: count direct fills but do @@ -849,15 +891,18 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( const uint64_t margin_events = broker_fill_event_seq_ - fill_seq_before_mc; if (margin_events > 0 && fill_events < max_fill_events) { fill_events += run_coof_recalc_chain( - script_bar, cursor, /*cursor_is_bar_close=*/true, margin_events, + script_bar, cursor, /*cursor_is_bar_close=*/true, + /*recalc_at_bar_open=*/false, margin_events, max_fill_events, fill_events); } restore_coof_script_state(); coof_scheduler_active_ = false; coof_fill_recalc_active_ = false; + coof_recalc_at_bar_open_ = false; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; + coof_at_extreme_waypoint_ = false; coof_direct_fill_events_remaining_ = 0; coof_checkpoint_contains_current_bar_ = false; history_slot_is_new_ = true; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index f8e9115..fd0e3da 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -187,6 +187,11 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.created_during_coof_recalc = coof_fill_recalc_active_; order.coof_born_at_close_recalc = coof_fill_recalc_active_ && coof_cursor_is_bar_close_; + // KI-67: a fill recalc that was NOT triggered at the bar-open tick is a + // mid-bar recalc; orders it places are cascade orders (eligible only at the + // remaining extreme waypoints of the historical 4-tick path). + order.coof_born_mid_bar = + coof_fill_recalc_active_ && !coof_recalc_at_bar_open_; order.created_position_side = position_side_; order.created_after_position_close_in_bar = pending_close_qty_in_bar_ > kQtyEpsilon; @@ -800,6 +805,11 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.created_during_coof_recalc = coof_fill_recalc_active_; order.coof_born_at_close_recalc = coof_fill_recalc_active_ && coof_cursor_is_bar_close_; + // KI-67: a fill recalc that was NOT triggered at the bar-open tick is a + // mid-bar recalc; orders it places are cascade orders (eligible only at the + // remaining extreme waypoints of the historical 4-tick path). + order.coof_born_mid_bar = + coof_fill_recalc_active_ && !coof_recalc_at_bar_open_; if (coof_fill_recalc_active_ && coof_scheduler_active_ && std::isfinite(coof_cursor_price_) && position_side_ != PositionSide::FLAT @@ -871,6 +881,11 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double order.created_during_coof_recalc = coof_fill_recalc_active_; order.coof_born_at_close_recalc = coof_fill_recalc_active_ && coof_cursor_is_bar_close_; + // KI-67: a fill recalc that was NOT triggered at the bar-open tick is a + // mid-bar recalc; orders it places are cascade orders (eligible only at the + // remaining extreme waypoints of the historical 4-tick path). + order.coof_born_mid_bar = + coof_fill_recalc_active_ && !coof_recalc_at_bar_open_; order.created_position_side = position_side_; order.created_after_position_close_in_bar = pending_close_qty_in_bar_ > kQtyEpsilon; @@ -1155,6 +1170,11 @@ void BacktestEngine::queue_deferred_close_order(const std::string& id, order.created_during_coof_recalc = coof_fill_recalc_active_; order.coof_born_at_close_recalc = coof_fill_recalc_active_ && coof_cursor_is_bar_close_; + // KI-67: a fill recalc that was NOT triggered at the bar-open tick is a + // mid-bar recalc; orders it places are cascade orders (eligible only at the + // remaining extreme waypoints of the historical 4-tick path). + order.coof_born_mid_bar = + coof_fill_recalc_active_ && !coof_recalc_at_bar_open_; order.created_position_side = position_side_; order.tv_carry_qty = position_qty_; order.comment = comment; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9d4e39f..3c7e840 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,7 @@ set(TEST_SOURCES test_margin_call test_streaming test_calc_on_order_fills + test_coof_cascade_eligibility test_pooc_position_visibility ) diff --git a/tests/test_calc_on_order_fills.cpp b/tests/test_calc_on_order_fills.cpp index a4a5c66..73e25ef 100644 --- a/tests/test_calc_on_order_fills.cpp +++ b/tests/test_calc_on_order_fills.cpp @@ -464,10 +464,12 @@ void test_stop_limit_activation_survives_segment_split() { } } -// Candidate discovery may look beyond the broker cursor, but stop-limit -// activation cannot. A's stop sits beyond the last event-budget cursor (105); -// scanning the full O->H segment must not pre-arm it and allow its 95 limit to -// fill on a later bar that never touches the 108 stop. +// KI-67: with the fixed 4-event budget removed the broker cursor traverses the +// WHOLE O->L->H->C path, so A's stop=108 IS genuinely reached on the L->H leg +// (the bar prints 110) and A activates; its limit=95 then fills on bar index 2 +// when the low reaches 90. (The old budget stopped the cursor at 105 and A +// never armed — a truncation artifact, not TV behaviour.) A is a resting order, +// not a cascade order, so the cascade waypoint gate never applies to it. class StopLimitSpeculationProbe final : public CoofBase { public: void on_bar(const Bar&) override { @@ -493,18 +495,25 @@ void test_stop_limit_activation_commits_only_through_consumed_cursor() { CHECK(p.last_error().empty()); const auto ids = p.open_lot_ids(); - CHECK(ids.size() == 4); - if (ids.size() == 4) { + const auto lpx = p.open_lot_prices(); + CHECK(ids.size() == 5); + if (ids.size() == 5) { CHECK(ids[0] == "M0"); CHECK(ids[1] == "M1"); CHECK(ids[2] == "B103"); CHECK(ids[3] == "B105"); + CHECK(ids[4] == "A"); // KI-67: A's stop=108 is truly reached; it + CHECK(near(lpx[4], 95.0)); // arms and its limit fills at 95 on bar 2. } } // The legacy one-priced-entry-per-bar throttle predates COOF. A priced entry // born in a fill recalc belongs to the new broker epoch and may itself fill, // recalc, and place another priced entry on the remaining same-bar segment. +// KI-67: L1 is placed by the bar-OPEN recalc (standard: exact fill at 105); +// L2 is placed by the MID-BAR recalc that L1's fill triggered, so it is a +// cascade order and GAP-fills at the next extreme waypoint (W2=110), not at its +// interpolated 108 level on the L->H segment. class RecalcPricedEntryCascadeProbe final : public CoofBase { public: void on_bar(const Bar&) override { @@ -538,7 +547,9 @@ void test_fill_recalc_priced_entries_bypass_legacy_bar_throttle() { CHECK(ids[2] == "L2"); CHECK(near(px[0], 100.0)); CHECK(near(px[1], 105.0)); - CHECK(near(px[2], 108.0)); + // KI-67: cascade L2 gap-fills at the extreme waypoint W2=110, not at + // its interpolated 108 level inside the L->H segment. + CHECK(near(px[2], 110.0)); } } @@ -850,10 +861,14 @@ void test_pooc_same_tick_requires_close_cursor_or_immediately() { } } -// POOC's legacy same-bar priced-order carve-out applies to orders created by -// the ordinary CLOSE execution. A bracket born in an INTRABAR fill recalc is -// already live at the broker cursor and must use normal remaining-path trigger -// semantics, not the close endpoint's gap/reissue pricing. +// A bracket born in an INTRABAR fill recalc is a KI-67 cascade order: it may +// fill ONLY at a remaining extreme waypoint of the historical 4-tick path, +// never at an interpolated intra-segment level and never at C. Entry stop +// L=105 fills mid-bar (path tie -> O=100,L=90,H=110,C=100), so the bracket is a +// cascade order with only the W2=110 extreme left, and NEITHER leg is reachable +// there (sl=102 needs a fall to 102; tp=112 needs a rise to 112 — 110 delivers +// neither). Both therefore convert to ordinary resting orders and fill on the +// NEXT bar, instead of the old behaviour of exact-level-filling on the segment. class PoocIntrabarBracketProbe final : public CoofBase { public: enum class Leg { STOP, LIMIT }; @@ -869,7 +884,7 @@ class PoocIntrabarBracketProbe final : public CoofBase { if (leg_ == Leg::STOP) { strategy_exit("X", "L", kNaN, 102.0); } else { - strategy_exit("X", "L", 109.0, kNaN); + strategy_exit("X", "L", 112.0, kNaN); } } } @@ -883,26 +898,35 @@ void test_pooc_intrabar_recalc_priced_order_uses_remaining_path() { Bar bars[] = { {100.0, 101.0, 99.0, 100.0, 1000.0, 900'000}, {100.0, 110.0, 90.0, 100.0, 1000.0, 1'800'000}, + {103.0, 113.0, 95.0, 98.0, 1000.0, 2'700'000}, }; + // Cascade sl=102 is not reachable at the only remaining extreme (W2=110); it + // converts to an ordinary resting order and fills at 102 on the NEXT bar + // (bar 2 dips to 95), NOT via an exact-level fill on the bar-1 110->100 leg. PoocIntrabarBracketProbe stop(PoocIntrabarBracketProbe::Leg::STOP); - stop.run(bars, 2); + stop.run(bars, 3); CHECK(stop.last_error().empty()); CHECK(stop.trade_count() == 1); if (stop.trade_count() == 1) { CHECK(near(stop.get_trade(0).entry_price, 105.0)); CHECK(near(stop.get_trade(0).exit_price, 102.0)); - CHECK(stop.get_trade(0).entry_bar_index == stop.get_trade(0).exit_bar_index); + CHECK(stop.get_trade(0).entry_bar_index == 1); + CHECK(stop.get_trade(0).exit_bar_index == 2); } + // Cascade tp=112 is likewise unreachable at W2=110 on bar 1; it converts to + // a resting limit and fills at 112 on bar 2 (which rises to 113), NOT at an + // interpolated level on the bar-1 105->110 segment. PoocIntrabarBracketProbe limit(PoocIntrabarBracketProbe::Leg::LIMIT); - limit.run(bars, 2); + limit.run(bars, 3); CHECK(limit.last_error().empty()); CHECK(limit.trade_count() == 1); if (limit.trade_count() == 1) { CHECK(near(limit.get_trade(0).entry_price, 105.0)); - CHECK(near(limit.get_trade(0).exit_price, 109.0)); - CHECK(limit.get_trade(0).entry_bar_index == limit.get_trade(0).exit_bar_index); + CHECK(near(limit.get_trade(0).exit_price, 112.0)); + CHECK(limit.get_trade(0).entry_bar_index == 1); + CHECK(limit.get_trade(0).exit_bar_index == 2); } } @@ -1357,12 +1381,12 @@ void test_pooc_ordinary_close_reversal_siblings_share_live_c() { CHECK(near(p.signed_size(), -1.0)); } -// An EXPLICIT immediate close produced by the final allowed INTRABAR recalc -// consumes the same four-event historical budget. The carried five-unit fill -// is event 1, so only three one-unit immediate closes may execute; admitting a -// fourth close would silently produce a fifth broker event. This control is -// deliberately non-POOC: its carried entry fills at O, so all recalculations -// occur before the terminal close phase. +// KI-67: TradingView applies NO per-bar fill-event budget. A carried five-unit +// entry fills at O and each fill recalc closes one more unit immediately; with +// the fixed 4-event cap removed, all five one-unit closes execute and the +// position ends flat (the old budget stopped after three, leaving 2 units). +// This control is deliberately non-POOC: its carried entry fills at O, so all +// recalculations occur before the terminal close phase. class RecalcChainBudgetProbe final : public CoofBase { public: void on_bar(const Bar&) override { @@ -1385,8 +1409,9 @@ void test_intrabar_direct_fill_from_last_recalc_respects_event_budget() { p.run(bars, 2); CHECK(p.last_error().empty()); - CHECK(p.trade_count() == 3); - CHECK(near(p.signed_size(), 2.0)); + // KI-67: no fill-event budget — all five one-unit closes execute (was 3). + CHECK(p.trade_count() == 5); + CHECK(near(p.signed_size(), 0.0)); } struct IdentitySnapshot { diff --git a/tests/test_coof_cascade_eligibility.cpp b/tests/test_coof_cascade_eligibility.cpp new file mode 100644 index 0000000..8c7cdc5 --- /dev/null +++ b/tests/test_coof_cascade_eligibility.cpp @@ -0,0 +1,347 @@ +/* + * KI-67: calc_on_order_fills cascade eligibility replaces the fill-event budget. + * + * TradingView has NO per-bar fill-event budget. The historical 4-tick path is + * O -> W1 -> W2 -> C (W1/W2 are the bar's two extremes in proximity order). + * Order eligibility on that path splits by provenance: + * + * - Orders RESTING at bar start, and orders placed by the BAR-OPEN fill + * recalc (the recalc chain triggered by a fill AT the open tick), get + * STANDARD semantics: exact-level fills anywhere along the remaining path. + * - Orders placed by a MID-BAR fill recalc ("cascade orders") are eligible + * ONLY at the remaining EXTREME waypoints (W1/W2): market orders fill AT + * the next extreme (or ROLL to next-bar open when only C remains); + * stop/limit orders gap-fill ONLY at an extreme waypoint tick price (no + * intra-segment exact-level interpolation, and NEVER at C). A cascade + * order that does not fill this bar converts to a normal resting order. + * + * These fixtures pin the two divergences the fixed 4-event budget produced: + * R1/R2 — cascade PRICED / MARKET orders over-thread onto the W2->C segment + * and the C tick (the +302 class); the new rule holds them to the + * remaining extreme or rolls them to the next bar. + * R3 — the budget truncates legitimate busy-bar RESTING-order fills TV + * allows (aureate's deficit direction); the new rule fills them all. + * The G-rows lock behaviour that must NOT change: bar-open-recalc standard + * semantics, the flag-off legacy path, and the magnifier (real lower-TF) path, + * which owns its own tick semantics and is scoped OUT of the cascade gate. + */ + +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +namespace { + +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +bool near(double a, double b, double eps = 1e-9) { + return std::fabs(a - b) <= eps; +} + +class CoofBase : public BacktestEngine { +public: + explicit CoofBase(bool enabled = true) { + calc_on_order_fills_ = enabled; + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 10; + slippage_ = 0; + commission_value_ = 0.0; + } + + double signed_size() const { return signed_position_size(); } + int open_lot_count() const { return static_cast(pyramid_entries_.size()); } + std::vector open_lot_prices() const { + std::vector out; + for (const auto& lot : pyramid_entries_) out.push_back(lot.price); + return out; + } +}; + +// ── R1 ──────────────────────────────────────────────────────────────── +// A cascade bracket whose take-profit level lies STRICTLY inside the final +// W2->C segment. Path (H near): O=100 -> W1=101(H) -> W2=90(L) -> C=95. +// E@100 (bar-open) -> tp bracket @101 exits at W1 -> re-enter E@90 (cascade +// market, fills at extreme W2) -> tp bracket @93 (cascade; 90 < 93 < 95). +// Fixed 4-event engine exact-level fills that second bracket at 93 ON the +// W2->C segment (exit_bar == 1). The new rule holds it: no extreme remains +// after W2, C is ineligible, so it converts to resting and fills on bar 2. +class CascadeBracketW2CProbe final : public CoofBase { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("E", true); + return; + } + if (position_side_ == PositionSide::LONG) { + double tp = trades_.empty() ? 101.0 : 93.0; + strategy_exit("X", "E", /*limit=*/tp, kNaN); + } else if (bar_index_ == 1 && reentries_ < 1) { + strategy_entry("E", true); + ++reentries_; + } + } + +private: + int reentries_ = 0; +}; + +void test_r1_cascade_bracket_does_not_exact_fill_on_w2_c_segment() { + std::printf("test_r1_cascade_bracket_does_not_exact_fill_on_w2_c_segment\n"); + CascadeBracketW2CProbe p; + Bar bars[] = { + {100.0, 101.0, 99.0, 100.0, 1000.0, 900'000}, + {100.0, 101.0, 90.0, 95.0, 1000.0, 1'800'000}, + { 91.0, 94.0, 90.0, 92.0, 1000.0, 2'700'000}, + }; + p.run(bars, 3); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 2); + if (p.trade_count() == 2) { + // Cycle 1: bar-open bracket, standard semantics (unchanged). + CHECK(near(p.get_trade(0).entry_price, 100.0)); + CHECK(near(p.get_trade(0).exit_price, 101.0)); + CHECK(p.get_trade(0).entry_bar_index == 1); + CHECK(p.get_trade(0).exit_bar_index == 1); + // Cycle 2: cascade re-entry at W2=90; its tp=93 is inside W2->C, so the + // exit must NOT occur on bar 1 — it converts to resting and fills bar 2. + CHECK(near(p.get_trade(1).entry_price, 90.0)); + CHECK(near(p.get_trade(1).exit_price, 93.0)); + CHECK(p.get_trade(1).entry_bar_index == 1); + CHECK(p.get_trade(1).exit_bar_index == 2); // RED vs fixed budget (==1) + } +} + +// ── R2 ──────────────────────────────────────────────────────────────── +// A cascade MARKET re-entry born at W2 (only C remains). Path (H near): +// O=100 -> W1=101 -> W2=90 -> C=95. E@100 (bar-open) -> stop bracket @90 exits +// at W2 -> re-enter E (cascade market): the fixed-budget engine fills it at +// the C tick (95); the new rule rolls it to the next bar's open (96). +class CascadeMarketRollProbe final : public CoofBase { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("E", true); + return; + } + if (position_side_ == PositionSide::LONG) { + strategy_exit("X", "E", kNaN, 90.0); // sl stop at the far extreme + } else if (bar_index_ == 1 && reentries_ < 1) { + strategy_entry("E", true); // cascade market re-entry + ++reentries_; + } + } + +private: + int reentries_ = 0; +}; + +void test_r2_cascade_market_only_c_remains_rolls_to_next_open() { + std::printf("test_r2_cascade_market_only_c_remains_rolls_to_next_open\n"); + CascadeMarketRollProbe p; + Bar bars[] = { + {100.0, 101.0, 99.0, 100.0, 1000.0, 900'000}, + {100.0, 101.0, 90.0, 95.0, 1000.0, 1'800'000}, + { 96.0, 97.0, 94.0, 95.0, 1000.0, 2'700'000}, + }; + p.run(bars, 3); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); // only the first cycle closes + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.0)); + CHECK(near(p.get_trade(0).exit_price, 90.0)); + } + // The rolled cascade re-entry fills at bar 2's OPEN (96), not the bar-1 C + // tick (95) the fixed budget would have used. + CHECK(p.open_lot_count() == 1); + if (p.open_lot_count() == 1) { + CHECK(near(p.open_lot_prices().front(), 96.0)); // RED vs budget (95.0) + } + CHECK(near(p.signed_size(), 1.0)); +} + +// ── R3 ──────────────────────────────────────────────────────────────── +// Five RESTING buy-limit orders swept by one bar. Path (L near): +// O=100 -> L=94, sweeping 99/98/97/96/95 in order. The fixed 4-event budget +// truncates the 5th (aureate's deficit direction); the new rule fills all five. +class RestingLimitSweepProbe final : public CoofBase { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + for (int i = 0; i < 5; ++i) { + strategy_entry("E" + std::to_string(i), true, + /*limit=*/99.0 - i); + } + } + } +}; + +void test_r3_more_than_four_resting_fills_are_not_budget_truncated() { + std::printf("test_r3_more_than_four_resting_fills_are_not_budget_truncated\n"); + RestingLimitSweepProbe p; + Bar bars[] = { + {100.0, 101.0, 99.0, 100.0, 1000.0, 900'000}, + {100.0, 120.0, 94.0, 100.0, 1000.0, 1'800'000}, + }; + p.run(bars, 2); + + CHECK(p.last_error().empty()); + CHECK(p.open_lot_count() == 5); // RED vs fixed budget (==4) + CHECK(near(p.signed_size(), 5.0)); +} + +// ── G3 ──────────────────────────────────────────────────────────────── +// Bar-open-recalc order keeps STANDARD semantics: a bracket born when a +// carried market entry fills at the open exact-level fills at its stop within +// the same bar (green before AND after — provenance is bar-open, not mid-bar). +class BarOpenBracketProbe final : public CoofBase { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("L", true); + } + if (position_side_ == PositionSide::LONG) { + strategy_exit("X", "L", kNaN, 99.0); // exact-level sl + } + } +}; + +void test_g3_bar_open_recalc_bracket_keeps_exact_level_fill() { + std::printf("test_g3_bar_open_recalc_bracket_keeps_exact_level_fill\n"); + BarOpenBracketProbe p; + Bar bars[] = { + {100.0, 101.0, 99.0, 100.0, 1000.0, 900'000}, + {100.0, 110.0, 90.0, 105.0, 1000.0, 1'800'000}, + }; + p.run(bars, 2); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.0)); + CHECK(near(p.get_trade(0).exit_price, 99.0)); // exact, not a waypoint + CHECK(p.get_trade(0).exit_bar_index == 1); + } +} + +// ── G4 ──────────────────────────────────────────────────────────────── +// calc_on_order_fills=false path is completely untouched by the cascade gate. +class LegacyProbe final : public CoofBase { +public: + explicit LegacyProbe() : CoofBase(/*enabled=*/false) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", true); + if (position_side_ == PositionSide::LONG) { + strategy_exit("X", "L", kNaN, 99.0); + } + } +}; + +void test_g4_flag_off_path_is_legacy_identical() { + std::printf("test_g4_flag_off_path_is_legacy_identical\n"); + LegacyProbe p; + // Legacy (no intrabar recalc): the market entry fills at bar 1's open; the + // sl stop placed that bar rests and fills on bar 2 — no same-bar recalc. + Bar bars[] = { + {100.0, 101.0, 99.0, 100.0, 1000.0, 900'000}, + {100.0, 110.0, 95.0, 105.0, 1000.0, 1'800'000}, + {100.0, 101.0, 98.0, 100.0, 1000.0, 2'700'000}, + }; + p.run(bars, 3); + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.0)); + CHECK(near(p.get_trade(0).exit_price, 99.0)); + CHECK(p.get_trade(0).exit_bar_index == 2); + } +} + +// ── G5 ──────────────────────────────────────────────────────────────── +// The magnifier (real lower-TF) path owns its own tick semantics and is scoped +// OUT of the historical cascade gate (the gate is guarded by +// !bar_magnifier_enabled_). A recalc-created bracket under magnifier still +// fills at its exact stop level off the real sub-bar ticks — green before AND +// after. (The full test_calc_on_order_fills magnifier suite is the broader +// magnifier-regression guard; this pins the KI-67 scoping directly.) +class MagnifierBracketProbe final : public CoofBase { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("L", true); + } + if (position_side_ == PositionSide::LONG) { + strategy_exit("X", "L", kNaN, /*stop=*/99.0); + } + } +}; + +void test_g5_magnifier_path_is_untouched_by_cascade_gate() { + std::printf("test_g5_magnifier_path_is_untouched_by_cascade_gate\n"); + MagnifierBracketProbe p; + // Real lower-TF magnifier data (matches the known-good recalc-bracket + // magnifier contract): market entry fills at the entry bar's first tick + // (100); the recalc-created stop sees only real ticks and fills at 99. + Bar lower[] = { + {100.0, 101.0, 99.0, 100.0, 500.0, 60'000}, + {100.0, 101.0, 99.0, 100.0, 500.0, 120'000}, + {100.0, 102.0, 98.0, 101.0, 500.0, 180'000}, + {101.0, 103.0, 100.0, 102.0, 500.0, 240'000}, + }; + p.run(lower, 4, "1", "2", /*bar_magnifier=*/true, + /*magnifier_samples=*/4, MagnifierDistribution::ENDPOINTS); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.0)); + CHECK(near(p.get_trade(0).exit_price, 99.0)); // exact under magnifier + } +} + +} // namespace + +int main() { + test_r1_cascade_bracket_does_not_exact_fill_on_w2_c_segment(); + test_r2_cascade_market_only_c_remains_rolls_to_next_open(); + test_r3_more_than_four_resting_fills_are_not_budget_truncated(); + test_g3_bar_open_recalc_bracket_keeps_exact_level_fill(); + test_g4_flag_off_path_is_legacy_identical(); + test_g5_magnifier_path_is_untouched_by_cascade_gate(); + + if (tests_failed == 0) { + std::printf("test_coof_cascade_eligibility PASSED (%d checks)\n", + tests_passed); + return 0; + } + std::printf("test_coof_cascade_eligibility FAILED (%d failed, %d passed)\n", + tests_failed, tests_passed); + return 1; +}