diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 678da3b..b0e96d0 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -214,7 +214,26 @@ struct PendingOrder { // 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. + // + // ENTRY cascade orders use the plain "extreme-waypoint only" reach above. + // strategy.exit cascade orders follow the finer KI-67 Model S rule + // ("R-cascade-gapjump") captured by the two fields below. bool coof_born_mid_bar = false; + // KI-67 exit cascade (Model S). Set at birth for a coof_born_mid_bar + // strategy.exit order: the historical-path LEG index (0 = O->W1, 1 = W1->W2, + // 2 = W2->C) the triggering intrabar fill (coof_cursor_price_ "ap") landed + // on — the "in-flight" leg. -1 when the order is not a mid-bar cascade exit, + // or ap is off-path (roll). The gate holds the order on that leg's remainder, + // 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. + bool coof_cascade_inflight_fires = 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 @@ -1465,6 +1484,28 @@ class BacktestEngine { // 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; + // KI-67 exit cascade: the historical dispatch publishes its current path + // position here for the strategy.exit cascade gate. coof_hist_is_segment_ + // marks a segment (vs point) evaluation; coof_hist_path_index_ is the LEG + // index (0..2) on a segment, or the path WAYPOINT index (0..3, cursor = + // path[index]) on a point. Meaningful only while coof_scheduler_active_ on + // the non-magnifier historical path; the POOC-C / margin passes publish the + // C waypoint (index 3) so cascade exits are held there. + bool coof_hist_is_segment_ = false; + int coof_hist_path_index_ = -1; + // KI-67 exit cascade: the in-flight leg index (0..2) the CURRENT fill recalc + // was triggered on — the leg the dispatch cursor traverses next after the + // triggering fill. Published by the loop right before each recalc so a + // strategy.exit placed in that recalc records its seg_i from the loop's real + // position ("a fill AT a waypoint starts the NEXT leg"), rather than + // re-deriving it from the fill price (ambiguous exactly at waypoints). -1 (or + // >=3) outside a mid-bar historical recalc / at the terminal C tick. + int coof_cascade_recalc_leg_ = -1; + // KI-67 exit cascade: set by the gate immediately before evaluate_fill_price + // so resolve_exit_path_fill runs its open-gap shortcut on the in-flight + // leg-end waypoint POINT even when is_entry_bar (entry + exit share a bar). + // Reset right after that evaluation; never set on the magnifier path. + bool coof_cascade_force_wp_gap_ = 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 diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 9167681..b910e5b 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -192,24 +192,59 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( } // 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. + // born in a MID-BAR fill recalc ("cascade" order) has restricted + // same-bar reach. The magnifier path (bar_magnifier_enabled_) owns + // its own tick model and is scoped out. + coof_cascade_force_wp_gap_ = false; if (!bar_magnifier_enabled_ && coof_scheduler_active_ && order.coof_born_mid_bar - && order.created_bar == bar_index_ - && !coof_at_extreme_waypoint_) { - continue; + && order.created_bar == bar_index_) { + // Model S governs only PRICED (stop/limit, non-trail) + // strategy.exit cascade orders — the class the probe pinned. + // Opposing raw strategy.order brackets, market exits/closes and + // trailing exits keep the plain PR#95 extreme-waypoint reach + // alongside entries (a market cascade fills at the next extreme + // or rolls). + const bool priced_exit = + order.type == OrderType::EXIT + && (!std::isnan(order.stop_price) + || !std::isnan(order.limit_price)) + && std::isnan(order.trail_points) + && std::isnan(order.trail_price); + if (!priced_exit) { + // ENTRY / market-close / trailing cascade order: eligible + // ONLY at the remaining extreme waypoints (W1/W2); never + // intra-segment, never at C. Held otherwise, converting to an + // ordinary resting order once bar_index_ advances past its + // creation bar. + if (!coof_at_extreme_waypoint_) continue; + } else { + // EXIT cascade order (KI-67 Model S "R-cascade-gapjump"). + // seg_i is the in-flight leg the triggering fill landed on. + // Hold the order on that leg's remainder; gap-fill it at the + // leg-end waypoint POINT iff its level is in the in-flight + // remainder (coof_cascade_inflight_fires); EXACT-level fill it + // on every SUBSEQUENT leg's segment. A terminal in-flight leg + // (seg_i == 2) or an off-path fill (seg_i < 0) rolls. + const int si = order.coof_cascade_seg_i; + bool admit = false; + if (si >= 0) { + if (coof_hist_is_segment_) { + admit = coof_hist_path_index_ > si; + } else if (coof_hist_path_index_ == si + 1 && si < 2 + && order.coof_cascade_inflight_fires) { + admit = true; + coof_cascade_force_wp_gap_ = true; + } + } + if (!admit) continue; + } } auto fill = evaluate_fill_price( order, i, bar, opposing_pass, trail_best_path_state, pass0_opposing_skip_ids); + coof_cascade_force_wp_gap_ = false; if (fill.kind != FillEvaluation::Kind::Fill) continue; double path_position = 0.0; @@ -2371,7 +2406,8 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( trail_best_path_state, is_entry_bar, bar_magnifier_enabled_, - syminfo_mintick_); + syminfo_mintick_, + coof_cascade_force_wp_gap_); if (exit_fill.should_fill) { fill_price = exit_fill.fill_price; should_fill = true; diff --git a/src/engine_internal.hpp b/src/engine_internal.hpp index e8a4416..a3e70fa 100644 --- a/src/engine_internal.hpp +++ b/src/engine_internal.hpp @@ -218,7 +218,21 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, double trail_best_start, bool is_entry_bar, bool magnifier_active, - double syminfo_mintick); + double syminfo_mintick, + bool cascade_wp_gap = false); + + +// KI-67 exit cascade (Model S "R-cascade-gapjump"). Given the in-flight LEG +// index seg_i (0 = O->W1, 1 = W1->W2, 2 = W2->C; supplied by the dispatch loop's +// real cursor position), the full script bar, and the recalc price ap the +// triggering fill landed on, returns true when the exit level lies inside the +// in-flight remainder (ap -> leg-end waypoint W0 = path[seg_i+1]) in the trigger +// direction on a NON-terminal leg — i.e. it gap-fills same-bar at W0 (limits +// fill better than the level, stops worse). False for a terminal (seg_i>=2) or +// off-path (seg_i<0) leg, and whenever the level is not swept in the remainder. +bool cascade_exit_inflight_fires(const Bar& bar, double ap, int seg_i, + PositionSide position_side, + double stop_price, double limit_price); // ── Lower-TF emulation helpers (defined in engine_lower_tf.cpp) ── diff --git a/src/engine_path_resolve.cpp b/src/engine_path_resolve.cpp index febffd4..3c2193a 100644 --- a/src/engine_path_resolve.cpp +++ b/src/engine_path_resolve.cpp @@ -795,7 +795,8 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, double trail_best_start, bool is_entry_bar, bool magnifier_active, - double syminfo_mintick) { + double syminfo_mintick, + bool cascade_wp_gap) { ExitPathFill fill; if (position_side == PositionSide::FLAT) return fill; @@ -817,7 +818,12 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, // shortcut on the entry bar in magnifier mode only; the wrong-side // eligibility skip in classify_order_eligibility still gates the non- // magnifier path against bogus na-arithmetic stops. - if (!is_entry_bar || magnifier_active) { + // cascade_wp_gap is the KI-67 exit-cascade in-flight leg-end waypoint POINT + // (a degenerate O=H=L=C=W bar). The Model S gate has already established that + // the exit level lies in the in-flight remainder in the trigger direction, so + // the order gap-fills at that waypoint price — force the open-gap shortcut on + // even though entry and exit share this bar (is_entry_bar). + if (!is_entry_bar || magnifier_active || cascade_wp_gap) { if (try_exit_open_gap_fill(bar, is_long, has_stop, stop_price, has_limit, limit_price, trail, &fill)) { return fill; @@ -857,5 +863,40 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, } +bool cascade_exit_inflight_fires(const Bar& bar, double ap, int seg_i, + PositionSide position_side, + double stop_price, double limit_price) { + // A terminal (seg_i == 2 -> C) or off-path (seg_i < 0) in-flight leg never + // gap-fills same-bar; only the two extreme-ending legs (O->W1, W1->W2) do. + if (seg_i < 0 || seg_i >= 2) return false; + + double path[4]; + fill_bar_path_points(bar, path); + + const bool is_long = (position_side == PositionSide::LONG); + // W0 is the in-flight leg-end waypoint; the in-flight remainder is ap -> W0 + // (a fill AT the leg's start waypoint makes ap == path[seg_i], sweeping the + // whole leg). A limit fires on the with-cursor-direction leg (better than the + // level); a stop would need the opposite direction, so it can only trigger on + // a later (reversed) leg — the general form is kept for brackets. + const double W0 = path[seg_i + 1]; + const bool goes_up = W0 >= ap; + const bool has_stop = !std::isnan(stop_price); + const bool has_limit = !std::isnan(limit_price); + bool trig = false; + if (has_stop) { + trig = trig + || (is_long && !goes_up && W0 <= stop_price && stop_price <= ap) + || (!is_long && goes_up && ap <= stop_price && stop_price <= W0); + } + if (has_limit) { + trig = trig + || (is_long && goes_up && ap < limit_price && limit_price <= W0) + || (!is_long && !goes_up && W0 <= limit_price && limit_price < ap); + } + return trig; +} + + } // namespace internal } // namespace pineforge diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 419cc3a..187ce38 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -229,6 +229,10 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; + coof_hist_is_segment_ = false; + coof_hist_path_index_ = -1; + coof_cascade_recalc_leg_ = -1; + coof_cascade_force_wp_gap_ = false; double path[4]; fill_bar_path_points(script_bar, path); @@ -264,12 +268,20 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { // not admit them. coof_at_extreme_waypoint_ = (next_waypoint == 2 || next_waypoint == 3); + // KI-67 exit cascade: publish this POINT's path index (cursor == + // path[next_waypoint-1]) for the strategy.exit cascade gate. + coof_hist_is_segment_ = false; + coof_hist_path_index_ = next_waypoint - 1; const Bar point = coof_point_bar(script_bar, cursor); current_bar_ = point; CoofFillResult fill = process_next_pending_order( point, /*allow_market_orders=*/true, exit_closed_from_bar, exit_closed_was_long); if (fill.filled) { + // A fill at this POINT (cursor == path[next_waypoint-1]) puts the + // in-flight leg at path[next_waypoint-1] -> path[next_waypoint], + // i.e. leg (next_waypoint-1) — the leg the loop traverses next. + coof_cascade_recalc_leg_ = next_waypoint - 1; consume_fill( fill, cursor_is_close, /*filled_at_bar_open_point=*/next_waypoint == 1); @@ -284,8 +296,14 @@ 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. + // No intra-segment exact-level fills for ENTRY cascade orders. EXIT + // cascade orders exact-fill on SUBSEQUENT legs (leg index > seg_i); the + // gate uses the published leg index below to distinguish them. coof_at_extreme_waypoint_ = false; + // KI-67 exit cascade: publish this SEGMENT's leg index + // (path[next_waypoint-1] -> path[next_waypoint]). + coof_hist_is_segment_ = true; + coof_hist_path_index_ = next_waypoint - 1; CoofFillResult fill = process_next_pending_order( segment, /*allow_market_orders=*/false, exit_closed_from_bar, exit_closed_was_long); @@ -295,6 +313,11 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { std::abs(fill.fill_price - target) <= kSegmentDenomEps; const bool cursor_is_close = next_waypoint == 3 && reached_target; + // A fill mid-leg leaves the in-flight leg at (next_waypoint-1); a fill + // that reaches the leg-end waypoint (path[next_waypoint]) advances to + // the NEXT leg (next_waypoint) — the loop's ++next_waypoint below. + coof_cascade_recalc_leg_ = + reached_target ? next_waypoint : (next_waypoint - 1); consume_fill( fill, cursor_is_close, /*filled_at_bar_open_point=*/false); @@ -312,7 +335,14 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { // Past the extreme waypoints: neither the ordinary close execution nor the // POOC-C / margin passes admit cascade orders (they hold to the next bar). + // Publishing the C waypoint (index 3) also holds EXIT cascade orders there: + // a terminal in-flight leg never gap-fills, and no leg is "subsequent" to C. coof_at_extreme_waypoint_ = false; + coof_hist_is_segment_ = false; + coof_hist_path_index_ = 3; + // No in-flight leg remains: any exit placed by the ordinary-close / POOC-C / + // margin recalcs is terminal and rolls. + coof_cascade_recalc_leg_ = -1; // The regular historical close execution is still required after all // fill-triggered executions. It starts from the prior committed checkpoint @@ -373,6 +403,10 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; + coof_hist_is_segment_ = false; + coof_hist_path_index_ = -1; + coof_cascade_recalc_leg_ = -1; + coof_cascade_force_wp_gap_ = false; coof_direct_fill_events_remaining_ = 0; coof_checkpoint_contains_current_bar_ = false; history_slot_is_new_ = true; @@ -444,6 +478,10 @@ void BacktestEngine::reset_run_state() { coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; + coof_hist_is_segment_ = false; + coof_hist_path_index_ = -1; + coof_cascade_recalc_leg_ = -1; + coof_cascade_force_wp_gap_ = false; coof_cursor_price_ = std::numeric_limits::quiet_NaN(); coof_direct_fill_events_remaining_ = 0; coof_checkpoint_contains_current_bar_ = false; @@ -903,6 +941,10 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; + coof_hist_is_segment_ = false; + coof_hist_path_index_ = -1; + coof_cascade_recalc_leg_ = -1; + coof_cascade_force_wp_gap_ = 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 b72a284..536c47b 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -847,6 +847,27 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.coof_suppress_stop_on_entry_bar = stop_marketable; order.coof_suppress_limit_on_entry_bar = limit_marketable; } + // 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, + // exact-fill it on subsequent legs, and gap-fill it at the in-flight leg-end + // waypoint. seg_i is the loop's REAL in-flight leg at this recalc — not + // re-derived from the recalc price, which is ambiguous when the triggering + // fill lands exactly on a waypoint ("a fill AT a waypoint starts the NEXT + // leg"). current_bar_ is the full script bar during a fill recalc; the + // magnifier path owns its own tick model and is scoped out. + if (order.coof_born_mid_bar && !bar_magnifier_enabled_ + && coof_scheduler_active_ && std::isfinite(coof_cursor_price_) + && position_side_ != PositionSide::FLAT + && (!std::isnan(order.stop_price) || !std::isnan(order.limit_price)) + && std::isnan(order.trail_points) && std::isnan(order.trail_price)) { + const int si = coof_cascade_recalc_leg_; + order.coof_cascade_seg_i = + (si >= 0 && si <= 2) ? static_cast(si) + : static_cast(-1); + order.coof_cascade_inflight_fires = internal::cascade_exit_inflight_fires( + current_bar_, coof_cursor_price_, si, position_side_, + order.stop_price, order.limit_price); + } // Position-derived captures use the post-batched-close view (see // live_pos_qty above) so an exit armed after a same-bar strategy.close // records the same state it did when the close executed mid-bar. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b619bcf..efbb636 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -91,6 +91,7 @@ set(TEST_SOURCES test_streaming test_calc_on_order_fills test_coof_cascade_eligibility + test_cascade_exit_gapjump test_pooc_position_visibility ) diff --git a/tests/test_calc_on_order_fills.cpp b/tests/test_calc_on_order_fills.cpp index 73e25ef..f3ea782 100644 --- a/tests/test_calc_on_order_fills.cpp +++ b/tests/test_calc_on_order_fills.cpp @@ -861,14 +861,17 @@ void test_pooc_same_tick_requires_close_cursor_or_immediately() { } } -// 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. +// A priced bracket born in an INTRABAR fill recalc is a KI-67 cascade EXIT and +// follows Model S ("R-cascade-gapjump"): held on its in-flight leg, then it +// gap-fills at that leg-end waypoint if its level is in the in-flight remainder, +// and EXACT-level fills on any subsequent leg. Entry stop L=105 fills mid-bar +// (path tie -> O=100,L=90,H=110,C=100 => O->L->H->C), so the exit's in-flight +// leg is L->H (90->110) and the subsequent leg is H->C (110->100). +// sl=102: below the rising in-flight leg, but the reversed subsequent leg +// 110->100 crosses it — EXACT fill at 102, SAME bar (KI-67 residual +// fix; pre-fix this rolled because only the W2=110 extreme was eligible). +// tp=112: not in the in-flight remainder (105,110] and never reached on the +// down subsequent leg — it rolls to the next bar (rises to 113 there). class PoocIntrabarBracketProbe final : public CoofBase { public: enum class Leg { STOP, LIMIT }; @@ -901,9 +904,9 @@ void test_pooc_intrabar_recalc_priced_order_uses_remaining_path() { {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. + // Cascade sl=102 (KI-67 Model S): the subsequent leg H->C (110->100) crosses + // it, so it EXACT-level fills at 102 on the SAME bar (bar 1), not at the + // W2=110 extreme and not rolled to the next bar. PoocIntrabarBracketProbe stop(PoocIntrabarBracketProbe::Leg::STOP); stop.run(bars, 3); CHECK(stop.last_error().empty()); @@ -912,7 +915,7 @@ void test_pooc_intrabar_recalc_priced_order_uses_remaining_path() { 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 == 1); - CHECK(stop.get_trade(0).exit_bar_index == 2); + CHECK(stop.get_trade(0).exit_bar_index == 1); // KI-67 residual: was 2 } // Cascade tp=112 is likewise unreachable at W2=110 on bar 1; it converts to diff --git a/tests/test_cascade_exit_gapjump.cpp b/tests/test_cascade_exit_gapjump.cpp new file mode 100644 index 0000000..012d93f --- /dev/null +++ b/tests/test_cascade_exit_gapjump.cpp @@ -0,0 +1,284 @@ +/* + * KI-67 residual — strategy.exit cascade "R-cascade-gapjump" (Model S). + * + * PR#95 gave calc_on_order_fills ENTRY orders cascade eligibility (fill only at + * the remaining extreme waypoints), but that machinery never reached + * strategy.exit orders: the shipped engine rolls 100% of exits placed by a + * mid-bar fill recalc (even the ones whose level sits exactly at a waypoint). + * TradingView does not. A clean-room probe (pf-probe-ki67-exitlim-midseg, + * 4,851 TV trades) pins the exact rule, Model S: + * + * After an intrabar fill at price ap triggers a coof recalc, the emulator + * resumes the O->W1->W2->C proximity path from ap. An exit order placed at + * that recalc, on the historical (non-magnifier) path: + * (1) IN-FLIGHT leg (remainder ap -> leg-end waypoint W0): a level inside + * the remainder in the trigger direction gap-fills SAME BAR at W0 + * (limits fill better than the level, stops worse). A terminal in-flight + * leg (-> C) never fills. + * (2) SUBSEQUENT legs (incl. terminal): continuous evaluation — the first + * crossing fills SAME BAR at the EXACT level. + * (3) otherwise it rolls to the next bar as an ordinary resting order. + * + * Geometry note (adjudication): a position's in-flight leg always moves in its + * ENTRY direction, so only the profit-side LIMIT is ever crossed in-flight; + * the adverse-side STOP is only crossed on a later, reversed leg (clause 2, + * exact-level). "In-flight stop" and "subsequent-leg limit" are the + * geometrically dead complements of that split — the rule spells them out for + * 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. + * + * R-rows are RED against b6e4e35 (the exit rolls to the next bar); G/M-rows lock + * behaviour that must NOT change. + */ + +#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(); } +}; + +// A single cascade cycle: a resting long stop entry at `entry_stop` placed on +// bar 0 fills MID-BAR on bar 1 (open below it), and the fill recalc arms one +// strategy.exit whose stop/limit are supplied by the ctor. Exactly the probe's +// FRAC/WP shape. +class CascadeExitProbe final : public CoofBase { +public: + CascadeExitProbe(double entry_stop, double exit_limit, double exit_stop) + : entry_stop_(entry_stop), exit_limit_(exit_limit), + exit_stop_(exit_stop) {} + + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("E", true, /*limit=*/kNaN, /*stop=*/entry_stop_); + } + if (position_side_ == PositionSide::LONG) { + strategy_exit("X", "E", exit_limit_, exit_stop_); + } + } + +private: + double entry_stop_, exit_limit_, exit_stop_; +}; + +// The up-first work bar (bar 1): O=100 -> W1=101(H) -> W2=90(L) -> C=95. +// A long entry stop at 100.5 fills mid-bar on the in-flight leg O->W1. +std::vector make_bars(const Bar& bar1) { + return { + {100.0, 100.4, 99.5, 100.0, 1000.0, 900'000}, // bar 0: below 100.5 + bar1, // bar 1: cascade bar + {100.0, 105.0, 88.0, 100.0, 1000.0, 2'700'000}, // bar 2: roll target + }; +} + +// ── R1 — in-flight LIMIT gap-fills at the leg-end waypoint ─────────────────── +void test_r1_inflight_limit_gap_fills_at_waypoint() { + std::printf("test_r1_inflight_limit_gap_fills_at_waypoint\n"); + // TP=100.8 sits in the in-flight remainder (100.5, W1=101]; it gap-fills at + // W1=101 (better than 100.8). b6e4e35 rolls -> exact 100.8 on bar 2. + CascadeExitProbe p(/*entry_stop=*/100.5, /*exit_limit=*/100.8, /*exit_stop=*/kNaN); + auto bars = make_bars({100.0, 101.0, 90.0, 95.0, 1000.0, 1'800'000}); + p.run(bars.data(), bars.size()); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.5)); + CHECK(near(p.get_trade(0).exit_price, 101.0)); // RED: b6e4e35 -> 100.8 + CHECK(p.get_trade(0).exit_bar_index == 1); // RED: b6e4e35 -> 2 + } +} + +// ── R2 — adverse STOP fills at the EXACT level on the subsequent leg ───────── +void test_r2_subsequent_leg_stop_exact_fill() { + std::printf("test_r2_subsequent_leg_stop_exact_fill\n"); + // SL=95 is below the in-flight leg (never crossed rising); the reversed + // subsequent leg W1->W2 (101->90) crosses it at the exact level 95. + CascadeExitProbe p(/*entry_stop=*/100.5, /*exit_limit=*/kNaN, /*exit_stop=*/95.0); + auto bars = make_bars({100.0, 101.0, 90.0, 95.0, 1000.0, 1'800'000}); + p.run(bars.data(), bars.size()); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.5)); + CHECK(near(p.get_trade(0).exit_price, 95.0)); // exact, not a waypoint + CHECK(p.get_trade(0).exit_bar_index == 1); // RED: b6e4e35 -> 2 + } +} + +// ── R3 — STOP whose level IS the far waypoint (the coof-refill "stop-at-wp" +// root): still a subsequent-leg exact fill, at W2=90 ───────────────── +void test_r3_subsequent_leg_stop_at_waypoint() { + std::printf("test_r3_subsequent_leg_stop_at_waypoint\n"); + CascadeExitProbe p(/*entry_stop=*/100.5, /*exit_limit=*/kNaN, /*exit_stop=*/90.0); + auto bars = make_bars({100.0, 101.0, 90.0, 95.0, 1000.0, 1'800'000}); + p.run(bars.data(), bars.size()); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.5)); + CHECK(near(p.get_trade(0).exit_price, 90.0)); + CHECK(p.get_trade(0).exit_bar_index == 1); // RED: b6e4e35 -> 2 + } +} + +// ── R4 — a BRACKET (stop + limit): the profit-side limit gap-fills in-flight, +// the stop stays dormant. Exercises "limits and stops alike". ──────── +void test_r4_bracket_inflight_limit_gap_fill() { + std::printf("test_r4_bracket_inflight_limit_gap_fill\n"); + CascadeExitProbe p(/*entry_stop=*/100.5, /*exit_limit=*/100.8, /*exit_stop=*/95.0); + auto bars = make_bars({100.0, 101.0, 90.0, 95.0, 1000.0, 1'800'000}); + p.run(bars.data(), bars.size()); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.5)); + CHECK(near(p.get_trade(0).exit_price, 101.0)); // limit at W1 + CHECK(p.get_trade(0).exit_bar_index == 1); // RED: b6e4e35 -> 2 @100.8 + } +} + +// ── G1 — a cascade exit whose level is UNREACHABLE this bar rolls (guards +// against the fix over-firing; the terminal / no-same-bar-fill clause) ─ +void test_g1_unreachable_cascade_exit_rolls() { + std::printf("test_g1_unreachable_cascade_exit_rolls\n"); + // TP=102 is above the bar's high (101): not in the in-flight remainder and + // never reached on any subsequent (down, then up-to-95) leg. It must roll to + // bar 2 and fill there at the exact 102... bar 2 high is 105, so exact 102. + CascadeExitProbe p(/*entry_stop=*/100.5, /*exit_limit=*/102.0, /*exit_stop=*/kNaN); + auto bars = make_bars({100.0, 101.0, 90.0, 95.0, 1000.0, 1'800'000}); + p.run(bars.data(), bars.size()); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.5)); + CHECK(p.get_trade(0).exit_bar_index == 2); // rolled (before AND after) + CHECK(near(p.get_trade(0).exit_price, 102.0)); + } +} + +// ── M1 — marketable-at-placement is SUPPRESSED and rolls, not filled at the +// placement price. This documents the one deliberate departure from the +// pinned-rule prose ("marketable -> fills at p"): the engine's existing +// coof_suppress_*_on_entry_bar path owns that case and rolls it, and the +// byte-exact coof cohort is validated with that behaviour. Green before +// AND after — the exit-cascade change does not touch it. ───────────── +void test_m1_marketable_at_placement_is_suppressed_and_rolls() { + std::printf("test_m1_marketable_at_placement_is_suppressed_and_rolls\n"); + // SL=100.6 is above ap=100.5: a long sell-stop already breached at + // placement (marketable). Suppressed on the entry bar -> rolls to bar 2. + CascadeExitProbe p(/*entry_stop=*/100.5, /*exit_limit=*/kNaN, /*exit_stop=*/100.6); + auto bars = make_bars({100.0, 101.0, 90.0, 95.0, 1000.0, 1'800'000}); + p.run(bars.data(), bars.size()); + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 100.5)); + CHECK(p.get_trade(0).exit_bar_index == 2); // NOT filled at 100.5 on bar 1 + } +} + +// ── G2 — a bar-OPEN-recalc exit keeps STANDARD exact-level semantics: the +// exit-cascade gate only touches coof_born_mid_bar exits. ──────────── +class BarOpenExitProbe final : public CoofBase { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trades_.empty()) { + strategy_entry("E", true); // market -> fills at bar 1 open (bar-open recalc) + } + if (position_side_ == PositionSide::LONG) { + strategy_exit("X", "E", kNaN, /*stop=*/99.0); + } + } +}; + +void test_g2_bar_open_recalc_exit_exact_level_unchanged() { + std::printf("test_g2_bar_open_recalc_exit_exact_level_unchanged\n"); + BarOpenExitProbe p; + // Down-first bar: O=100 -> L=95 -> H=110 -> C=105. Market entry fills at the + // open (100); the bracket armed in that bar-open recalc is NOT a cascade + // order and exact-fills its stop at 99 on the O->L leg, same bar. + Bar bars[] = { + {100.0, 101.0, 99.5, 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)); // exact, same bar + CHECK(p.get_trade(0).exit_bar_index == 1); + } +} + +} // namespace + +int main() { + test_r1_inflight_limit_gap_fills_at_waypoint(); + test_r2_subsequent_leg_stop_exact_fill(); + test_r3_subsequent_leg_stop_at_waypoint(); + test_r4_bracket_inflight_limit_gap_fill(); + test_g1_unreachable_cascade_exit_rolls(); + test_m1_marketable_at_placement_is_suppressed_and_rolls(); + test_g2_bar_open_recalc_exit_exact_level_unchanged(); + + if (tests_failed == 0) { + std::printf("test_cascade_exit_gapjump PASSED (%d checks)\n", tests_passed); + return 0; + } + std::printf("test_cascade_exit_gapjump FAILED (%d failed, %d passed)\n", + tests_failed, tests_passed); + return 1; +}