diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index d4acbf5..3a0fed2 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -215,14 +215,14 @@ struct PendingOrder { // 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 + // fill recalc (a recalc chain that did not own the first fill event 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. + // in that first 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. // // ENTRY cascade orders use the plain "extreme-waypoint only" reach above. // strategy.exit cascade orders follow the finer KI-67 Model S rule @@ -236,12 +236,12 @@ struct PendingOrder { // lets it EXACT-level fill on every SUBSEQUENT leg, and (when // coof_cascade_inflight_fires) gap-fills it at the in-flight leg-end waypoint. int8_t coof_cascade_seg_i = -1; - // KI-67 exit cascade: true when the exit level lies inside the in-flight - // remainder (ap -> leg-end waypoint) in the trigger direction on a - // NON-terminal in-flight leg, so it gap-fills same-bar AT that waypoint price - // (Model S clause 1: limits better than level, stops worse). False otherwise - // — subsequent legs fill at the exact level and a terminal in-flight leg - // (seg_i == 2) or off-path (seg_i < 0) rolls to the next bar. + // KI-67 exit cascade: true when the exit may gap-fill at the in-flight + // leg-end waypoint. Normally its level lies inside the in-flight remainder + // in the trigger direction (Model S clause 1). The one scoped extension is + // a marketable LIMIT born after a later same-O fill: it is held through leg + // 0 and gets its gap attempt at W1. Marketable STOP never uses that extension. + // Otherwise subsequent legs exact-fill, while a terminal/off-path order rolls. bool coof_cascade_inflight_fires = false; PositionSide created_position_side = PositionSide::FLAT; // Monotonic identity of the live position instance at placement. Side @@ -1602,11 +1602,17 @@ 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). + // KI-67: true only while the active fill recalc owns the FIRST fill event + // at the bar-open tick (O). Orders placed while this holds keep STANDARD + // exact-level semantics. Later fills at that same O, like fills at every + // other path point, are MID-BAR cascades (PendingOrder::coof_born_mid_bar). bool coof_recalc_at_bar_open_ = false; + // True only while executing a fill recalc triggered by a later fill event + // at O, after the first O fill has already consumed bar-open provenance. + // Such a recalc is mid-bar for KI-67 and resumes on leg 0 (O->W1). This bit + // lets strategy.exit apply the one pinned exception: a marketable LIMIT may + // resume at W1, while marketable STOP suppression remains whole-entry-bar. + bool coof_recalc_after_first_open_fill_ = 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 diff --git a/src/engine_run.cpp b/src/engine_run.cpp index a582682..49939c7 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -160,9 +160,9 @@ 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. + // KI-67: only the first fill event at O owns "bar-open" provenance and + // places standard orders. A later fill at the same O, like a fill at any + // segment/extreme/close point, 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; @@ -176,6 +176,7 @@ uint64_t BacktestEngine::execute_coof_script_body( } coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; + coof_recalc_after_first_open_fill_ = false; coof_direct_fill_events_remaining_ = 0; return broker_fill_event_seq_ - before; } @@ -197,9 +198,16 @@ uint64_t BacktestEngine::run_coof_recalc_chain( const uint64_t used = events_already + total_events; const uint64_t direct_budget = used < max_events ? max_events - used : 0; + // Only the first fill event at O owns bar-open provenance. A direct or + // separately-dispatched later fill at that same O is a KI-67 cascade + // recalc whose remaining path starts on leg 0 (O->W1). + const bool first_open_fill_recalc = + recalc_at_bar_open && events_already == 0 && handled == 1; + coof_recalc_after_first_open_fill_ = + recalc_at_bar_open && !first_open_fill_recalc; const uint64_t direct = execute_coof_script_body( script_bar, broker_cursor_price, /*is_fill_recalc=*/true, - cursor_is_bar_close, recalc_at_bar_open, + cursor_is_bar_close, first_open_fill_recalc, direct_budget); total_events += direct; pending_recalcs += direct; @@ -257,6 +265,7 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { coof_hist_path_index_ = -1; coof_cascade_recalc_leg_ = -1; coof_cascade_force_wp_gap_ = false; + coof_recalc_after_first_open_fill_ = false; double path[4]; fill_bar_path_points(script_bar, path); @@ -269,9 +278,9 @@ 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). + // The recalc chain receives O-point provenance, but only its first fill + // event is classified as bar-open. A later fill at the same O is a + // leg-0 cascade (PendingOrder::coof_born_mid_bar). fill_events += run_coof_recalc_chain( script_bar, cursor, cursor_is_close, filled_at_bar_open_point, fill.fill_events, kNoFillEventBudget, fill_events); @@ -427,6 +436,7 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { coof_scheduler_active_ = false; coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; + coof_recalc_after_first_open_fill_ = false; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; @@ -507,6 +517,7 @@ void BacktestEngine::reset_run_state() { coof_scheduler_active_ = false; coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; + coof_recalc_after_first_open_fill_ = false; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; @@ -846,6 +857,7 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( coof_scheduler_active_ = true; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; + coof_recalc_after_first_open_fill_ = false; double cursor = ticks.front().price; int64_t cursor_ts = ticks.front().timestamp; @@ -974,6 +986,7 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( coof_scheduler_active_ = false; coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; + coof_recalc_after_first_open_fill_ = false; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 272465b..efafdcb 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -246,9 +246,9 @@ 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). + // KI-67: a fill recalc without first-O provenance is mid-bar. This includes + // later fills at that same O; 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_; @@ -1110,22 +1110,53 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro 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). + // remaining extreme waypoints of the historical 4-tick path). The new + // later-same-O refinement is priced/non-trail only: a trail born in that + // exact cell retains its established standard/open path reach. Otherwise + // the refinement would newly hold it to W1/W2 instead of letting it arm and + // cross continuously on the remaining legs. Magnifier keeps its own tick + // model and is unchanged. + const bool preserve_later_same_open_trail_provenance = + !bar_magnifier_enabled_ && has_trail_request + && coof_fill_recalc_active_ && !coof_recalc_at_bar_open_ + && coof_recalc_after_first_open_fill_ + && coof_cascade_recalc_leg_ == 0; order.coof_born_mid_bar = - coof_fill_recalc_active_ && !coof_recalc_at_bar_open_; + coof_fill_recalc_active_ && !coof_recalc_at_bar_open_ + && !preserve_later_same_open_trail_provenance; + // A priced exit born after a later fill at the SAME O is already held by + // the KI-67 cascade gate for its in-flight leg 0. The pinned exception is + // LIMIT-only: a marketable limit may resume at W1. A marketable stop keeps + // the established whole-entry-bar suppression (including M1). + const bool later_same_open_priced_exit_on_entry_bar = + !bar_magnifier_enabled_ && order.coof_born_mid_bar + && coof_recalc_after_first_open_fill_ + && coof_cascade_recalc_leg_ == 0 + && position_open_bar_ == bar_index_ + && (!std::isnan(stop_price) || !std::isnan(limit_price)) + && std::isnan(trail_points) && std::isnan(trail_price); + bool stop_marketable_at_coof_cursor = false; + bool limit_marketable_at_coof_cursor = false; + bool later_same_open_marketable_limit = false; if (coof_fill_recalc_active_ && coof_scheduler_active_ && std::isfinite(coof_cursor_price_) && position_side_ != PositionSide::FLAT && position_open_bar_ == bar_index_) { const bool closing_long = position_side_ == PositionSide::LONG; - const bool stop_marketable = !std::isnan(stop_price) + stop_marketable_at_coof_cursor = !std::isnan(stop_price) && (closing_long ? coof_cursor_price_ <= stop_price : coof_cursor_price_ >= stop_price); - const bool limit_marketable = !std::isnan(limit_price) + limit_marketable_at_coof_cursor = !std::isnan(limit_price) && (closing_long ? coof_cursor_price_ >= limit_price : coof_cursor_price_ <= limit_price); - order.coof_suppress_stop_on_entry_bar = stop_marketable; - order.coof_suppress_limit_on_entry_bar = limit_marketable; + later_same_open_marketable_limit = + later_same_open_priced_exit_on_entry_bar + && limit_marketable_at_coof_cursor; + order.coof_suppress_stop_on_entry_bar = + stop_marketable_at_coof_cursor; + order.coof_suppress_limit_on_entry_bar = + limit_marketable_at_coof_cursor + && !later_same_open_marketable_limit; } // KI-67 exit cascade (Model S). Record this mid-bar cascade exit's in-flight // leg so the historical dispatch gate can hold it on that leg's remainder, @@ -1147,6 +1178,13 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.coof_cascade_inflight_fires = internal::cascade_exit_inflight_fires( current_bar_, coof_cursor_price_, si, position_side_, order.stop_price, order.limit_price); + // The second fill at O has already consumed the only same-point refill + // exception. A marketable LIMIT born from that refill is held through + // O->W1 by the cascade gate, then gets one gap attempt at W1. STOP never + // enters this exception and retains its whole-entry-bar suppression. + if (later_same_open_marketable_limit) { + order.coof_cascade_inflight_fires = true; + } } // Position-derived captures use the post-batched-close view (see // live_pos_qty above) so an exit armed after a same-bar strategy.close diff --git a/tests/test_cascade_exit_gapjump.cpp b/tests/test_cascade_exit_gapjump.cpp index 012d93f..3dbf8f4 100644 --- a/tests/test_cascade_exit_gapjump.cpp +++ b/tests/test_cascade_exit_gapjump.cpp @@ -27,11 +27,13 @@ * completeness, but directed exits never hit them. The R-rows below therefore * realise "stop -> waypoint" as the confirmed subsequent-leg exact fill. * - * Marketable-at-placement exits are handled by the pre-existing - * coof_suppress_*_on_entry_bar mechanism (they roll), NOT by a placement-price - * fill — see M1 and the note there. + * Marketable-at-placement exits generally use the pre-existing + * coof_suppress_*_on_entry_bar mechanism (they roll), NOT a placement-price + * fill — see M1. R5-R7 pin the narrow exception for a marketable LIMIT born + * after a later same-O fill; G3 proves marketable STOP remains suppressed and + * G4 preserves the pre-refinement trailing-order path reach. * - * R-rows are RED against b6e4e35 (the exit rolls to the next bar); G/M-rows lock + * R1-R4 are RED against b6e4e35; R5-R7 are RED against ada0ca1. G/M rows lock * behaviour that must NOT change. */ @@ -79,6 +81,7 @@ class CoofBase : public BacktestEngine { commission_value_ = 0.0; } double signed_size() const { return signed_position_size(); } + double average_entry_price() const { return position_entry_price_; } }; // A single cascade cycle: a resting long stop entry at `entry_stop` placed on @@ -263,6 +266,268 @@ void test_g2_bar_open_recalc_exit_exact_level_unchanged() { } } +// ── R5 — an exit at O can trigger a market refill at that SAME O. The refill +// is the second fill event at the open, so orders born from its recalc +// resume the remaining O->W1->W2 path rather than receiving first-open +// provenance. A marketable LIMIT is held through leg 0, gap-fills at +// W1, and its fill-recalc market refill becomes eligible at W2. ────── +class SecondSameOpenRefillProbe final : public CoofBase { +public: + explicit SecondSameOpenRefillProbe(bool combined_bracket = false) + : combined_bracket_(combined_bracket) {} + + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("S", false); // carried market entry -> bar 1 O + return; + } + + if (bar_index_ == 1 && position_side_ == PositionSide::SHORT + && trades_.empty()) { + // Resting short TP. Bar 1 never reaches 99; bar 2 gaps below it, + // so the original position closes at bar 2 O=98. + strategy_exit("R", "S", /*limit=*/99.0, /*stop=*/kNaN); + return; + } + + if (bar_index_ != 2) return; + + if (position_side_ == PositionSide::FLAT && trades_.size() <= 2) { + // After the old position exits this fills at the same O. After the + // newborn position exits at W1 it fills at W2. + strategy_entry("S", false); + return; + } + + if (position_side_ == PositionSide::SHORT && trades_.size() == 1) { + // At the second O fill, limit 99 is already marketable. It must not + // execute at O, but must gap-fill after O->W1 at W1=95. The target- + // shaped variant adds an unmarketable stop at 105. + strategy_exit("R", "S", /*limit=*/99.0, + /*stop=*/(combined_bracket_ ? 105.0 : kNaN)); + } + } + +private: + bool combined_bracket_; +}; + +void test_r5_second_same_open_refill_resumes_remaining_path() { + std::printf("test_r5_second_same_open_refill_resumes_remaining_path\n"); + SecondSameOpenRefillProbe p; + Bar bars[] = { + {100.0, 100.5, 99.5, 100.0, 1000.0, 900'000}, + {100.0, 101.0, 99.5, 100.0, 1000.0, 1'800'000}, + // O=98; low is nearer, so the path is O -> L(W1) -> H(W2) -> C. + { 98.0, 110.0, 95.0, 105.0, 1000.0, 2'700'000}, + }; + p.run(bars, 3); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 2); // RED: baseline -> 1 + if (p.trade_count() == 2) { + CHECK(near(p.get_trade(0).entry_price, 100.0)); + CHECK(near(p.get_trade(0).exit_price, 98.0)); // old exit at O + CHECK(p.get_trade(0).exit_bar_index == 2); + CHECK(near(p.get_trade(1).entry_price, 98.0)); // refill at same O + CHECK(near(p.get_trade(1).exit_price, 95.0)); // risk exit at W1 + CHECK(p.get_trade(1).exit_bar_index == 2); + } + CHECK(p.signed_size() < 0.0); // refill at W2 + CHECK(near(p.average_entry_price(), 110.0)); // RED: baseline -> 98 +} + +// ── R6 — target-shaped short combined bracket: the unmarketable STOP sibling +// must not hide the marketable LIMIT's narrow W1 exception. ────────── +void test_r6_second_same_open_short_combined_bracket() { + std::printf("test_r6_second_same_open_short_combined_bracket\n"); + SecondSameOpenRefillProbe p(/*combined_bracket=*/true); + Bar bars[] = { + {100.0, 100.5, 99.5, 100.0, 1000.0, 900'000}, + {100.0, 101.0, 99.5, 100.0, 1000.0, 1'800'000}, + { 98.0, 110.0, 95.0, 105.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) { + CHECK(near(p.get_trade(1).entry_price, 98.0)); + CHECK(near(p.get_trade(1).exit_price, 95.0)); + CHECK(p.get_trade(1).exit_bar_index == 2); + } + CHECK(p.signed_size() < 0.0); + CHECK(near(p.average_entry_price(), 110.0)); +} + +// ── R7 — long-side mirror of R6: O->H(W1)->L(W2). The second-O combined +// bracket has a marketable long limit and an unmarketable long stop. ─ +class SecondSameOpenLongRefillProbe final : public CoofBase { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("L", true); + return; + } + + if (bar_index_ == 1 && position_side_ == PositionSide::LONG + && trades_.empty()) { + strategy_exit("R", "L", /*limit=*/101.0, /*stop=*/kNaN); + return; + } + + if (bar_index_ != 2) return; + + if (position_side_ == PositionSide::FLAT && trades_.size() <= 2) { + strategy_entry("L", true); + return; + } + + if (position_side_ == PositionSide::LONG && trades_.size() == 1) { + strategy_exit("R", "L", /*limit=*/101.0, /*stop=*/95.0); + } + } +}; + +void test_r7_second_same_open_limit_exception_is_side_symmetric() { + std::printf("test_r7_second_same_open_limit_exception_is_side_symmetric\n"); + SecondSameOpenLongRefillProbe p; + Bar bars[] = { + {100.0, 100.5, 99.5, 100.0, 1000.0, 900'000}, + {100.0, 100.5, 99.5, 100.0, 1000.0, 1'800'000}, + // O=102; high is nearer, so the path is O -> H(W1) -> L(W2) -> C. + {102.0, 105.0, 90.0, 95.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) { + CHECK(near(p.get_trade(0).entry_price, 100.0)); + CHECK(near(p.get_trade(0).exit_price, 102.0)); + CHECK(p.get_trade(0).exit_bar_index == 2); + CHECK(near(p.get_trade(1).entry_price, 102.0)); + CHECK(near(p.get_trade(1).exit_price, 105.0)); + CHECK(p.get_trade(1).exit_bar_index == 2); + } + CHECK(p.signed_size() > 0.0); + CHECK(near(p.average_entry_price(), 90.0)); +} + +// ── G3 — LIMIT-only scope guard. A marketable STOP born after the same second +// O fill keeps the established whole-entry-bar suppression and rolls. ─ +class SecondSameOpenMarketableStopProbe final : public CoofBase { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("S", false); + return; + } + + if (bar_index_ == 1 && position_side_ == PositionSide::SHORT + && trades_.empty()) { + strategy_exit("Old", "S", /*limit=*/99.0, /*stop=*/kNaN); + return; + } + + if (bar_index_ == 2 && position_side_ == PositionSide::FLAT + && trades_.size() == 1) { + strategy_entry("S", false); + return; + } + + if (bar_index_ == 2 && position_side_ == PositionSide::SHORT + && trades_.size() == 1 && coof_fill_recalc_active_) { + // Stop 97 is already breached at the second short fill O=98. It + // must stay dormant for all of bar 2, then gap at bar 3 O=100. + strategy_exit("Stop", "S", /*limit=*/kNaN, /*stop=*/97.0); + } + } +}; + +void test_g3_second_same_open_marketable_stop_stays_suppressed() { + std::printf("test_g3_second_same_open_marketable_stop_stays_suppressed\n"); + SecondSameOpenMarketableStopProbe p; + Bar bars[] = { + {100.0, 100.5, 99.5, 100.0, 1000.0, 900'000}, + {100.0, 101.0, 99.5, 100.0, 1000.0, 1'800'000}, + { 98.0, 110.0, 95.0, 105.0, 1000.0, 2'700'000}, + {100.0, 101.0, 96.0, 100.0, 1000.0, 3'600'000}, + }; + p.run(bars, 4); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 2); + CHECK(near(p.signed_size(), 0.0)); + if (p.trade_count() == 2) { + CHECK(near(p.get_trade(1).entry_price, 98.0)); + CHECK(near(p.get_trade(1).exit_price, 100.0)); + CHECK(p.get_trade(1).exit_bar_index == 3); + } +} + +// ── G4 — the later-same-O refinement is priced/non-trail only. This +// short trail previously had standard/open provenance: it arms on +// O->W1 and crosses at its exact stop level on W1->W2. Newly marking it +// coof_born_mid_bar incorrectly holds it until W2, where the entry-bar +// gap guard prevents the otherwise valid same-bar exit. ──────────── +class SecondSameOpenTrailingExitProbe final : public CoofBase { +public: + SecondSameOpenTrailingExitProbe() { syminfo_mintick_ = 1.0; } + + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("S", false); + return; + } + + if (bar_index_ == 1 && position_side_ == PositionSide::SHORT + && trades_.empty()) { + strategy_exit("Old", "S", /*limit=*/99.0, /*stop=*/kNaN); + return; + } + + if (bar_index_ == 2 && position_side_ == PositionSide::FLAT + && trades_.size() == 1) { + strategy_entry("S", false); + return; + } + + if (bar_index_ == 2 && position_side_ == PositionSide::SHORT + && trades_.size() == 1 && coof_fill_recalc_active_) { + // The second short fill is at O=98. The trail activates at 97 on + // O->L=95, then reverses into its exact 96 stop on L->H. + strategy_exit("Trail", "S", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/1.0, /*trail_offset=*/1.0); + } + } +}; + +void test_g4_second_same_open_trail_keeps_standard_path_reach() { + std::printf("test_g4_second_same_open_trail_keeps_standard_path_reach\n"); + SecondSameOpenTrailingExitProbe p; + Bar bars[] = { + {100.0, 100.5, 99.5, 100.0, 1000.0, 900'000}, + {100.0, 101.0, 99.5, 100.0, 1000.0, 1'800'000}, + // O=98; low is nearer, so the path is O -> L(W1) -> H(W2) -> C. + { 98.0, 110.0, 95.0, 105.0, 1000.0, 2'700'000}, + }; + p.run(bars, 3); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 2); // RED: broken -> 1 + CHECK(near(p.signed_size(), 0.0)); // RED: broken -> -1 + if (p.trade_count() == 2) { + CHECK(near(p.get_trade(1).entry_price, 98.0)); + CHECK(near(p.get_trade(1).exit_price, 96.0)); + CHECK(p.get_trade(1).exit_bar_index == 2); + } +} + } // namespace int main() { @@ -273,6 +538,11 @@ int main() { test_g1_unreachable_cascade_exit_rolls(); test_m1_marketable_at_placement_is_suppressed_and_rolls(); test_g2_bar_open_recalc_exit_exact_level_unchanged(); + test_r5_second_same_open_refill_resumes_remaining_path(); + test_r6_second_same_open_short_combined_bracket(); + test_r7_second_same_open_limit_exception_is_side_symmetric(); + test_g3_second_same_open_marketable_stop_stays_suppressed(); + test_g4_second_same_open_trail_keeps_standard_path_reach(); if (tests_failed == 0) { std::printf("test_cascade_exit_gapjump PASSED (%d checks)\n", tests_passed);