From 8b328e928fc5b21923f1a12286385d3661965351 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 13 Jul 2026 23:08:21 +0800 Subject: [PATCH 01/16] Treat zero trailing offset as activation-only Normalize explicit zero trailing offsets onto the existing omitted-offset activation path while preserving positive-offset behavior. Add exact long and short resolver regressions for activation-price fills. (cherry picked from commit bd7aa83a2b1b91784de8c9abb967a40c541788c0) --- src/engine_path_resolve.cpp | 6 +++++- tests/test_path_resolve_extra.cpp | 35 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/engine_path_resolve.cpp b/src/engine_path_resolve.cpp index 3c2193a..006ee85 100644 --- a/src/engine_path_resolve.cpp +++ b/src/engine_path_resolve.cpp @@ -560,7 +560,11 @@ ExitTrailState compute_exit_trail_state(bool is_long, double trail_points, // Absolute activation price level (no entry-relative tick rounding). s.activation_level = trail_price; } - if (!std::isnan(trail_offset)) { + // TV treats an explicit zero offset like an omitted offset: the exit fires + // at the activation crossing itself. Keep zero represented as NaN here so + // it follows the existing activation-only path; positive offsets retain + // the ordinary best-price-minus/plus-offset trailing behaviour. + if (!std::isnan(trail_offset) && trail_offset != 0.0) { s.trail_offset_price = std::ceil(trail_offset) * syminfo_mintick; } if (!std::isnan(s.best_price)) { diff --git a/tests/test_path_resolve_extra.cpp b/tests/test_path_resolve_extra.cpp index e7627bd..6f80a6a 100644 --- a/tests/test_path_resolve_extra.cpp +++ b/tests/test_path_resolve_extra.cpp @@ -278,6 +278,41 @@ static void test_resolve_exit_trail_fills() { CHECK(fs.should_fill == true); CHECK(near(fs.fill_price, 98.5)); + // Explicit trail_offset=0 follows the same activation-only path as an + // omitted offset. These two bars are the first short/long trailing exits + // from the Boz WMA+ADX strategy. Before the fix, finite zero was treated + // as a normal trailing distance: the resolver armed at the favorable + // extreme, then retraced zero ticks and filled at that extreme instead of + // at the activation crossing. + // + // SHORT, dynamically re-issued exit: + // entry=1861.49, latest trail_points=1872.14*0.008/0.01=1497.712 + // -> ceil(1497.712)=1498 ticks -> activation=1846.51. + // High-first path 1872.14 -> 1875.00 -> 1843.97 -> 1849.27 must fill + // at 1846.51, not the favorable low 1843.97. + Bar boz_short = mk(1872.14, 1875.00, 1843.97, 1849.27); + ExitPathFill boz_short_zero = resolve_exit_path_fill( + boz_short, PositionSide::SHORT, kNaN, kNaN, + /*trail_points=*/1497.712, /*trail_price=*/kNaN, + /*trail_offset=*/0.0, /*entry=*/1861.49, + /*best_start=*/1858.80, false, false, kMintick); + CHECK(boz_short_zero.should_fill == true); + CHECK(near(boz_short_zero.fill_price, 1846.51)); + + // LONG, first exit snapshot: + // entry=1903.31, trail_points=1910*0.008/0.01=1528 ticks + // -> activation=1918.59. + // Low-first path 1910.00 -> 1907.98 -> 1920.58 -> 1919.43 must fill + // at 1918.59, not the favorable high 1920.58. + Bar boz_long = mk(1910.00, 1920.58, 1907.98, 1919.43); + ExitPathFill boz_long_zero = resolve_exit_path_fill( + boz_long, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/1528.0, /*trail_price=*/kNaN, + /*trail_offset=*/0.0, /*entry=*/1903.31, + /*best_start=*/1910.00, false, false, kMintick); + CHECK(boz_long_zero.should_fill == true); + CHECK(near(boz_long_zero.fill_price, 1918.59)); + // LONG trail no-offset, ALREADY armed via best_start, bar opens past the // activation level -> gap-fill at the open (exits-at-activation gap arm). // activation=101; best_start=101.5 (>=activation so armed); open=102>=101. From 7e9cc323d4512b3db6447fbbb53a19e3460f1ac0 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 00:23:50 +0800 Subject: [PATCH 02/16] Fix foreign-currency short opening margin (cherry picked from commit 415560abb5c97aa26f2924487ea2d45d531a6a04) --- include/pineforge/engine.hpp | 24 +- src/engine_fills.cpp | 104 ++++++-- src/engine_strategy_commands.cpp | 4 +- tests/test_margin_call.cpp | 416 ++++++++++++++++++++++++++++++- 4 files changed, 510 insertions(+), 38 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 73ccdbd..991979c 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -399,10 +399,9 @@ struct PendingOrder { // placement, and no earlier paired close in this on_bar. Consumers: // 1. KI-61 long entry-bar affordability EXEMPTION (engine_fills.cpp): // long-only — it independently re-checks order.is_long and margin_long - // via long_full_margin_after_fill, so widening this flag to shorts - // leaves the exemption's derivation unchanged. Fill-time code must - // additionally prove true-flat fill, sizing-price admission, success, - // and zero actual opening commission before treating it as exempt. + // via long_full_margin_after_fill. Fill-time code must additionally + // prove true-flat fill, sizing-price admission, success, and zero + // actual opening commission before treating it as exempt. // 2. gap-reject (design-cntvxiao-gap-reject, engine_fills.cpp): // direction-symmetric — silently drops the entry at fill when the // frozen-qty notional at the slipped fill price exceeds sizing_equity @@ -522,13 +521,16 @@ class BacktestEngine { // --- Position state --- PositionSide position_side_ = PositionSide::FLAT; double position_entry_price_ = 0.0; // volume-weighted average (for strategy calculations) - // One-shot post-fill affordability event for a 100%-margin long. Every - // successful fresh opening or accepted positive-qty same-direction add - // queues exactly one event carrying the raw matched-price base. The event - // is eligible unless the fill proves the narrow frozen-all-in true-flat - // MARKET exemption; rejected/no-op attempts leave an existing event - // untouched. process_margin_call consumes and clears the event on the - // current script bar. Do not reconstruct it from trade rows or + // One-shot post-fill affordability event. Every 100%-margin LONG opening / + // accepted add queues it as before; SHORT queues it only for a high-level + // explicit-qty MARKET strategy.entry opening/add at margin_short=100. + // The event carries the raw matched-price base and is eligible unless a + // LONG fill proves the narrow frozen-all-in true-flat MARKET exemption. + // Rejected/no-op attempts leave an existing event untouched; a later + // successful SHORT opening/add with any non-scoped shape invalidates prior + // short provenance rather than letting end-of-bar reuse its stale fill. + // process_margin_call consumes and clears it on the current script bar. + // Do not reconstruct it from trade rows or // position_entry_count_: a paired close/reentry can create zero-PnL rows, // and FIFO can reduce a real pyramid back to one live lot. bool opening_affordability_pending_ = false; diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 13f6708..619dbdf 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -421,19 +421,29 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // A LONG at exactly 100% margin has no leverage-derived liquidation price: // compute_liquidation_price() returns na because m/100 - direction == 0. // Its only broker action is the non-price affordability event attached to - // the successful fill. Explicit pending provenance is essential: an add - // can occur on a carried position, and FIFO can later make the mutable - // position_entry_count_ equal one again. + // the successful fill. The same event is consumed for the narrowly pinned + // SHORT shape: a high-level explicit-qty MARKET opening/add at 100% margin, + // whose individually admitted fills can over-allocate the combined short. + // Other short order shapes retain the ordinary finite-price cascade. const bool long_full_margin = (position_side_ == PositionSide::LONG) && std::isfinite(margin_long_) && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; - const bool long_opening_affordability = - long_full_margin - && opening_event_pending + const bool short_full_margin = + (position_side_ == PositionSide::SHORT) + && std::isfinite(margin_short_) + && std::abs(margin_short_ / 100.0 - 1.0) < 1e-12; + const bool event_is_actionable = + opening_event_pending && opening_event_eligible && std::isfinite(opening_event_raw_fill_base) && opening_event_raw_fill_base > 0.0; + const bool long_opening_affordability = + long_full_margin && event_is_actionable; + const bool short_opening_affordability = + short_full_margin && event_is_actionable; + const bool opening_affordability = + long_opening_affordability || short_opening_affordability; // A carried 1x long has no adverse-price liquidation. A just-filled 1x // long with no event is likewise ineligible, while a pending-but-exempt @@ -442,15 +452,15 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // A leveraged position filled at the bar CLOSE has no post-fill adverse // path on that bar, so its first price liquidation remains next-bar-only. - // The 1x-long opening check is affordability at the fill, not an adverse- - // path test, and therefore still runs for a POOC close fill. + // The 1x opening check is affordability at the fill, not an adverse-path + // test, and therefore still runs for a POOC close fill on either side. if (process_orders_on_close_ && opened_this_bar - && !long_opening_affordability) { + && !opening_affordability) { return; } const double liq = compute_liquidation_price(); - if (std::isnan(liq) && !long_opening_affordability) { + if (std::isnan(liq) && !opening_affordability) { return; // includes every carried/ineligible 1x long } @@ -471,7 +481,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { double q_min = 0.0; double raw_exit_fill_base = 0.0; - if (long_opening_affordability) { + if (opening_affordability) { // Post-fill affordability is evaluated from the current position's // actual, directionally snapped/slipped entry basis. Capital and // realized PnL are account-currency-native; price notional is quote @@ -487,7 +497,15 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // broker-generated closing fill below. const double fx = account_currency_fx_; if (!std::isfinite(fx) || !(fx > 0.0)) return; - const double margin_per_unit = position_entry_price_ * pv * fx * m; + // Preserve the established long calculation byte-for-byte. A scoped + // short add instead marks the WHOLE position at the latest raw fill: + // required margin uses that price, and carried lots contribute their + // open PnL at the same mark. Using the post-add VWAP for required + // margin makes base2@100 + add2@110 on equity420 look like an exact + // 4*105 tie and suppresses the required broker action. + const double opening_mark = short_opening_affordability + ? opening_event_raw_fill_base : position_entry_price_; + const double margin_per_unit = opening_mark * pv * fx * m; double entry_commission = 0.0; for (const auto& pe : pyramid_entries_) { // A requested add can floor to zero yet leave a bookkeeping row. @@ -498,8 +516,12 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (!std::isfinite(lot_commission)) return; entry_commission += lot_commission; } - const double opening_equity = + double opening_equity = initial_capital_ + net_profit_sum_ - entry_commission; + if (short_opening_affordability) { + opening_equity += direction + * (opening_mark - position_entry_price_) * qty * pv * fx; + } if (!std::isfinite(margin_per_unit) || !(margin_per_unit > 0.0) || !std::isfinite(entry_commission) || !std::isfinite(opening_equity)) { @@ -510,17 +532,22 @@ void BacktestEngine::process_margin_call(const Bar& bar) { q_min = qty - opening_equity / margin_per_unit; raw_exit_fill_base = opening_event_raw_fill_base; } else { - // Preserve the established finite-price cascade math and operation - // ordering byte-for-byte: shorts and leveraged longs still check the - // adverse extreme and fill there. + // Shorts and leveraged longs without a fresh opening event keep the + // established adverse-extreme cascade. Equity and required margin are + // account-currency values, so quote-currency price PnL/notional must + // carry the configured FX multiplier on this path just as they do in + // the opening-budget branch above. FX=1 preserves the old arithmetic. const double adverse = (position_side_ == PositionSide::LONG) ? bar.low : bar.high; if (!std::isfinite(adverse) || !(adverse > 0.0)) return; + const double fx = account_currency_fx_; + if (!std::isfinite(fx) || !(fx > 0.0)) return; const double equity_adv = initial_capital_ + net_profit_sum_ - + direction * (adverse - position_entry_price_) * qty * pv; - const double req_margin_adv = qty * adverse * pv * m; + + direction * (adverse - position_entry_price_) * qty * pv * fx; + const double margin_per_unit_adv = adverse * pv * fx * m; + const double req_margin_adv = qty * margin_per_unit_adv; if (equity_adv >= req_margin_adv) return; - q_min = qty - equity_adv / (adverse * pv * m); + q_min = qty - equity_adv / margin_per_unit_adv; raw_exit_fill_base = adverse; } @@ -542,7 +569,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // not a reason to force the generic finite-price cascade's one-step // progress fallback. qty_step==0 intentionally retains continuous-qty // behavior because no exchange lot floor was configured. - if (long_opening_affordability && q_min <= kQtyEpsilon) return; + if (opening_affordability && q_min <= kQtyEpsilon) return; double qty_liq = 4.0 * q_min; if (qty_step_ > 0.0) { // q_min is already a multiple of qty_step_, so 4*q_min is mathematically @@ -553,7 +580,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // with it alpha-wizard-channel cascade-1 matches TV 19/19 bit-exact. double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_; if (floored <= kQtyEpsilon) { - if (long_opening_affordability) return; + if (opening_affordability) return; // A liquidation IS required (we passed the margin-shortfall gate) // but the floored lot rounds to zero (sub-lot shortfall). Take the // smallest step that still makes progress — one qty_step_, or the @@ -567,7 +594,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return; // Finite-price calls pass the raw adverse extreme to the close helper. A - // 1x-long opening trim instead passes the captured raw matched entry base. + // 1x opening trim instead passes the captured raw matched entry base. // current_fill_is_limit_ is false here, so both routes independently apply // the closing side's market snap/slippage. This is load-bearing for both a // buy-slipped stop/market entry and an unslipped limit entry; attempting to @@ -1764,7 +1791,10 @@ void BacktestEngine::apply_filled_order_to_state( // dispatch point shared by MARKET, priced ENTRY, and RAW_ORDER fills while // the exact raw matched base is still available. A rejected or zero-effect // attempt changes neither the live quantity nor the pyramid roster and - // therefore leaves a prior event untouched. + // therefore leaves a prior event untouched. A successful short open/add + // with a non-scoped shape instead supersedes any earlier short provenance: + // its latest fill changed the position that end-of-bar will evaluate, so + // retaining an older raw base would misclassify the combined position. const bool entry_like_order = order.type == OrderType::MARKET || order.type == OrderType::ENTRY @@ -1788,9 +1818,35 @@ void BacktestEngine::apply_filled_order_to_state( position_side_ == PositionSide::LONG && std::isfinite(margin_long_) && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; + // Scope the new short event to the TV-pinned generic shape only: + // high-level strategy.entry, explicit finite qty, pure MARKET order, + // SHORT at margin_short=100. Default-sized percent/cash entries, + // priced ENTRY orders, RAW strategy.order, and other margin settings + // retain their prior short-side event behavior (none). + const bool explicit_market_short_full_margin_after_fill = + position_side_ == PositionSide::SHORT + && std::isfinite(margin_short_) + && std::abs(margin_short_ / 100.0 - 1.0) < 1e-12 + && order.type == OrderType::MARKET + && !order.is_long + && std::isfinite(order.qty); const bool positive_raw_base = std::isfinite(fill_price) && fill_price > 0.0; - if (long_full_margin_after_fill && positive_raw_base + const bool successful_short_open_or_add = + requested_side == PositionSide::SHORT + && (successful_fresh_open || accepted_additional_entry); + const bool scoped_short_opening_fill = + explicit_market_short_full_margin_after_fill + && positive_raw_base; + if (successful_short_open_or_add && !scoped_short_opening_fill) { + opening_affordability_pending_ = false; + opening_affordability_eligible_ = false; + opening_affordability_raw_fill_base_ = + std::numeric_limits::quiet_NaN(); + } + if ((long_full_margin_after_fill + || explicit_market_short_full_margin_after_fill) + && positive_raw_base && (successful_fresh_open || accepted_additional_entry)) { // The only exemption requires every item of provenance to agree: // omitted qty; a frozen 100%-equity high-level MARKET snapshot; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 2b3b5bd..bf1fc16 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -328,8 +328,8 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, // Direction-neutral: two fill-time consumers read this flag. // 1. KI-61 long entry-bar affordability trim // (engine_fills.cpp): re-checks order.is_long and margin_long - // via long_full_margin_after_fill, so its long-only semantics - // are invariant to widening this to shorts. + // via long_full_margin_after_fill, so its long-only exemption + // remains invariant. // 2. gap-reject (design-cntvxiao-gap-reject, engine_fills.cpp): // direction-symmetric — drops a true-flat all-in zero-comm // entry whose gapped fill notional exceeds equity by >1 lot, diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index ff97781..d3dcad2 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -90,7 +90,8 @@ class MCEngine : public BacktestEngine { class ShortLiqProbe : public MCEngine { public: bool disable_mc_ = false; - explicit ShortLiqProbe(bool disable_mc = false, double qty_step = 0.0) { + explicit ShortLiqProbe(bool disable_mc = false, double qty_step = 0.0, + double account_fx = 1.0) { initial_capital_ = 1000.0; default_qty_type_ = QtyType::PERCENT_OF_EQUITY; default_qty_value_ = 100.0; // size the short at 100% of equity @@ -100,6 +101,7 @@ class ShortLiqProbe : public MCEngine { process_orders_on_close_ = true; // market entry fills at bar close disable_mc_ = disable_mc; qty_step_ = qty_step; // 0 = no lot quantization + account_currency_fx_ = account_fx; if (disable_mc_) set_margin_call_enabled(false); } void on_bar(const Bar& /*bar*/) override { @@ -241,6 +243,27 @@ static void test_short_margin_call_qty_step() { CHECK(!is_multiple_of(raw.trade_size(0), step)); } +static void test_short_margin_call_account_fx() { + std::printf("test_short_margin_call_account_fx\n"); + constexpr double account_fx = 2.0; + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 101.0, 105.0, 100.5, 104.0, 1.0), + }; + ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/0.0, account_fx); + eng.run(bars.data(), (int)bars.size()); + + // FX-aware percent sizing opens qty=1000/(100*2)=5. At high=105: + // equity=1000-(105-100)*5*2=950, margin=5*105*2=1050, so the + // finite-price 4x restore is 4*(5-950/(105*2)). + const double expected_qty = 4.0 * (5.0 - 950.0 / (105.0 * account_fx)); + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.exit_price(0), 105.0)); + CHECK(near(eng.trade_size(0), expected_qty)); + CHECK(near(eng.position_size(), -(5.0 - expected_qty))); +} + // ---- B: long at 100% margin is never liquidated ---------------------------- class LongNoLiqProbe : public MCEngine { @@ -679,6 +702,389 @@ static int margin_call_rows(const MCEngine& eng) { return count; } +// Two explicit qty=2 market entries are each affordable on their own, but the +// accepted same-bar pyramid (qty=4) exceeds a 100%-margin account after the +// configured account-currency FX conversion and opening commissions. The +// resulting broker action is direction-symmetric: it restores margin from the +// raw matched fill, not from the short side's later adverse-price path. +class SameBarExplicitPairOpeningProbe : public MCEngine { +public: + SameBarExplicitPairOpeningProbe(bool is_long, double account_fx, + double initial_capital) + : is_long_(is_long) { + initial_capital_ = initial_capital; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::CASH_PER_CONTRACT; + commission_value_ = 20.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = true; + pyramiding_ = 2; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + account_currency_fx_ = account_fx; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + + strategy_entry("BASE", is_long_, kNaN, kNaN, /*qty=*/2.0); + process_pending_orders(current_bar_); + strategy_entry("ADD", is_long_, kNaN, kNaN, /*qty=*/2.0); + process_pending_orders(current_bar_); + } + +private: + bool is_long_; +}; + +static double expected_same_bar_pair_opening_liquidation( + double initial_capital, double raw_fill, double account_fx) { + constexpr double total_qty = 4.0; + constexpr double cash_per_contract = 20.0; + constexpr double qty_step = 0.0001; + const double margin_per_unit = raw_fill * account_fx; + const double opening_commission = total_qty * cash_per_contract; + const double opening_equity = initial_capital - opening_commission; + double q_min = total_qty - opening_equity / margin_per_unit; + q_min = std::floor(q_min / qty_step) * qty_step; + double qty_liq = 4.0 * q_min; + qty_liq = std::floor(qty_liq / qty_step + 1e-6) * qty_step; + return std::min(qty_liq, total_qty); +} + +static void check_same_bar_explicit_pair_opening_trim( + bool is_long, double account_fx, double initial_capital) { + constexpr double raw_fill = 1741.23; + constexpr double one_entry_qty = 2.0; + constexpr double total_qty = 4.0; + constexpr double entry_fee = 20.0; + + // The admission fork is cumulative, not per-order: each order fits, but + // the accepted pair plus its account-native opening fees does not. + CHECK(one_entry_qty * raw_fill * account_fx < initial_capital); + CHECK(total_qty * raw_fill * account_fx + total_qty * entry_fee + > initial_capital); + + SameBarExplicitPairOpeningProbe eng(is_long, account_fx, initial_capital); + std::vector bars = { + mk_bar(1000, raw_fill, raw_fill, raw_fill, raw_fill, 1.0), + }; + eng.run(bars.data(), (int)bars.size()); + + const double expected_qty = expected_same_bar_pair_opening_liquidation( + initial_capital, raw_fill, account_fx); + double liquidated_qty = 0.0; + for (int i = 0; i < eng.trade_count(); ++i) { + CHECK(eng.exit_comment(i) == std::string("Margin call")); + CHECK(near(eng.entry_price(i), raw_fill)); + CHECK(near(eng.exit_price(i), raw_fill)); + liquidated_qty += eng.trade_size(i); + } + CHECK(margin_call_rows(eng) == 2); + CHECK(near(liquidated_qty, expected_qty)); + CHECK(near(std::fabs(eng.position_size()), total_qty - expected_qty)); +} + +static void test_same_bar_explicit_pair_foreign_fx_direction_symmetry() { + std::printf("test_same_bar_explicit_pair_foreign_fx_direction_symmetry\n"); + constexpr double account_fx = 88.0; + constexpr double initial_capital = 500000.0; + check_same_bar_explicit_pair_opening_trim( + /*is_long=*/true, account_fx, initial_capital); + check_same_bar_explicit_pair_opening_trim( + /*is_long=*/false, account_fx, initial_capital); +} + +static void test_same_bar_explicit_pair_fx1_direction_symmetry() { + std::printf("test_same_bar_explicit_pair_fx1_direction_symmetry\n"); + constexpr double account_fx = 1.0; + constexpr double initial_capital = 6000.0; + check_same_bar_explicit_pair_opening_trim( + /*is_long=*/true, account_fx, initial_capital); + check_same_bar_explicit_pair_opening_trim( + /*is_long=*/false, account_fx, initial_capital); +} + +// The add fills above the base short. Marking required margin at the VWAP +// (105) reports an exact 4*105 == 420 tie and incorrectly does nothing; +// TradingView marks the whole position at the latest raw fill (110), including +// the carried short's open loss, and immediately restores margin there. +class UnequalFillShortAddProbe : public MCEngine { +public: + UnequalFillShortAddProbe() { + initial_capital_ = 420.0; + default_qty_type_ = QtyType::FIXED; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = true; + pyramiding_ = 2; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("BASE", false, kNaN, kNaN, /*qty=*/2.0); + } else if (bar_index_ == 1) { + strategy_entry("ADD", false, kNaN, kNaN, /*qty=*/2.0); + } + } +}; + +static void test_short_add_opening_margin_marks_latest_raw_fill() { + std::printf("test_short_add_opening_margin_marks_latest_raw_fill\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(2000, 110.0, 110.0, 110.0, 110.0, 1.0), + }; + UnequalFillShortAddProbe eng; + eng.run(bars.data(), (int)bars.size()); + + // At the latest fill, equity is 420 - (110-100)*2 = 400 and required + // margin is 4*110=440. The 4x restore closes 4*(4-400/110). + const double expected_qty = 4.0 * (4.0 - 400.0 / 110.0); + CHECK(eng.trade_count() == 1); + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.entry_price(0), 100.0)); + CHECK(near(eng.exit_price(0), 110.0)); + CHECK(near(eng.trade_size(0), expected_qty)); + CHECK(near(eng.position_size(), -(4.0 - expected_qty))); +} + +// Literal non-POOC geometry from the Thula margin fork. The effective fixed FX +// is deliberately inside the observed interval but remains an ordinary runtime +// input; the expected broker rows are pinned directly, not computed by a copy +// of the implementation formula. +class NextOpenExplicitShortPairProbe : public MCEngine { +public: + NextOpenExplicitShortPairProbe() { + // Prior realized loss leaves 497641.70 before these fills; four + // account-native 20-per-contract opening fees make the broker's + // opening-equity basis exactly 497561.70. + initial_capital_ = 497641.70; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::CASH_PER_CONTRACT; + commission_value_ = 20.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + pyramiding_ = 2; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + account_currency_fx_ = 85.3567; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + strategy_entry("BASE", false, kNaN, kNaN, /*qty=*/2.0); + strategy_entry("ADD", false, kNaN, kNaN, /*qty=*/2.0); + } +}; + +static void test_thula_next_open_short_pair_exact_margin_rows() { + std::printf("test_thula_next_open_short_pair_exact_margin_rows\n"); + std::vector bars = { + mk_bar(1000, 1700.0, 1700.0, 1700.0, 1700.0, 1.0), + mk_bar(2000, 1741.23, 1741.23, 1741.23, 1741.23, 1.0), + }; + NextOpenExplicitShortPairProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 2); + CHECK(margin_call_rows(eng) == 2); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(eng.exit_comment(1) == std::string("Margin call")); + CHECK(near(eng.entry_price(0), 1741.23)); + CHECK(near(eng.entry_price(1), 1741.23)); + CHECK(near(eng.exit_price(0), 1741.23)); + CHECK(near(eng.exit_price(1), 1741.23)); + CHECK(near(eng.trade_size(0), 2.0)); + CHECK(near(eng.trade_size(1), 0.6088)); + CHECK(near(eng.position_size(), -1.3912)); +} + +class ShortOpeningEventScopeProbe : public MCEngine { +public: + enum class Shape { DefaultPercent, DefaultCash, Priced, Raw, MarginNot100 }; + bool widened_event = false; + bool priced_fill_observed = false; + + explicit ShortOpeningEventScopeProbe(Shape shape) : shape_(shape) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 2.0; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = shape == Shape::MarginNot100 ? 80.0 : 100.0; + process_orders_on_close_ = true; + pyramiding_ = 2; + if (shape == Shape::DefaultPercent) { + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 50.0; + } else if (shape == Shape::DefaultCash) { + default_qty_type_ = QtyType::CASH; + default_qty_value_ = 200.0; + } + } + + void on_bar(const Bar& /*bar*/) override { + // The priced control must be a real fill, not merely a pending shape. + // Arm it below the market on bar 0, then observe its short fill after + // bar 1 gaps to the limit. dispatch_bar's step 1 applies that resting + // order before this callback, while its event provenance is visible. + if (shape_ == Shape::Priced) { + if (bar_index_ == 0) { + strategy_entry("S", false, /*limit=*/110.0, kNaN, + /*qty=*/2.0); + } else if (bar_index_ == 1) { + priced_fill_observed = + position_side_ == PositionSide::SHORT + && near(position_qty_, 2.0) + && !pyramid_entries_.empty() + && near(pyramid_entries_.back().price, 110.0); + widened_event = opening_affordability_pending_ + || opening_affordability_eligible_ + || std::isfinite(opening_affordability_raw_fill_base_); + } + return; + } + + if (bar_index_ != 0) return; + switch (shape_) { + case Shape::DefaultPercent: + case Shape::DefaultCash: + strategy_entry("S", false, kNaN, kNaN, kNaN); + break; + case Shape::Priced: + break; // handled above on two distinct bars + case Shape::Raw: + strategy_order("S", false, /*qty=*/2.0); + break; + case Shape::MarginNot100: + strategy_entry("S", false, kNaN, kNaN, /*qty=*/2.0); + break; + } + process_pending_orders(current_bar_); + widened_event = opening_affordability_pending_ + || opening_affordability_eligible_ + || std::isfinite(opening_affordability_raw_fill_base_); + } + +private: + Shape shape_; +}; + +static void test_short_opening_event_scope_is_explicit_market_margin100_only() { + std::printf("test_short_opening_event_scope_is_explicit_market_margin100_only\n"); + const ShortOpeningEventScopeProbe::Shape shapes[] = { + ShortOpeningEventScopeProbe::Shape::DefaultPercent, + ShortOpeningEventScopeProbe::Shape::DefaultCash, + ShortOpeningEventScopeProbe::Shape::Priced, + ShortOpeningEventScopeProbe::Shape::Raw, + ShortOpeningEventScopeProbe::Shape::MarginNot100, + }; + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(2000, 110.0, 110.0, 110.0, 110.0, 1.0), + }; + for (auto shape : shapes) { + ShortOpeningEventScopeProbe eng(shape); + eng.run(bars.data(), (int)bars.size()); + if (shape == ShortOpeningEventScopeProbe::Shape::Priced) { + CHECK(eng.priced_fill_observed); + } + CHECK(!eng.widened_event); + } +} + +// A scoped explicit MARKET short event is only provenance for that exact +// fill. If a later successful same-direction short fill in the same dispatch +// cycle has a non-scoped shape, the earlier event must not survive to the +// end-of-bar margin pass. These mutations use a later synthetic broker sample +// so BASE fills at 100 and the accepted add really fills at 110. +class ShortOpeningEventMutationProbe : public MCEngine { +public: + enum class LaterFill { PricedEntry, RawOrder }; + bool base_event_captured = false; + bool later_add_filled = false; + bool stale_event_cleared = false; + + explicit ShortOpeningEventMutationProbe(LaterFill later_fill) + : later_fill_(later_fill) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = true; + pyramiding_ = 2; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + + strategy_entry("BASE", false, kNaN, kNaN, /*qty=*/2.0); + process_pending_orders(current_bar_); + base_event_captured = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(opening_affordability_raw_fill_base_, 100.0); + + if (later_fill_ == LaterFill::PricedEntry) { + strategy_entry("ADD", false, /*limit=*/110.0, kNaN, + /*qty=*/2.0); + } else { + strategy_order("ADD", false, /*qty=*/2.0); + } + + const Bar later_sample = + mk_bar(current_bar_.timestamp, 110.0, 110.0, 110.0, 110.0, 1.0); + process_pending_orders(later_sample); + later_add_filled = position_side_ == PositionSide::SHORT + && near(position_qty_, 4.0) + && pyramid_entries_.size() == 2 + && near(pyramid_entries_.back().price, 110.0); + stale_event_cleared = !opening_affordability_pending_ + && !opening_affordability_eligible_ + && std::isnan(opening_affordability_raw_fill_base_); + } + +private: + LaterFill later_fill_; +}; + +static void test_priced_short_add_invalidates_scoped_opening_event() { + std::printf("test_priced_short_add_invalidates_scoped_opening_event\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + ShortOpeningEventMutationProbe eng( + ShortOpeningEventMutationProbe::LaterFill::PricedEntry); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.base_event_captured); + CHECK(eng.later_add_filled); + CHECK(eng.stale_event_cleared); + CHECK(margin_call_rows(eng) == 0); + CHECK(near(eng.position_size(), -4.0)); +} + +static void test_raw_short_add_invalidates_scoped_opening_event() { + std::printf("test_raw_short_add_invalidates_scoped_opening_event\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + ShortOpeningEventMutationProbe eng( + ShortOpeningEventMutationProbe::LaterFill::RawOrder); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.base_event_captured); + CHECK(eng.later_add_filled); + CHECK(eng.stale_event_cleared); + CHECK(margin_call_rows(eng) == 0); + CHECK(near(eng.position_size(), -4.0)); +} + // A genuine accepted same-direction add is itself a post-fill affordability // event. FIFO then drains the original lot and makes the mutable entry count // equal one again; the event must survive because it came from the accepted @@ -1127,6 +1533,7 @@ static void test_long_leveraged_margin_call() { int main() { test_short_margin_call(); test_short_margin_call_qty_step(); + test_short_margin_call_account_fx(); test_margin_liquidation_price_formula(); test_short_margin_call_disabled(); test_long_100pct_margin_no_call(); @@ -1141,6 +1548,13 @@ int main() { test_long_100pct_margin_stop_trim_uses_raw_base_and_exit_slip(); test_long_100pct_margin_limit_trim_uses_raw_base_and_exit_slip(); test_raw_order_fresh_open_captures_affordability(); + test_same_bar_explicit_pair_foreign_fx_direction_symmetry(); + test_same_bar_explicit_pair_fx1_direction_symmetry(); + test_short_add_opening_margin_marks_latest_raw_fill(); + test_thula_next_open_short_pair_exact_margin_rows(); + test_short_opening_event_scope_is_explicit_market_margin100_only(); + test_priced_short_add_invalidates_scoped_opening_event(); + test_raw_short_add_invalidates_scoped_opening_event(); test_accepted_add_fifo_keeps_add_affordability_event(); test_rejected_add_preserves_opening_eligibility(); test_zero_qty_add_preserves_opening_eligibility(); From dec6a60614e67dffc6c901c41d38043814dd6e87 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 01:54:40 +0800 Subject: [PATCH 03/16] Cancel all-Na strategy exits without filling (cherry picked from commit 83a31f719920930ac0d67ab267e33f7dc0df5dc5) --- src/engine_strategy_commands.cpp | 17 ++ tests/test_fills_edge.cpp | 461 +++++++++++++++++++++++++++---- tests/test_integration.cpp | 35 ++- 3 files changed, 458 insertions(+), 55 deletions(-) diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index bf1fc16..3678fc6 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -753,6 +753,23 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro double qty, const std::string& oca_name, double profit_ticks, double loss_ticks) { if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return; + const bool has_actionable_exit = !std::isnan(limit_price) + || !std::isnan(stop_price) + || !std::isnan(profit_ticks) + || !std::isnan(loss_ticks) + || !std::isnan(trail_points) + || !std::isnan(trail_price); + if (!has_actionable_exit) { + // TV probe N0/NR: an all-actionable-NaN strategy.exit is inert, not a + // market close. A same-id call still cancels its prior bracket, so the + // return follows matching-EXIT removal but precedes all sizing and + // reservation work. trail_offset alone is intentionally insufficient. + int64_t discarded_seq = 0; + double discarded_reserved_qty = std::numeric_limits::quiet_NaN(); + clear_existing_exit_order(id, from_entry, /*has_trail_request=*/false, + discarded_seq, discarded_reserved_qty); + return; + } bool has_explicit_qty = !std::isnan(qty); double qp = std::isnan(qty_percent) ? 100.0 : std::clamp(qty_percent, 0.0, 100.0); // A default-FIFO strategy.close batched earlier on this SAME bar has not diff --git a/tests/test_fills_edge.cpp b/tests/test_fills_edge.cpp index 09626b2..e63bc1d 100644 --- a/tests/test_fills_edge.cpp +++ b/tests/test_fills_edge.cpp @@ -18,9 +18,10 @@ * 481-485). * - percent-based partial exit by entry (execute_partial_exit_by_entry_percent, * reached via close_entries_rule_any_ + from_entry, lines 583-591). - * - same-bar market exit on the entry bar is SKIPPED (classify_order_ - * eligibility lines 826-828): a no-price strategy.exit placed on the - * entry bar does NOT fire that bar. + * - high-level strategy.exit actionability: calls whose limit, stop, + * profit, loss, trail_points, and trail_price are all runtime NaN are + * inert after cancelling a matching prior bracket; strategy.close remains + * the market-close API. * * NDEBUG-PROOF: every assertion uses the returning CHECK macro (failure * increments g_fail; main returns nonzero). bare assert() is never used, so @@ -338,87 +339,451 @@ static void test_partial_exit_by_entry_percent() { } // ───────────────────────────────────────────────────────────────────── -// 6. Same-bar MARKET exit on the ENTRY bar is SKIPPED -// (classify_order_eligibility lines 826-828: "skip market exits on -// entry bar"). A no-price strategy.exit placed on the same bar the -// position opens must NOT fire that bar; it only acts later when the -// position is explicitly closed. +// 6. TV-pinned generic strategy.exit actionability. // -// We open long on bar 0 (fills bar 1 open @ 100). On bar 1, while long & -// on the entry bar, we place a no-price (market-style) strategy.exit "MX". -// It must be SKIPPED for bar 1 (the entry bar) — the position stays open -// through bar 1. On bar 2 (no longer the entry bar) "MX" is a live market -// exit and fills at bar 2's OPEN (102). So the single closed trade exits at -// 102 (bar 2 open), NOT at the entry price 100 on bar 1. The skip is the -// load-bearing arm: without it the exit would fire same-bar at entry price -// 100 (flat $0 trade) and exit_bar_index would be 1, not 2. +// A high-level strategy.exit is inert only when limit, stop, profit, loss, +// trail_points, and trail_price are all runtime NaN. trail_offset alone does +// not make an exit actionable. Shipped compatibility is deliberately kept for +// activation-only trails and non-Na infinities; this gate does not redefine +// the existing downstream fill resolver. An inert call is NOT a market exit; +// the explicit market-close APIs remain strategy.close / strategy.close_all. // ───────────────────────────────────────────────────────────────────── -class EntryBarMarketExitSkip : public BacktestEngine { +class NoActionableExitFresh : public BacktestEngine { public: - int exit_placed_bar = -1; - EntryBarMarketExitSkip() { + int exits_after_inert = -1; + double pos_after_inert = -1.0; + double pos_next_bar = -1.0; + + NoActionableExitFresh() { initial_capital_ = 1'000'000; default_qty_type_ = QtyType::FIXED; default_qty_value_ = 1.0; slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; syminfo_mintick_ = 0.01; + process_orders_on_close_ = true; // matches the TV N0 probe } + void on_bar(const Bar&) override { if (bar_index_ == 0) strategy_entry("L", true, kNaN, kNaN, 1.0, "long"); - // On the entry bar (position just opened, position_open_bar_ == - // bar_index_), attach a no-price (market) strategy.exit. The - // engine must skip it for this bar. - if (position_side_ == PositionSide::LONG && position_was_just_opened()) { - strategy_exit("MX", "L", /*limit=*/kNaN, /*stop=*/kNaN, - kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "market exit"); - exit_placed_bar = bar_index_; + + // N0: no prior X exists. Every absolute, relative, and trailing + // action field is runtime NaN. qty/OCA/comment do not make it live. + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_exit("X", "L", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, /*qty_percent=*/100.0, + /*comment=*/"inert", /*qty=*/1.0, + /*oca_name=*/"INERT", /*profit_ticks=*/kNaN, + /*loss_ticks=*/kNaN); + exits_after_inert = 0; + for (const auto& o : pending_orders_) { + if (o.type == OrderType::EXIT) ++exits_after_inert; + } + pos_after_inert = signed_position_size(); + } + if (bar_index_ == 2) { + pos_next_bar = signed_position_size(); + if (position_side_ == PositionSide::LONG) { + strategy_close("L", "explicit close"); + } } - } - bool position_was_just_opened() const { - return position_side_ != PositionSide::FLAT - && position_open_bar_ == bar_index_; } double signed_pos() const { return signed_position_size(); } }; -static void test_entry_bar_market_exit_skipped() { - std::printf("test_entry_bar_market_exit_skipped\n"); - EntryBarMarketExitSkip p; +static void test_no_actionable_exit_fresh_is_inert() { + std::printf("test_no_actionable_exit_fresh_is_inert\n"); + NoActionableExitFresh p; Bar bars[5] = { {100, 100.5, 99.5, 100, 1000, kT0_UTC + 0 * k15m_ms}, - {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, // L fills @ 100; entry-bar market exit must be SKIPPED here - {102, 103, 101, 102, 1000, kT0_UTC + 2 * k15m_ms}, // not entry bar: MX fires @ open 102 - {103, 104, 102, 103, 1000, kT0_UTC + 3 * k15m_ms}, - {120, 121, 119, 120, 1000, kT0_UTC + 4 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, // inert X is called against the bar-0 position + {102, 103, 101, 102, 1000, kT0_UTC + 2 * k15m_ms}, // position persists; explicit close fills at close 102 + {107, 108, 106, 107, 1000, kT0_UTC + 3 * k15m_ms}, + {109, 110, 108, 109, 1000, kT0_UTC + 4 * k15m_ms}, }; p.run(bars, 5); - // The market exit was placed on the entry bar (bar 1). - CHECK(p.exit_placed_bar == 1); - // If the entry-bar market exit had NOT been skipped, the position would - // have closed on bar 1 at the entry price (100) for a flat $0 trade with - // exit_bar_index == 1. The skip defers it one bar: exits on bar 2 at - // bar 2's open (102), exit_bar_index == 2. + CHECK(p.exits_after_inert == 0); + CHECK(near(p.pos_after_inert, 1.0)); + CHECK(near(p.pos_next_bar, 1.0)); CHECK(p.trade_count() == 1); if (p.trade_count() == 1) { const Trade& t = p.get_trade(0); CHECK(near(t.entry_price, 100.0)); - CHECK(near(t.exit_price, 102.0)); // deferred to bar 2 open, NOT 100 - CHECK(t.entry_bar_index == 1); - CHECK(t.exit_bar_index == 2); // NOT 1 (the entry bar) - CHECK(t.exit_comment == "market exit"); + CHECK(near(t.exit_price, 102.0)); + CHECK(t.entry_bar_index == 0); + CHECK(t.exit_bar_index == 2); + CHECK(t.exit_comment == "explicit close"); + CHECK(t.exit_id == "__close__L"); } CHECK(near(p.signed_pos(), 0.0)); } +// NR: replacing a live same-id stop with an all-actionable-NaN call cancels +// the prior bracket and creates no replacement. The old stop must not fire. +class NoActionableExitReissue : public BacktestEngine { +public: + int exits_after_stop = -1; + int exits_after_inert = -1; + double pos_after_old_stop_cross = -1.0; + + NoActionableExitReissue() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + process_orders_on_close_ = true; // matches the TV NR probe + } + + int exit_count() const { + int count = 0; + for (const auto& o : pending_orders_) { + if (o.type == OrderType::EXIT) ++count; + } + return count; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 1.0, "long"); + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_exit("X", "L", /*limit=*/kNaN, /*stop=*/95.0, + kNaN, kNaN, kNaN, 100.0, "old stop"); + exits_after_stop = exit_count(); + } + if (bar_index_ == 2 && position_side_ == PositionSide::LONG) { + strategy_exit("X", "L", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, /*qty_percent=*/100.0, + /*comment=*/"cancel X", /*qty=*/kNaN, + /*oca_name=*/"", /*profit_ticks=*/kNaN, + /*loss_ticks=*/kNaN); + exits_after_inert = exit_count(); + } + if (bar_index_ == 3) { + pos_after_old_stop_cross = signed_position_size(); + } + if (bar_index_ == 4 && position_side_ == PositionSide::LONG) { + strategy_close("L", "explicit close"); + } + } +}; + +static void test_no_actionable_reissue_cancels_prior_exit() { + std::printf("test_no_actionable_reissue_cancels_prior_exit\n"); + NoActionableExitReissue p; + Bar bars[7] = { + {100, 101, 99, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, // stop placement against bar-0 entry + {100, 101, 99, 100, 1000, kT0_UTC + 2 * k15m_ms}, // NaN reissue cancels stop + {100, 101, 90, 100, 1000, kT0_UTC + 3 * k15m_ms}, // old stop would cross + {101, 102, 100, 101, 1000, kT0_UTC + 4 * k15m_ms}, // explicit close fills at close + {106, 107, 105, 106, 1000, kT0_UTC + 5 * k15m_ms}, + {106, 107, 105, 106, 1000, kT0_UTC + 6 * k15m_ms}, + }; + p.run(bars, 7); + + CHECK(p.exits_after_stop == 1); + CHECK(p.exits_after_inert == 0); + CHECK(near(p.pos_after_old_stop_cross, 1.0)); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + const Trade& t = p.get_trade(0); + CHECK(near(t.exit_price, 101.0)); + CHECK(t.exit_bar_index == 4); + CHECK(t.exit_comment == "explicit close"); + CHECK(t.exit_id == "__close__L"); + } +} + +// While flat, an inert exit must not bind itself to a same-pass pending entry. +// The entry remains live, opens normally under POOC, and only strategy.close +// ends the trade on the following bar. +class NoActionableExitPendingEntry : public BacktestEngine { +public: + int entries_after_calls = -1; + int exits_after_calls = -1; + double pos_after_entry_fill = -1.0; + + NoActionableExitPendingEntry() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + process_orders_on_close_ = true; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, 1.0, "long"); + strategy_exit("X", "L", kNaN, kNaN, kNaN, kNaN, kNaN, + 100.0, "inert while flat", kNaN, "", kNaN, kNaN); + entries_after_calls = 0; + exits_after_calls = 0; + for (const auto& o : pending_orders_) { + if (o.type == OrderType::EXIT) ++exits_after_calls; + if (o.type == OrderType::ENTRY || o.type == OrderType::MARKET) + ++entries_after_calls; + } + } + if (bar_index_ == 1) { + pos_after_entry_fill = signed_position_size(); + if (position_side_ == PositionSide::LONG) + strategy_close("L", "explicit close"); + } + } +}; + +static void test_inert_exit_does_not_bind_pending_entry() { + std::printf("test_inert_exit_does_not_bind_pending_entry\n"); + NoActionableExitPendingEntry p; + Bar bars[3] = { + {100, 101, 99, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {101, 102, 100, 101, 1000, kT0_UTC + 1 * k15m_ms}, + {102, 103, 101, 102, 1000, kT0_UTC + 2 * k15m_ms}, + }; + p.run(bars, 3); + + CHECK(p.entries_after_calls == 1); + CHECK(p.exits_after_calls == 0); + CHECK(near(p.pos_after_entry_fill, 1.0)); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + const Trade& t = p.get_trade(0); + CHECK(t.entry_bar_index == 0); + CHECK(t.exit_bar_index == 1); + CHECK(near(t.exit_price, 101.0)); + CHECK(t.exit_comment == "explicit close"); + CHECK(t.exit_id == "__close__L"); + } +} + +// An inert same-id call must release the old qty/OCA reservation, and its own +// qty/OCA arguments must not reserve anything. A following sibling can reserve +// the full two-lot position. +class NoActionableExitReservation : public BacktestEngine { +public: + int exits_after_reissue = -1; + bool found_x = false; + bool found_y = false; + double y_qty = kNaN; + std::string y_oca; + + NoActionableExitReservation() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 2.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 2.0, "long"); + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_exit("X", "L", kNaN, /*stop=*/90.0, + kNaN, kNaN, kNaN, 100.0, "old X", + /*qty=*/1.0, /*oca_name=*/"OLD_GROUP"); + } + if (bar_index_ == 2 && position_side_ == PositionSide::LONG) { + strategy_exit("X", "L", kNaN, kNaN, + kNaN, kNaN, kNaN, 100.0, "inert X", + /*qty=*/1.0, /*oca_name=*/"INERT_GROUP", + /*profit_ticks=*/kNaN, /*loss_ticks=*/kNaN); + strategy_exit("Y", "L", /*limit=*/130.0, kNaN, + kNaN, kNaN, kNaN, 100.0, "live Y", + /*qty=*/2.0, /*oca_name=*/"LIVE_GROUP"); + exits_after_reissue = 0; + for (const auto& o : pending_orders_) { + if (o.type != OrderType::EXIT) continue; + ++exits_after_reissue; + if (o.id == "X") found_x = true; + if (o.id == "Y") { + found_y = true; + y_qty = o.qty; + y_oca = o.oca_name; + } + } + } + } +}; + +static void test_inert_exit_has_no_qty_or_oca_reservation() { + std::printf("test_inert_exit_has_no_qty_or_oca_reservation\n"); + NoActionableExitReservation p; + Bar bars[5] = { + {100, 101, 99, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 2 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 3 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + + CHECK(p.exits_after_reissue == 1); + CHECK(!p.found_x); + CHECK(p.found_y); + CHECK(near(p.y_qty, 2.0)); + CHECK(p.y_oca == "LIVE_GROUP"); +} + +enum class ExitActionForm { + Stop, + Limit, + Profit, + Loss, + TrailPriceWithOffset, + TrailPointsWithOffset, + TrailOffsetOnly, + TrailPriceWithoutOffset, + TrailPointsWithoutOffset, + InfiniteStop, + InfiniteTrailPoints, +}; + +// Snapshot placement, not fill behavior: this isolates the high-level +// strategy_exit predicate from the generic fill resolver. +class ExitActionabilityProbe : public BacktestEngine { +public: + explicit ExitActionabilityProbe(ExitActionForm form) : form_(form) { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + + int exits_after_call = -1; + + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 1.0, "long"); + if (bar_index_ != 1 || position_side_ != PositionSide::LONG) return; + + double limit = kNaN; + double stop = kNaN; + double trail_points = kNaN; + double trail_offset = kNaN; + double trail_price = kNaN; + double profit = kNaN; + double loss = kNaN; + switch (form_) { + case ExitActionForm::Stop: stop = 1.0; break; + case ExitActionForm::Limit: limit = 1000.0; break; + case ExitActionForm::Profit: profit = 90'000.0; break; + case ExitActionForm::Loss: loss = 90'000.0; break; + case ExitActionForm::TrailPriceWithOffset: + trail_price = 1000.0; trail_offset = 5.0; break; + case ExitActionForm::TrailPointsWithOffset: + trail_points = 90'000.0; trail_offset = 5.0; break; + case ExitActionForm::TrailOffsetOnly: + trail_offset = 5.0; break; + case ExitActionForm::TrailPriceWithoutOffset: + trail_price = 1000.0; break; + case ExitActionForm::TrailPointsWithoutOffset: + trail_points = 90'000.0; break; + case ExitActionForm::InfiniteStop: + stop = std::numeric_limits::infinity(); break; + case ExitActionForm::InfiniteTrailPoints: + trail_points = std::numeric_limits::infinity(); break; + } + strategy_exit("X", "L", limit, stop, trail_points, trail_offset, + trail_price, 100.0, "probe", kNaN, "", profit, loss); + exits_after_call = 0; + for (const auto& o : pending_orders_) { + if (o.type == OrderType::EXIT) ++exits_after_call; + } + } + +private: + ExitActionForm form_; +}; + +static int pending_exits_for(ExitActionForm form) { + ExitActionabilityProbe p(form); + Bar bars[3] = { + {100, 101, 99, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 2 * k15m_ms}, + }; + p.run(bars, 3); + return p.exits_after_call; +} + +static void test_absolute_and_relative_exit_forms_are_actionable() { + std::printf("test_absolute_and_relative_exit_forms_are_actionable\n"); + CHECK(pending_exits_for(ExitActionForm::Stop) == 1); + CHECK(pending_exits_for(ExitActionForm::Limit) == 1); + CHECK(pending_exits_for(ExitActionForm::Profit) == 1); + CHECK(pending_exits_for(ExitActionForm::Loss) == 1); + // The new gate is intentionally NaN-based, preserving shipped non-Na + // parameter behavior rather than broadening this fix to finite-value QA. + CHECK(pending_exits_for(ExitActionForm::InfiniteStop) == 1); +} + +static void test_trailing_activation_is_actionable_but_offset_only_is_inert() { + std::printf("test_trailing_activation_is_actionable_but_offset_only_is_inert\n"); + CHECK(pending_exits_for(ExitActionForm::TrailPriceWithOffset) == 1); + CHECK(pending_exits_for(ExitActionForm::TrailPointsWithOffset) == 1); + CHECK(pending_exits_for(ExitActionForm::TrailOffsetOnly) == 0); + CHECK(pending_exits_for(ExitActionForm::TrailPriceWithoutOffset) == 1); + CHECK(pending_exits_for(ExitActionForm::TrailPointsWithoutOffset) == 1); + CHECK(pending_exits_for(ExitActionForm::InfiniteTrailPoints) == 1); +} + +// strategy.close is still an ordinary deferred market close when POOC is off. +class ExplicitMarketClose : public BacktestEngine { +public: + ExplicitMarketClose() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 1.0, "long"); + if (bar_index_ == 2 && position_side_ == PositionSide::LONG) + strategy_close("L", "market close"); + } +}; + +static void test_strategy_close_market_behavior_remains() { + std::printf("test_strategy_close_market_behavior_remains\n"); + ExplicitMarketClose p; + Bar bars[5] = { + {100, 101, 99, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, + {103, 104, 102, 103, 1000, kT0_UTC + 2 * k15m_ms}, + {109, 110, 108, 109, 1000, kT0_UTC + 3 * k15m_ms}, + {111, 112, 110, 111, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + const Trade& t = p.get_trade(0); + CHECK(near(t.exit_price, 109.0)); + CHECK(t.exit_bar_index == 3); + CHECK(t.exit_comment == "market close"); + CHECK(t.exit_id == "__close__L"); + } +} + int main() { test_gap_open_long_stop_fills_at_open(); test_gap_open_short_limit_fills_at_open(); test_two_sibling_exits_path_order(); test_cap_autoclose_at_bar_extreme_and_rollover(); test_partial_exit_by_entry_percent(); - test_entry_bar_market_exit_skipped(); + test_no_actionable_exit_fresh_is_inert(); + test_no_actionable_reissue_cancels_prior_exit(); + test_inert_exit_does_not_bind_pending_entry(); + test_inert_exit_has_no_qty_or_oca_reservation(); + test_absolute_and_relative_exit_forms_are_actionable(); + test_trailing_activation_is_actionable_but_offset_only_is_inert(); + test_strategy_close_market_behavior_remains(); std::printf("\n%d passed, %d failed\n", g_pass, g_fail); return g_fail ? 1 : 0; diff --git a/tests/test_integration.cpp b/tests/test_integration.cpp index ff93f78..b3a80e2 100644 --- a/tests/test_integration.cpp +++ b/tests/test_integration.cpp @@ -1490,6 +1490,9 @@ static void test_reversal_uses_explicit_qty_for_new_side() { class PyramidPartialExitStrategy : public BacktestEngine { public: + double position_before_close_all = -1.0; + int partial_trade_rows = -1; + PyramidPartialExitStrategy() { initial_capital_ = 100000; default_qty_type_ = QtyType::FIXED; @@ -1502,16 +1505,23 @@ class PyramidPartialExitStrategy : public BacktestEngine { if (bar_index_ == 1) strategy_entry("E1", true); if (bar_index_ == 2) strategy_entry("E2", true); if (bar_index_ == 3) strategy_entry("E3", true); - // Partial exit: 50% of position + // Actionable partial exit: bar 6 reaches the 110 limit and closes + // exactly 50% of the three-lot pyramided position. if (bar_index_ == 5) strategy_exit("X1", "", - std::numeric_limits::quiet_NaN(), // no limit + 110.0, // actionable limit std::numeric_limits::quiet_NaN(), // no stop std::numeric_limits::quiet_NaN(), // no trail_points std::numeric_limits::quiet_NaN(), // no trail_offset std::numeric_limits::quiet_NaN(), // no trail_price 50.0); // qty_percent - if (bar_index_ == 7) strategy_close_all(); + if (bar_index_ == 7) { + position_before_close_all = signed_position_size(); + partial_trade_rows = trade_count(); + strategy_close_all(); + } } + + double final_position() const { return signed_position_size(); } }; static void test_pyramid_partial_exit() { @@ -1522,11 +1532,22 @@ static void test_pyramid_partial_exit() { bars[i] = {100.0 + i, 105.0 + i, 95.0 + i, 102.0 + i, 50, (int64_t)(i + 1) * 60000}; strat.run(bars, 9); - // Should have trades from partial exit + final close_all - CHECK(strat.trade_count() >= 2); + // The partial leg must have executed before close_all: qty 3 -> 1.5. + CHECK(near(strat.position_before_close_all, 1.5, 1e-9)); + CHECK(strat.partial_trade_rows > 0); + double partial_qty = 0.0; + for (int i = 0; i < strat.trade_count(); ++i) { + if (strat.get_trade(i).exit_id == "X1") { + partial_qty += strat.get_trade(i).qty; + } + } + CHECK(near(partial_qty, 1.5, 1e-9)); + CHECK(near(strat.final_position(), 0.0, 1e-9)); } -// strategy.exit(..., qty_percent<100) should reduce, not flatten, a position. +// An actionable strategy.exit(..., qty_percent<100) should reduce, not flatten, +// a position. The limit leg is required: an all-actionable-NaN strategy.exit is +// inert under the TV-pinned high-level command contract. static void test_exit_qty_percent_reduces_position() { std::printf("test_exit_qty_percent_reduces_position\n"); @@ -1546,7 +1567,7 @@ static void test_exit_qty_percent_reduces_position() { } if (bar_index_ == 1) { strategy_exit("PX", "L", - std::numeric_limits::quiet_NaN(), // no limit + 102.0, // actionable limit, reached on bar 2 std::numeric_limits::quiet_NaN(), // no stop std::numeric_limits::quiet_NaN(), // no trail_points std::numeric_limits::quiet_NaN(), // no trail_offset From e07139e58e8ea76afb589c84e65c4aa10e0060cc Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 04:32:31 +0800 Subject: [PATCH 04/16] fix: reject over-cap priced entries at placement (cherry picked from commit 2df55e30c9fa22fd2eb2d585c4c24405a877eaee) --- src/engine_strategy_commands.cpp | 33 +++-- tests/test_deferred_flip_carry_close_only.cpp | 11 +- tests/test_strategy_pyramiding.cpp | 134 ++++++++++++++++++ 3 files changed, 162 insertions(+), 16 deletions(-) diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 3678fc6..05558a8 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -211,6 +211,23 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, [&](const PendingOrder& o) { return o.id == id; }), pending_orders_.end()); + // On the ordinary non-POOC path, TradingView rejects a same-direction + // priced strategy.entry call when the live position is already at the + // pyramiding cap. This is a placement-time admission rule, not merely a + // fill-time check: a rejected stop/limit must not survive a later reversal + // and fire against the new opposite position. Same-id replacement happens + // first, so an over-cap reissue also removes the older pending order without + // admitting the replacement. Market entries keep their fill-time role- + // change semantics, and POOC entry+close co-queues remain governed by the + // same-close-pass rules. Ground truth: + // order-entry-overcap-priced-admission-01 phases A/B. + bool over_pyramiding_cap = + position_side_ != PositionSide::FLAT + && position_side_ == (is_long ? PositionSide::LONG : PositionSide::SHORT) + && position_entry_count_ >= pyramiding_; + bool is_priced_entry = !std::isnan(limit_price) || !std::isnan(stop_price); + if (is_priced_entry && !process_orders_on_close_ && over_pyramiding_cap) return; + PendingOrder order; order.id = id; order.from_entry = ""; @@ -238,18 +255,10 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.created_position_cycle_seq = position_cycle_seq_; order.created_after_position_close_in_bar = pending_close_qty_in_bar_ > kQtyEpsilon; - // Snapshot the placement-time over-pyramiding-cap status, mirroring the - // fill-time gate (add_to_pyramid_market, engine_orders.cpp) EXACTLY: a - // SAME-direction add against a position already at/over the pyramiding cap - // is one TradingView never admits. Same-id re-placement rebuilds this - // PendingOrder wholesale, so the snapshot naturally refreshes each call. - // The post-full-close same-direction wipe reads this flag to keep drop- - // ping over-cap co-queues even when a co-queued full close zeroes - // position_entry_count_ before the add's fill-time gate runs. - order.over_pyramiding_cap_at_placement = - position_side_ != PositionSide::FLAT - && position_side_ == (is_long ? PositionSide::LONG : PositionSide::SHORT) - && position_entry_count_ >= pyramiding_; + // Market orders and the POOC priced path retain the placement snapshot for + // the downstream full-close compaction and role-change rules. Ordinary + // non-POOC priced orders that are over cap returned above. + order.over_pyramiding_cap_at_placement = over_pyramiding_cap; // TradingView empirical rule (probe 52 trade 113): the deferred-flip // carry is the position size at THIS placement, not the original. // ``strategy.entry`` with the same id replaces the pending order diff --git a/tests/test_deferred_flip_carry_close_only.cpp b/tests/test_deferred_flip_carry_close_only.cpp index 30293f3..1681b78 100644 --- a/tests/test_deferred_flip_carry_close_only.cpp +++ b/tests/test_deferred_flip_carry_close_only.cpp @@ -78,11 +78,14 @@ static Bar mk(double o, double h, double l, double c, int64_t ts) { // ───────────────────────────────────────────────────────────────────── // Deferred-flip carry: a short stop "S" is armed while SHORT (so its // created_position_side is SHORT), the position then flips to LONG via a -// market entry, and "S" survives and triggers against that LONG. TV closes -// the long only; the engine must NOT open a phantom short. +// market entry, and "S" survives and triggers against that LONG. The fixture +// uses pyramiding=2 so the stop is below cap and therefore valid at placement; +// the separate over-cap oracle proves an at-cap stop is rejected. TV closes the +// long only; the engine must NOT open a phantom short. // // bar0: place market short "SH" -// bar1: SH fills @100 → SHORT 1; arm "S" short stop @95 (created SHORT) +// bar1: SH fills @100 → SHORT 1 (< cap 2); arm "S" short stop @95 +// (created SHORT) // bar2: place market long "L" // bar3: L fills @100 → reverses to LONG 1 (SH closed @100). "S"@95 pending, // still carrying created_position_side = SHORT. @@ -103,7 +106,7 @@ static void test_carry_stop_flips_opposite_close_only() { default_qty_value_ = 1.0; slippage_ = 0; commission_value_ = 0; - pyramiding_ = 1; + pyramiding_ = 2; syminfo_mintick_ = 0.01; } double pos_size() const { return signed_position_size(); } diff --git a/tests/test_strategy_pyramiding.cpp b/tests/test_strategy_pyramiding.cpp index 9403c81..bb75996 100644 --- a/tests/test_strategy_pyramiding.cpp +++ b/tests/test_strategy_pyramiding.cpp @@ -555,12 +555,146 @@ static void test_per_leg_fifo_pnl_three_legs() { } } +// A priced strategy.entry submitted in the current position's direction while +// that position is already at the pyramiding cap is rejected at placement. +// It must not remain armed and fire after a later reversal makes its direction +// opposite to the live position. TradingView oracle: +// order-entry-overcap-priced-admission-01, phase A. +static void test_overcap_priced_entry_does_not_survive_reversal() { + std::printf("test_overcap_priced_entry_does_not_survive_reversal\n"); + class Probe : public BacktestEngine { + public: + struct TradeRow { + std::string entry_id; + double exit_price; + int64_t exit_time; + }; + std::vector closed_trades; + + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 1; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("base-long", true); + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) + strategy_entry("overcap-long", true, + std::numeric_limits::quiet_NaN(), + /*stop=*/110.0); + if (bar_index_ == 2) + strategy_entry("live-short", false); + if (bar_index_ == 5) + strategy_close_all(); + if (bar_index_ == 7) { + for (const auto& t : trades_) + closed_trades.push_back({t.entry_id, t.exit_price, t.exit_time}); + } + } + }; + + Probe p; + Bar bars[8]; + for (int i = 0; i < 8; ++i) { + bars[i] = {100, 101, 99, 100, 1000, (int64_t)(i + 1) * 60'000}; + } + // If the over-cap stop leaked into pending_orders_, it fires here while + // SHORT and closes that position at 110 instead of the later cleanup. + bars[4].high = 115; + p.run(bars, 8); + + CHECK(p.closed_trades.size() == 2); + bool found_short = false; + for (const auto& tr : p.closed_trades) { + if (tr.entry_id == "live-short") { + found_short = true; + CHECK(near(tr.exit_price, 100.0)); + CHECK(tr.exit_time == bars[6].timestamp); + } + } + CHECK(found_short); +} + +// Same-id contract: an over-cap reissue first replaces (removes) the older +// pending order, then the new priced order is rejected. Neither the new level +// nor the old level may fire after a reversal. TradingView oracle: +// order-entry-overcap-priced-admission-01, phase B. +static void test_overcap_same_id_reissue_removes_old_pending_order() { + std::printf("test_overcap_same_id_reissue_removes_old_pending_order\n"); + class Probe : public BacktestEngine { + public: + struct TradeRow { + std::string entry_id; + double exit_price; + int64_t exit_time; + }; + std::vector closed_trades; + + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 1; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("pending-long", true, + std::numeric_limits::quiet_NaN(), + /*stop=*/130.0); + if (bar_index_ == 1) + strategy_entry("base-long", true); + if (bar_index_ == 2 && position_side_ == PositionSide::LONG) + strategy_entry("pending-long", true, + std::numeric_limits::quiet_NaN(), + /*stop=*/110.0); + if (bar_index_ == 3) + strategy_entry("live-short", false); + if (bar_index_ == 7) + strategy_close_all(); + if (bar_index_ == 9) { + for (const auto& t : trades_) + closed_trades.push_back({t.entry_id, t.exit_price, t.exit_time}); + } + } + }; + + Probe p; + Bar bars[10]; + for (int i = 0; i < 10; ++i) { + bars[i] = {100, 101, 99, 100, 1000, (int64_t)(i + 1) * 60'000}; + } + bars[5].high = 115; // would touch the rejected replacement at 110 + bars[6].high = 135; // would touch the removed old order at 130 + p.run(bars, 10); + + CHECK(p.closed_trades.size() == 2); + bool found_short = false; + for (const auto& tr : p.closed_trades) { + if (tr.entry_id == "live-short") { + found_short = true; + CHECK(near(tr.exit_price, 100.0)); + CHECK(tr.exit_time == bars[8].timestamp); + } + } + CHECK(found_short); +} + int main() { test_deferred_flip_chain_grows(); test_same_direction_no_carry(); test_two_cycle_siblings_independent_carry(); test_per_bar_pending_close_resets_in_script_tf_run(); test_per_leg_fifo_pnl_three_legs(); + test_overcap_priced_entry_does_not_survive_reversal(); + test_overcap_same_id_reissue_removes_old_pending_order(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; From b5a85895a65696eb9aa74f9b1486f9c05b991846 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 05:19:28 +0800 Subject: [PATCH 05/16] fix: resume prearmed exits after parent fill (cherry picked from commit 4e54a77880055d727350458a3cfd0d742a1503e1) --- include/pineforge/engine.hpp | 5 + src/engine_fills.cpp | 65 ++++++++++++- src/engine_internal.hpp | 3 +- src/engine_orders.cpp | 3 +- src/engine_path_resolve.cpp | 22 ++++- tests/CMakeLists.txt | 1 + tests/test_prearmed_exit_path_cursor.cpp | 114 +++++++++++++++++++++++ 7 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 tests/test_prearmed_exit_path_cursor.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 991979c..99fd9f7 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -59,6 +59,11 @@ struct PyramidEntry { // sequence, the exit covers (scratches) the add dur-0. Gated by // entry_bar_index == bar_index_ so prior-bar slices are never covered. bool market_pyramid_add = false; + // Synthetic OHLC-path coordinate where a pure-stop strategy.entry fired. + // A single flat-born, non-trailing from_entry bracket may inspect only the + // path suffix at/after this cursor on the entry bar. NaN marks every + // unrouted parent class (market, limit, stop-limit, raw order). + double entry_path_position = std::numeric_limits::quiet_NaN(); }; struct Trade { diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 619dbdf..e73b3ad 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -2225,6 +2225,20 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri && pyramid_entries_.back().entry_id == order.id) { set_entry_fill_excursion_masks(pyramid_entries_.back(), bar, pyramid_entries_.back().price); + // Keep the bracket-activation cursor separate from the booked + // fill price used by excursion accounting. Stop fills can be + // rounded or slipped; the child becomes live at the actual, + // direction-aware parent trigger crossing. + if (!std::isnan(order.stop_price) + && std::isnan(order.limit_price)) { + double entry_path_position = 0.0; + if (internal::entry_stop_first_touch( + bar, order.stop_price, order.is_long, + &entry_path_position)) { + pyramid_entries_.back().entry_path_position = + entry_path_position; + } + } } } trail_best_path_state = trail_best_after_fill; @@ -2920,6 +2934,54 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( is_limit_fill = true; } } else if (!should_fill && exit_style && (has_stop || has_limit || has_trail)) { + double path_start_position = 0.0; + // Ordinary historical processing scans the retained pure-stop parent + // entry and its from_entry bracket in one pass. Once the parent fills, + // the child may inspect only the remaining OHLC path. COOF already + // supplies a monotonic segment cursor, and magnifier has its own tick + // path, so neither is routed through this full-bar coordinate. Keep + // multi-order exit groups on the existing path until their sibling + // ordering metric is cursor-aware; a single strategy.exit may still + // carry both its stop and limit legs inside one order. + if (is_entry_bar + && order.type == OrderType::EXIT + && !order.from_entry.empty() + && !order.created_while_in_position + && std::isnan(order.trail_points) + && std::isnan(order.trail_price) + && !bar_magnifier_enabled_ + && !(calc_on_order_fills_ && coof_scheduler_active_)) { + int matching_exit_orders = 0; + for (const PendingOrder& pending : pending_orders_) { + if (pending.type == OrderType::EXIT + && pending.from_entry == order.from_entry) { + ++matching_exit_orders; + } + } + bool found_parent = false; + double earliest_parent = std::numeric_limits::infinity(); + for (const PyramidEntry& pe : pyramid_entries_) { + if (matching_exit_orders != 1) break; + if (pe.entry_id != order.from_entry + || pe.entry_bar_index != bar_index_ + || pe.time != bar.timestamp) { + continue; + } + found_parent = true; + // A matching market/raw parent was active from the open, so + // the bracket keeps the full path even if another same-id + // priced add filled later this bar. + if (!std::isfinite(pe.entry_path_position)) { + earliest_parent = 0.0; + break; + } + earliest_parent = std::min(earliest_parent, + pe.entry_path_position); + } + if (found_parent && std::isfinite(earliest_parent)) { + path_start_position = earliest_parent; + } + } ExitPathFill exit_fill = resolve_exit_path_fill( bar, position_side_, @@ -2933,7 +2995,8 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( is_entry_bar, bar_magnifier_enabled_, syminfo_mintick_, - coof_cascade_force_wp_gap_); + coof_cascade_force_wp_gap_, + path_start_position); 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 a3e70fa..8efd62a 100644 --- a/src/engine_internal.hpp +++ b/src/engine_internal.hpp @@ -219,7 +219,8 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, bool is_entry_bar, bool magnifier_active, double syminfo_mintick, - bool cascade_wp_gap = false); + bool cascade_wp_gap = false, + double path_start_position = 0.0); // KI-67 exit cascade (Model S "R-cascade-gapjump"). Given the in-flight LEG diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index cc2e787..c5563c9 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -188,7 +188,8 @@ double BacktestEngine::fifo_drain(const std::string* from_entry, double qty_limi pe.max_drawdown * keep_scale, pe.skip_entry_bar_high, pe.skip_entry_bar_low, - pe.market_pyramid_add}); + pe.market_pyramid_add, + pe.entry_path_position}); } } diff --git a/src/engine_path_resolve.cpp b/src/engine_path_resolve.cpp index 006ee85..db09f70 100644 --- a/src/engine_path_resolve.cpp +++ b/src/engine_path_resolve.cpp @@ -800,7 +800,8 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, bool is_entry_bar, bool magnifier_active, double syminfo_mintick, - bool cascade_wp_gap) { + bool cascade_wp_gap, + double path_start_position) { ExitPathFill fill; if (position_side == PositionSide::FLAT) return fill; @@ -827,7 +828,9 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, // 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) { + const double path_cursor = std::clamp(path_start_position, 0.0, 3.0); + if (path_cursor <= kPathPosEps + && (!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; @@ -838,8 +841,21 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, fill_bar_path_points(bar, path); for (int seg_idx = 1; seg_idx < 4; ++seg_idx) { - const double from_price = path[seg_idx - 1]; + // A priced parent entry can activate a resting from_entry bracket in + // the middle of this path. Skip every elapsed segment and truncate + // the containing segment to the unconsumed suffix. A waypoint cursor + // resumes on the following segment under that segment's normal + // direction-specific stop/limit rules. + if (path_cursor >= static_cast(seg_idx) - kPathPosEps) { + continue; + } + double from_price = path[seg_idx - 1]; const double to_price = path[seg_idx]; + const double seg_start = static_cast(seg_idx - 1); + if (path_cursor > seg_start + kPathPosEps) { + const double t = std::clamp(path_cursor - seg_start, 0.0, 1.0); + from_price += (to_price - from_price) * t; + } const bool rising = to_price > from_price; const bool falling = to_price < from_price; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fab1ec1..f9578d2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -97,6 +97,7 @@ set(TEST_SOURCES test_coof_cascade_eligibility test_cascade_exit_gapjump test_pooc_position_visibility + test_prearmed_exit_path_cursor ) find_package(Threads REQUIRED) diff --git a/tests/test_prearmed_exit_path_cursor.cpp b/tests/test_prearmed_exit_path_cursor.cpp new file mode 100644 index 0000000..acacda3 --- /dev/null +++ b/tests/test_prearmed_exit_path_cursor.cpp @@ -0,0 +1,114 @@ +/* + * A resting strategy.exit bracket becomes eligible only once its priced + * from_entry parent fills. On that entry bar it may consume the remaining + * synthetic OHLC path, never a stop touch that preceded the parent fill. + * + * The four cells mirror the TradingView-pinned clean-room probe + * order-pooc-resting-bracket-path-01: A/C have a pre-entry-only stop touch and + * must exit next bar; B/D touch the stop after entry and must exit same bar. + */ + +#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) + +static bool near(double a, double b, double tol = 1e-9) { + return std::fabs(a - b) <= tol; +} + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +enum class Cell { LongPre, LongPost, ShortPre, ShortPost }; + +class RestingBracketProbe final : public BacktestEngine { +public: + explicit RestingBracketProbe(Cell cell) : cell_(cell) { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + process_orders_on_close_ = true; + calc_on_order_fills_ = false; + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + const bool is_long = cell_ == Cell::LongPre || cell_ == Cell::LongPost; + const double entry = is_long ? 110.0 : 90.0; + const double stop = is_long ? 90.0 : 110.0; + strategy_entry("E", is_long, kNaN, entry, 1.0, "priced parent"); + strategy_exit("X", "E", kNaN, stop, kNaN, kNaN, kNaN, + 100.0, "resting child"); + } + +private: + Cell cell_; +}; + +static Bar bar(double o, double h, double l, double c, int64_t ts) { + return {o, h, l, c, 1'000.0, ts}; +} + +static void check_cell(Cell cell, bool is_long, bool pre_entry_touch) { + RestingBracketProbe probe(cell); + Bar bars[3] = { + bar(100.0, 101.0, 99.0, 100.0, 900'000), + // LongPre: O->L->H->C, SL 90 before entry 110. + // LongPost: O->H->L->C, entry 110 before SL 90. + // ShortPre: O->H->L->C, SL 110 before entry 90. + // ShortPost:O->L->H->C, entry 90 before SL 110. + cell == Cell::LongPre + ? bar(100.0, 120.0, 80.0, 105.0, 1'800'000) + : cell == Cell::LongPost + ? bar(100.0, 115.0, 80.0, 105.0, 1'800'000) + : cell == Cell::ShortPre + ? bar(100.0, 115.0, 80.0, 95.0, 1'800'000) + : bar(100.0, 120.0, 85.0, 95.0, 1'800'000), + is_long + ? bar(105.0, 108.0, 85.0, 95.0, 2'700'000) + : bar(95.0, 115.0, 90.0, 100.0, 2'700'000), + }; + + probe.run(bars, 3); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& trade = probe.get_trade(0); + CHECK(trade.is_long == is_long); + CHECK(near(trade.entry_price, is_long ? 110.0 : 90.0)); + CHECK(near(trade.exit_price, is_long ? 90.0 : 110.0)); + CHECK(trade.entry_bar_index == 1); + CHECK(trade.exit_bar_index == (pre_entry_touch ? 2 : 1)); +} + +int main() { + std::printf("pre-armed from_entry bracket path cursor\n"); + check_cell(Cell::LongPre, true, true); + check_cell(Cell::LongPost, true, false); + check_cell(Cell::ShortPre, false, true); + check_cell(Cell::ShortPost, false, false); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} From 09ed5b527f4624592e84b421befb130d9325359a Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 07:16:28 +0800 Subject: [PATCH 06/16] fix: gap-fill prearmed market-parent stops (cherry picked from commit 40f9adb2275e1d0afdae988cfb2663dfbc6f6774) --- include/pineforge/engine.hpp | 8 + src/engine_fills.cpp | 119 ++++++++- tests/CMakeLists.txt | 1 + tests/test_magnifier_real_bars.cpp | 31 +-- .../test_prearmed_market_parent_gap_exit.cpp | 250 ++++++++++++++++++ 5 files changed, 384 insertions(+), 25 deletions(-) create mode 100644 tests/test_prearmed_market_parent_gap_exit.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 99fd9f7..b232d53 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -2052,6 +2052,14 @@ class BacktestEngine { void sort_orders_by_fill_phase(const Bar& bar); bool pending_flat_market_pair_is_live(const PendingOrder& order) const; void invalidate_pending_flat_market_pair(int64_t created_seq); + // TradingView binds a valid, single/full, non-trailing strategy.exit to a + // co-queued high-level MARKET parent. If that parent fills at the next open + // and its stop is already breached, the newborn lot scratches at that open. + // The helper proves the parent/child/fresh-lot provenance; it deliberately + // excludes POOC, COOF, magnifier, same-direction adds, priced parents, and + // multi-child groups. + bool prearmed_market_parent_stop_gaps_at_open( + const PendingOrder& order, const Bar& bar) const; void compact_filled_pending_orders(const std::vector& filled_indices, int exit_closed_from_bar, uint64_t exit_closed_from_incarnation, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index e73b3ad..7196acd 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1063,6 +1063,98 @@ bool BacktestEngine::pending_flat_market_pair_is_live( return false; } +// A strategy.exit can be armed on the signal bar together with the MARKET +// strategy.entry named by from_entry. The child is valid before the parent +// fills: TradingView binds it to the eventual lot, and if the next open has +// already breached its stop, it fills both parent and child at that same open. +// Clean-room probe order-market-reversal-resting-bracket-gap-01 pins this for +// both directions and for parents placed from true flat or as reversals. +// +// Do not turn this into a general entry-bar wrong-side bypass. The exact +// provenance below keeps freshly emitted/stale exits, priced parents, MARKET +// pyramid adds, partial/sibling groups, POOC, COOF, and magnifier on their +// existing paths. Generated Pine already lowers flat +// strategy.position_avg_price to na, so an avg-derived flat bracket never +// reaches this helper with a finite stop. +bool BacktestEngine::prearmed_market_parent_stop_gaps_at_open( + const PendingOrder& order, const Bar& bar) const { + if (process_orders_on_close_ || calc_on_order_fills_ || bar_magnifier_enabled_) { + return false; + } + if (position_side_ == PositionSide::FLAT + || position_open_bar_ != bar_index_ + || order.type != OrderType::EXIT + || order.from_entry.empty() + || order.created_bar != bar_index_ - 1 + || order.created_during_coof_recalc + || order.requested_partial + || order.qty_percent < 100.0 - kFullPercentEps + || !std::isfinite(order.stop_price) + || !std::isnan(order.trail_points) + || !std::isnan(order.trail_price)) { + return false; + } + + const bool live_long = position_side_ == PositionSide::LONG; + const bool stop_gapped = live_long ? bar.open <= order.stop_price + : bar.open >= order.stop_price; + if (!stop_gapped) return false; + + // The oracle has a correctly-sided, nonmarketable limit sibling inside the + // same bracket, not a second open-gap leg. Keep dual-marketable brackets + // out until a dedicated priority oracle exists. Test the actual W0 broker + // predicate: equality is marketable, and slippage can make the booked entry + // price differ from the bar open. + if (!std::isnan(order.limit_price)) { + const bool limit_gapped = live_long ? bar.open >= order.limit_price + : bar.open <= order.limit_price; + if (limit_gapped) return false; + } + + int matching_children = 0; + for (const PendingOrder& pending : pending_orders_) { + if (pending.type == OrderType::EXIT + && pending.from_entry == order.from_entry) { + ++matching_children; + } + } + if (matching_children != 1) return false; + + // This oracle path is a one-parent/one-lot scratch. Requiring the fresh + // matching lot to be the entire live position prevents a bracket for E + // from consuming a co-queued MARKET sibling F. An explicit qty armed while + // flat is not labelled requested_partial at placement, so also prove that + // its literal quantity covers the newborn lot before taking the shortcut. + if (pyramid_entries_.size() != 1) return false; + const PyramidEntry& fresh_lot = pyramid_entries_.front(); + if (fresh_lot.entry_id != order.from_entry + || fresh_lot.entry_bar_index != bar_index_ + || fresh_lot.time != bar.timestamp + || std::isfinite(fresh_lot.entry_path_position) + || (std::isfinite(order.qty) + && fresh_lot.qty - order.qty > kQtyEpsilon)) { + return false; + } + + for (const PendingOrder& parent : pending_orders_) { + if (parent.id != order.from_entry + || parent.type != OrderType::MARKET + || parent.created_bar != order.created_bar + || parent.created_seq >= order.created_seq + || parent.created_position_side != order.created_position_side + || parent.is_long != live_long) { + continue; + } + // True-flat parents and opposite-side reversals are pinned. A parent + // born in the live side is a pyramid add and remains out of scope. + if (parent.created_position_side == PositionSide::FLAT + || parent.created_position_side != position_side_) { + return true; + } + } + return false; +} + void BacktestEngine::invalidate_pending_flat_market_pair(int64_t created_seq) { if (created_seq <= 0) return; for (PendingOrder& order : pending_orders_) { @@ -2809,17 +2901,13 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( // Same-bar exit handling: TradingView evaluates priced exits (stop/limit/ // trail) on the entry bar itself (entry fills at open, then intra-bar - // data evaluates exits). But skip if the exit has garbage values - // (computed when position was flat). + // data evaluates exits). A generic wrong-side level is blocked unless the + // prearmed MARKET-parent helper proves that it is a valid open-gap child. // // The wrong-side eligibility skip (stop > entry for long, etc.) gates - // out spurious orders whose stop/limit was derived from - // ``strategy.position_avg_price`` while flat: Pine returns ``na`` and - // arithmetic propagates ``na`` (so the order becomes a no-op), but the - // engine returns ``0.0`` and arithmetic produces a numerically-valid - // but semantically-wrong-side level (negative or near-zero). Without - // the skip those orders gap-fill at the entry bar's open (every bar a - // signal fires would close at $0 PnL). + // out freshly emitted or stale levels that would have triggered before + // the position opened. Generated Pine separately preserves flat + // ``strategy.position_avg_price == na`` before it reaches this layer. // // The magnifier corpus (probe-01..08b) places exits with USER-COMPUTED // valid wrong-side stops (e.g. ``open + (high-open)*0.5`` is between @@ -2844,7 +2932,9 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( return OrderEligibility::Skip; } } - if (!bar_magnifier_enabled_ + const bool prearmed_market_gap = + prearmed_market_parent_stop_gaps_at_open(order, bar); + if (!prearmed_market_gap && !bar_magnifier_enabled_ && !(calc_on_order_fills_ && coof_scheduler_active_ && order.created_during_coof_recalc)) { double ep = position_entry_price_; @@ -2894,6 +2984,15 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( bool should_fill = false; bool is_limit_fill = false; + // A valid child that was armed with its pending MARKET parent and whose + // stop is already breached at the parent's fill open scratches there. + // Route it directly: the generic entry-bar resolver intentionally blocks + // wrong-side levels and remains unchanged for every other provenance. + if (exit_style && prearmed_market_parent_stop_gaps_at_open(order, bar)) { + fill_price = bar.open; + should_fill = true; + } + // If every non-trailing priced leg is suppressed on the entry bar, the // order is dormant rather than becoming a market exit. The original // prices remain stored on PendingOrder and become active next bar. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f9578d2..f36d2a6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -98,6 +98,7 @@ set(TEST_SOURCES test_cascade_exit_gapjump test_pooc_position_visibility test_prearmed_exit_path_cursor + test_prearmed_market_parent_gap_exit ) find_package(Threads REQUIRED) diff --git a/tests/test_magnifier_real_bars.cpp b/tests/test_magnifier_real_bars.cpp index 7072312..6d7bf1c 100644 --- a/tests/test_magnifier_real_bars.cpp +++ b/tests/test_magnifier_real_bars.cpp @@ -237,25 +237,26 @@ static void test_wrong_side_stop_fills_at_entry_under_magnifier() { } } -// Without magnifier, the legacy non-magnifier path applies: a wrong-side -// stop placed on the signal bar must NOT fire on the entry bar (the bar -// path doesn't cross the stop on a falling segment, and the eligibility -// skip in classify_order_eligibility blocks the gap-shortcut). This is -// the gate that protects scripts which derive stops/limits from -// strategy.position_avg_price while flat (the engine returns 0.0 there -// instead of na, producing numerically-valid but semantically-wrong-side -// levels — without the gate every such bar would close at $0 PnL). -static void test_wrong_side_stop_blocked_without_magnifier() { - std::printf("test_wrong_side_stop_blocked_without_magnifier\n"); +// Without magnifier, a valid literal bracket prearmed with its pending MARKET +// parent still gap-fills at the parent's next-open fill. The clean-room C/D +// cells pin this independently of reversal provenance. Generated Pine protects +// avg-derived flat brackets by lowering strategy.position_avg_price to na. +static void test_prearmed_market_parent_stop_fills_without_magnifier() { + std::printf("test_prearmed_market_parent_stop_fills_without_magnifier\n"); WrongSideStopStrat strat; auto bars = make_two_15m_bars_for_wrong_side(); - // Aggregate to 15m but with magnifier OFF: the entry bar runs as a - // single tick and the wrong-side stop should not fire on it. The - // strategy never closes the position, so no trades are emitted. + // Aggregate to 15m with magnifier OFF: parent and child fill at the same + // script-bar open, producing one zero-PnL trade. strat.run(bars.data(), (int)bars.size(), "1", "15", false, 4, MagnifierDistribution::ENDPOINTS); - CHECK(strat.trade_count() == 0); + CHECK(strat.trade_count() == 1); + if (strat.trade_count() == 1) { + const auto& t = strat.get_trade(0); + CHECK(near(t.entry_price, 100.0, 1e-9)); + CHECK(near(t.exit_price, 100.0, 1e-9)); + CHECK(t.entry_bar_index == t.exit_bar_index); + } } int main() { @@ -263,7 +264,7 @@ int main() { test_distribution_irrelevant_when_real_sub_bars(); test_legacy_path_used_when_single_sub_bar(); test_wrong_side_stop_fills_at_entry_under_magnifier(); - test_wrong_side_stop_blocked_without_magnifier(); + test_prearmed_market_parent_stop_fills_without_magnifier(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed > 0 ? 1 : 0; diff --git a/tests/test_prearmed_market_parent_gap_exit.cpp b/tests/test_prearmed_market_parent_gap_exit.cpp new file mode 100644 index 0000000..fcb380b --- /dev/null +++ b/tests/test_prearmed_market_parent_gap_exit.cpp @@ -0,0 +1,250 @@ +/* + * A valid strategy.exit bracket can be armed before its from_entry MARKET + * parent fills. When the parent opens at the next bar's open and that open has + * already breached the retained stop, TradingView scratches the new position + * at the same open. This applies to parents placed from true flat and to + * opposite-side market reversals. Correctly-sided stops continue to walk the + * remaining entry-bar path and fill at their level. + * + * The six cells mirror the clean-room TradingView probe + * order-market-reversal-resting-bracket-gap-01 (A-F). + */ + +#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) + +static bool near(double a, double b, double tol = 1e-9) { + return std::fabs(a - b) <= tol; +} + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +enum class Cell { + FlatLongGap, + FlatShortGap, + ReversalLongGap, + ReversalShortGap, + ReversalLongPostOpen, + ReversalShortPostOpen, +}; + +class PrearmedMarketBracketProbe final : public BacktestEngine { +public: + explicit PrearmedMarketBracketProbe(Cell cell) : cell_(cell) { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + process_orders_on_close_ = false; + calc_on_order_fills_ = false; + } + + double live_qty() const { return position_qty_; } + bool is_flat() const { return position_side_ == PositionSide::FLAT; } + + void on_bar(const Bar&) override { + const bool flat_parent = cell_ == Cell::FlatLongGap + || cell_ == Cell::FlatShortGap; + const bool opens_long = cell_ == Cell::FlatLongGap + || cell_ == Cell::ReversalLongGap + || cell_ == Cell::ReversalLongPostOpen; + + if (flat_parent && bar_index_ == 0) { + strategy_entry("E", opens_long, kNaN, kNaN, 1.0, "flat parent"); + strategy_exit("X", "E", opens_long ? 120.0 : 80.0, + /*stop=*/opens_long ? 105.0 : 95.0, + kNaN, kNaN, kNaN, 100.0, "prearmed gap"); + return; + } + + if (!flat_parent && bar_index_ == 0) { + strategy_entry("OLD", !opens_long, kNaN, kNaN, 1.0, "seed"); + return; + } + + if (!flat_parent && bar_index_ == 1) { + strategy_entry("E", opens_long, kNaN, kNaN, 1.0, "reverse parent"); + const bool post_open = cell_ == Cell::ReversalLongPostOpen + || cell_ == Cell::ReversalShortPostOpen; + const double stop = opens_long + ? (post_open ? 95.0 : 105.0) + : (post_open ? 105.0 : 95.0); + strategy_exit("X", "E", opens_long ? 120.0 : 80.0, stop, + kNaN, kNaN, kNaN, 100.0, "prearmed stop"); + } + } + +private: + Cell cell_; +}; + +static Bar bar(int64_t ts, double o, double h, double l, double c) { + return {o, h, l, c, 1'000.0, ts}; +} + +static void check_flat_gap(Cell cell, bool is_long) { + PrearmedMarketBracketProbe probe(cell); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 102.0, 98.0, 100.0), + bar(3'000, 100.0, 102.0, 98.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& t = probe.get_trade(0); + CHECK(t.is_long == is_long); + CHECK(t.entry_bar_index == 1); + CHECK(t.exit_bar_index == 1); + CHECK(near(t.entry_price, 100.0)); + CHECK(near(t.exit_price, 100.0)); + CHECK(near(t.qty, 1.0)); + CHECK(near(t.pnl, 0.0)); + CHECK(t.exit_id == "X"); + CHECK(probe.is_flat()); + CHECK(near(probe.live_qty(), 0.0)); +} + +static void check_reversal(Cell cell, bool new_is_long, bool post_open) { + PrearmedMarketBracketProbe probe(cell); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 101.0, 99.0, 100.0), + // Gap cells have a wrong-side stop and must scratch at O=100. The + // post-open controls have a correctly-sided stop at 95/105, crossed + // later by L=90 or H=110 and filled at that level. + bar(3'000, 100.0, 110.0, 90.0, 100.0), + bar(4'000, 100.0, 110.0, 90.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 2); + if (probe.trade_count() != 2) return; + const Trade& old = probe.get_trade(0); + const Trade& fresh = probe.get_trade(1); + CHECK(old.is_long != new_is_long); + CHECK(fresh.is_long == new_is_long); + CHECK(fresh.entry_bar_index == 2); + CHECK(fresh.exit_bar_index == 2); + CHECK(near(fresh.entry_price, 100.0)); + CHECK(near(fresh.exit_price, + post_open ? (new_is_long ? 95.0 : 105.0) : 100.0)); + CHECK(near(fresh.qty, 1.0)); + CHECK(near(fresh.pnl, + post_open ? -5.0 : 0.0)); + CHECK(fresh.exit_id == "X"); + CHECK(probe.is_flat()); + CHECK(near(probe.live_qty(), 0.0)); +} + +class PartialFlatBracket final : public BacktestEngine { +public: + explicit PartialFlatBracket(double exit_qty) : exit_qty_(exit_qty) { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 1; + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + strategy_entry("E", true, kNaN, kNaN, 1.0); + strategy_exit("X", "E", 120.0, 105.0, + kNaN, kNaN, kNaN, 100.0, "partial", exit_qty_); + } + + double live_qty() const { return position_qty_; } + +private: + double exit_qty_; +}; + +static void check_partial_qty_does_not_scratch_parent_open(double exit_qty) { + PartialFlatBracket probe(exit_qty); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 102.0, 98.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 0); + CHECK(near(probe.live_qty(), 1.0)); +} + +class MultipleFlatParents final : public BacktestEngine { +public: + MultipleFlatParents() { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 2; + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + strategy_entry("E", true, kNaN, kNaN, 1.0); + strategy_entry("F", true, kNaN, kNaN, 1.0); + strategy_exit("X", "E", 120.0, 105.0, + kNaN, kNaN, kNaN, 100.0, "multi-parent"); + } + + double live_qty() const { return position_qty_; } +}; + +static void check_multiple_market_parents_do_not_share_scratch() { + MultipleFlatParents probe; + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 102.0, 98.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 0); + CHECK(near(probe.live_qty(), 2.0)); +} + +int main() { + std::printf("prearmed MARKET-parent bracket gap exits\n"); + + check_flat_gap(Cell::FlatLongGap, true); + check_flat_gap(Cell::FlatShortGap, false); + check_reversal(Cell::ReversalLongGap, true, false); + check_reversal(Cell::ReversalShortGap, false, false); + check_reversal(Cell::ReversalLongPostOpen, true, true); + check_reversal(Cell::ReversalShortPostOpen, false, true); + check_partial_qty_does_not_scratch_parent_open(0.5); + // kFullQtyEps is wider than the engine's actual flattening threshold. A + // near-full literal that would leave live dust must remain off this path. + check_partial_qty_does_not_scratch_parent_open(0.9999999995); + check_multiple_market_parents_do_not_share_scratch(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} From 0fd4f1d4f1ac4d1fb8e47bedc25ccbecba6f5c8d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 07:57:04 +0800 Subject: [PATCH 07/16] fix: match chart EMA range-start warmup (cherry picked from commit 4b631e171060c3db9d65aa7c4a7001438bb5aad2) --- include/pineforge/engine.hpp | 24 ++- include/pineforge/ta.hpp | 19 +- src/engine_run.cpp | 33 +++- src/engine_stream.cpp | 2 +- src/ta_moving_averages.cpp | 16 +- tests/CMakeLists.txt | 1 + tests/test_chart_ema_na_warmup.cpp | 297 +++++++++++++++++++++++++++++ 7 files changed, 362 insertions(+), 30 deletions(-) create mode 100644 tests/test_chart_ema_na_warmup.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index b232d53..c2b7545 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -811,11 +811,16 @@ class BacktestEngine { Bar current_bar_; int bar_index_ = 0; int bar_index_offset_ = 0; - // Opt-in KI-55 HTF warmup parity (see set_syminfo_metadata, - // "security_range_start_na_warmup"). When enabled, request.security series - // aggregate from security_range_start_ms_ instead of the feed start and - // their embedded ta.ema na-warm per TV built-in semantics. Default OFF → - // byte-identical behavior; touched only through feed_security_eval_state. + // Opt-in KI-55 chart warmup parity (see set_syminfo_metadata, + // "chart_ema_na_warmup"). When enabled, chart-timeframe ta.ema instances + // first used by on_bar na-warm per TV built-in semantics. This selector is + // scoped independently from request.security so the two execution contexts + // cannot leak their warmup mode into each other. Default OFF. + bool chart_ema_na_warmup_ = false; + // Independent opt-in KI-55 HTF warmup parity. When enabled, + // request.security series aggregate from security_range_start_ms_ instead + // of the feed start and their embedded ta.ema na-warm per TV built-in + // semantics. Default OFF; touched only through feed_security_eval_state. bool security_range_start_na_warmup_ = false; int64_t security_range_start_ms_ = 0; // Opt-in historical-only request.security lookahead projection. TradingView @@ -2256,6 +2261,7 @@ class BacktestEngine { // Shared by run(), run_simple_bar_loop, and the no-magnifier aggregation // path. The magnifier tick loop does NOT use this — it gates the sequence // on is_last_tick_ and forces is_first_tick_ before on_bar. + void invoke_chart_on_bar(const Bar& bar); void dispatch_bar(); void dispatch_bar_calc_on_order_fills(); void snapshot_coof_script_state(); @@ -2454,6 +2460,14 @@ class BacktestEngine { historical_security_lookahead_projection_ = std::isfinite(value) && value > 0.0; } + // Opt-in KI-55 chart-timeframe EMA warmup parity. This is a boolean + // run configuration carried through the existing metadata channel: + // positive finite values enable it; 0 / NaN / negative disable it. + // Unlike security_range_start_na_warmup, it carries no timestamp and + // does not change request.security aggregation boundaries. + if (key == "chart_ema_na_warmup") { + chart_ema_na_warmup_ = std::isfinite(value) && value > 0.0; + } // "qty_step" is the per-instrument lot increment used by the forced- // liquidation quantizer. Route it onto the dedicated member so the // codegen run(const Bar*, int) path (which never overwrites it) keeps diff --git a/include/pineforge/ta.hpp b/include/pineforge/ta.hpp index c68e139..b4f30be 100644 --- a/include/pineforge/ta.hpp +++ b/include/pineforge/ta.hpp @@ -16,18 +16,17 @@ namespace ta { // have accumulated, then seeds with the SMA of those first ``length`` values — // unlike the documented ``pine_ema`` reference impl (and this engine's default), // which seed ``ema := src`` on the first bar and are therefore never ``na``. -// The difference only bites a ``request.security`` HTF series read under a -// range-start-truncated feed (KI-55): the security's embedded ``ta.ema`` must -// be ``na`` for its whole warmup window to match TV, exactly as ``ta.rma`` / -// ``ta.sma`` already are. +// The difference matters for range-start-truncated chart and +// ``request.security`` series (KI-55): the relevant ``ta.ema`` must be ``na`` +// for its whole warmup window to match TV, exactly as ``ta.rma`` / ``ta.sma`` +// already are. // // An ``EMA`` instance latches this flag on its first ``compute()`` and keeps -// that mode for life. The engine raises the flag ONLY around -// ``request.security`` evaluation while the ``security_range_start_na_warmup`` -// run flag is active (see BacktestEngine::feed_security_eval_state), so -// security-embedded EMAs latch ``true`` and chart-timeframe EMAs latch -// ``false``. When the run flag is never set the toggle stays ``false`` and -// every EMA is byte-identical to the prior src-seed behavior. +// that mode for life. The engine scopes the flag independently around chart +// ``on_bar`` dispatch (``chart_ema_na_warmup``) and request.security evaluation +// (``security_range_start_na_warmup``), so either context can opt in without +// contaminating the other. When neither run flag is set, every EMA is +// byte-identical to the prior src-seed behavior. bool& ema_na_warmup_flag(); class RMA { diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 0781d3f..69b8b1e 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -4,6 +4,8 @@ #include "engine_internal.hpp" +#include + #include #include #include @@ -16,6 +18,27 @@ using namespace internal; // open_trade_* accessors moved to engine_trade_accessors.cpp. +// Invoke the generated chart strategy body under its own EMA warmup mode. +// The selector is thread-local because multiple engines can run concurrently; +// restoring the previous value (also during stack unwinding) prevents both +// cross-engine contamination and leakage between chart and request.security +// evaluation. The latter installs its own scope around every security +// evaluator dispatch and restores the prior thread-local value on return. +void BacktestEngine::invoke_chart_on_bar(const Bar& bar) { + struct ChartEmaNaWarmupScope { + bool previous; + explicit ChartEmaNaWarmupScope(bool enabled) + : previous(ta::ema_na_warmup_flag()) { + ta::ema_na_warmup_flag() = enabled; + } + ~ChartEmaNaWarmupScope() { + ta::ema_na_warmup_flag() = previous; + } + } scope(chart_ema_na_warmup_); + + on_bar(bar); +} + // Standard per-script-bar dispatch sequence, shared by the simple run() loop, // run_simple_bar_loop, and the no-magnifier aggregation path. Operates on // current_bar_ (already set by the caller). @@ -41,13 +64,13 @@ void BacktestEngine::dispatch_bar() { if (process_orders_on_close_) { process_pending_orders(current_bar_); // step 1: old stop/limit update_per_trade_extremes(); // step 2: update before strategy reads - on_bar(current_bar_); // step 3: strategy logic + invoke_chart_on_bar(current_bar_); // step 3: strategy logic flush_same_bar_close(); // step 3b: surviving strategy.close fill process_pending_orders(current_bar_); // step 4: new market orders } else { process_pending_orders(current_bar_); update_per_trade_extremes(); - on_bar(current_bar_); + invoke_chart_on_bar(current_bar_); } // TradingView forced-liquidation check, once per script bar after all order // processing, using this bar's full adverse extreme (high/low). @@ -144,7 +167,7 @@ uint64_t BacktestEngine::execute_coof_script_body( coof_cursor_price_ = broker_cursor_price; coof_direct_fill_events_remaining_ = direct_fill_event_budget; const uint64_t before = broker_fill_event_seq_; - on_bar(current_bar_); + invoke_chart_on_bar(current_bar_); if (process_orders_on_close_) { // A same-bar close batch is a broker fill at the current monotonic // cursor. At the ordinary close execution that cursor is C; during a @@ -704,7 +727,7 @@ void BacktestEngine::run_magnified_bar(const std::vector& sub_bars, int64_t // magnifier: the single sub-bar IS the script bar). current_bar_.timestamp = script_bar_ts; _push_source_series(); - on_bar(current_bar_); + invoke_chart_on_bar(current_bar_); flush_same_bar_close(); // surviving strategy.close fill process_pending_orders(current_bar_); } @@ -718,7 +741,7 @@ void BacktestEngine::run_magnified_bar(const std::vector& sub_bars, int64_t // not the final sub-bar ts. current_bar_.timestamp = script_bar_ts; _push_source_series(); - on_bar(current_bar_); + invoke_chart_on_bar(current_bar_); } } } diff --git a/src/engine_stream.cpp b/src/engine_stream.cpp index 5e9bc09..bac820b 100644 --- a/src/engine_stream.cpp +++ b/src/engine_stream.cpp @@ -350,7 +350,7 @@ void BacktestEngine::stream_dispatch_script_bar(const Bar& bar, bool had_tick) { } _push_source_series(); - on_bar(current_bar_); + invoke_chart_on_bar(current_bar_); if (process_orders_on_close_) { flush_same_bar_close(); // New close-time orders only get the closing price point. Re-walking diff --git a/src/ta_moving_averages.cpp b/src/ta_moving_averages.cpp index dbb5920..3d3a9a8 100644 --- a/src/ta_moving_averages.cpp +++ b/src/ta_moving_averages.cpp @@ -20,9 +20,9 @@ namespace pineforge { namespace ta { // Thread-local so parallel in-process engines never cross-contaminate. Default -// false → src-seed EMA (byte-identical to prior behavior); the engine raises it -// only around request.security evaluation under the security_range_start_na_warmup -// run flag. See the declaration in for the full rationale. +// false → src-seed EMA (byte-identical to prior behavior); the engine scopes it +// independently around chart on_bar and request.security evaluation under +// their respective opt-in flags. See for the full rationale. bool& ema_na_warmup_flag() { static thread_local bool flag = false; return flag; @@ -142,12 +142,10 @@ EMA::EMA(int length) double EMA::compute(double src) { save(); - // Latch the warmup mode once, on the first compute(). A security-embedded - // EMA's first compute() always runs inside the engine's request.security - // evaluation (where the flag is raised under security_range_start_na_warmup), - // and a chart EMA's never does — so the latch is consistent for the - // instance's whole life without threading any per-instance wiring through - // codegen. + // Latch the warmup mode once, on the first compute(). Chart and + // security-embedded EMAs first compute inside their respective engine + // dispatch scopes, so the latch is consistent for the instance's whole + // life without threading per-instance wiring through codegen. if (!warmup_latched_) { na_warmup_ = ema_na_warmup_flag(); warmup_latched_ = true; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f36d2a6..cbe3f7c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,7 @@ set(TEST_SOURCES test_request_security test_historical_security_lookahead_projection test_security_range_start_na_warmup + test_chart_ema_na_warmup test_security_tf_validation test_security_lower_tf_input_passthrough test_ltf_buffer_no_leak diff --git a/tests/test_chart_ema_na_warmup.cpp b/tests/test_chart_ema_na_warmup.cpp new file mode 100644 index 0000000..7c5af55 --- /dev/null +++ b/tests/test_chart_ema_na_warmup.cpp @@ -0,0 +1,297 @@ +// test_chart_ema_na_warmup — pins the opt-in KI-55 chart-EMA warmup flag. +// +// ``chart_ema_na_warmup`` is an independent, default-off run flag carried +// through the syminfo-metadata channel. While chart strategy code executes, +// it makes newly used ta::EMA instances latch TradingView's built-in warmup +// shape (na for length-1 values, then an SMA seed). request.security keeps +// its own ``security_range_start_na_warmup`` scope and must not inherit this +// chart choice. + +// This fixture covers every engine-owned chart on_bar dispatch path: normal, +// calc_on_order_fills (ordinary + fill recalc), magnifier, and streaming. It +// also proves that the thread-local selector is restored when on_bar throws. + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace pineforge; + +namespace { + +int failures = 0; + +#define CHECK(cond, tag) do { \ + if (!(cond)) { \ + std::printf("FAIL: %s (line %d)\n", (tag), __LINE__); \ + ++failures; \ + } \ +} while (0) + +bool exact_or_both_na(double lhs, double rhs) { + if (is_na(lhs) && is_na(rhs)) return true; + if (is_na(lhs) || is_na(rhs)) return false; + return lhs == rhs; +} + +std::vector flat_bars(int count, int64_t step_ms = 60'000) { + std::vector bars; + bars.reserve(static_cast(count)); + for (int i = 0; i < count; ++i) { + const double price = 10.0 * static_cast(i + 1); + bars.push_back(Bar{price, price, price, price, 1.0, + static_cast(i + 1) * step_ms}); + } + return bars; +} + +class EmaValueHarness final : public BacktestEngine { +public: + ta::EMA ema{3}; + std::vector flags; + std::vector values; + + void on_bar(const Bar& bar) override { + flags.push_back(ta::ema_na_warmup_flag()); + values.push_back(ema.compute(bar.close)); + } +}; + +void test_default_off_on_and_disable_zero() { + const auto bars = flat_bars(3); + + ta::ema_na_warmup_flag() = false; + EmaValueHarness off; + off.run(bars.data(), static_cast(bars.size())); + const double expected_off[] = {10.0, 15.0, 22.5}; + CHECK(off.flags.size() == 3, "default-off: one chart dispatch per bar"); + CHECK(std::all_of(off.flags.begin(), off.flags.end(), + [](bool value) { return !value; }), + "default-off: chart scope exposes false"); + for (std::size_t i = 0; i < off.values.size() && i < 3; ++i) { + CHECK(exact_or_both_na(off.values[i], expected_off[i]), + "default-off: EMA keeps src-seed recursion"); + } + CHECK(!ta::ema_na_warmup_flag(), + "default-off: chart dispatch restores ambient false"); + + EmaValueHarness on; + on.set_syminfo_metadata("chart_ema_na_warmup", 1.0); + on.run(bars.data(), static_cast(bars.size())); + const double expected_on[] = {na(), na(), 20.0}; + CHECK(on.flags.size() == 3, "flag-on: one chart dispatch per bar"); + CHECK(std::all_of(on.flags.begin(), on.flags.end(), + [](bool value) { return value; }), + "flag-on: chart scope exposes true"); + for (std::size_t i = 0; i < on.values.size() && i < 3; ++i) { + CHECK(exact_or_both_na(on.values[i], expected_on[i]), + "flag-on: EMA na-warms then SMA-seeds"); + } + CHECK(!ta::ema_na_warmup_flag(), + "flag-on: chart dispatch restores ambient false"); + + EmaValueHarness disabled; + disabled.set_syminfo_metadata("chart_ema_na_warmup", 1.0); + disabled.set_syminfo_metadata("chart_ema_na_warmup", 0.0); + disabled.run(bars.data(), static_cast(bars.size())); + CHECK(std::all_of(disabled.flags.begin(), disabled.flags.end(), + [](bool value) { return !value; }), + "disable=0: later metadata value turns chart warmup off"); + CHECK(disabled.values.size() == 3 + && exact_or_both_na(disabled.values.front(), 10.0), + "disable=0: EMA returns to src-seed behavior"); +} + +class DispatchHarness final : public BacktestEngine { +public: + std::vector flags; + std::vector realtime_flags; + bool placed = false; + + explicit DispatchHarness(bool coof = false) { + calc_on_order_fills_ = coof; + } + + void on_bar(const Bar&) override { + const bool flag = ta::ema_na_warmup_flag(); + flags.push_back(flag); + if (barstate_islast_) realtime_flags.push_back(flag); + if (calc_on_order_fills_ && bar_index_ == 0 && !placed) { + placed = true; + strategy_entry("L", true); + } + } +}; + +void test_coof_dispatches_are_scoped() { + ta::ema_na_warmup_flag() = false; + DispatchHarness strat(/*coof=*/true); + strat.set_syminfo_metadata("chart_ema_na_warmup", 1.0); + const auto bars = flat_bars(3); + strat.run(bars.data(), static_cast(bars.size())); + + CHECK(strat.last_error().empty(), "COOF: run succeeds"); + CHECK(strat.flags.size() > bars.size(), + "COOF: fixture exercised at least one fill recalculation"); + CHECK(std::all_of(strat.flags.begin(), strat.flags.end(), + [](bool value) { return value; }), + "COOF: ordinary and fill-recalc chart dispatches expose true"); + CHECK(!ta::ema_na_warmup_flag(), "COOF: ambient flag restored"); +} + +void test_magnifier_dispatch_is_scoped() { + ta::ema_na_warmup_flag() = false; + DispatchHarness strat; + strat.set_syminfo_metadata("chart_ema_na_warmup", 1.0); + const auto bars = flat_bars(4); + strat.run(bars.data(), static_cast(bars.size()), + "1", "2", /*bar_magnifier=*/true, 4, + MagnifierDistribution::ENDPOINTS); + + CHECK(strat.last_error().empty(), "magnifier: run succeeds"); + CHECK(strat.flags.size() == 2, + "magnifier: one chart dispatch per completed 2m bar"); + CHECK(std::all_of(strat.flags.begin(), strat.flags.end(), + [](bool value) { return value; }), + "magnifier: chart dispatch exposes true"); + CHECK(!ta::ema_na_warmup_flag(), "magnifier: ambient flag restored"); +} + +void test_streaming_dispatch_is_scoped() { + ta::ema_na_warmup_flag() = false; + DispatchHarness strat; + strat.set_syminfo_metadata("chart_ema_na_warmup", 1.0); + const auto warmup = flat_bars(2); + CHECK(strat.stream_begin(warmup.data(), static_cast(warmup.size()), + "1", "1"), + "streaming: warmup begins"); + CHECK(strat.stream_push_tick(TradeTick{180'010, 1, 35.0, 1.0}), + "streaming: realtime tick accepted"); + CHECK(strat.stream_advance_time(240'000), + "streaming: realtime chart bar finalized"); + + CHECK(strat.flags.size() >= 3, + "streaming: warmup and realtime chart dispatches both ran"); + CHECK(std::all_of(strat.flags.begin(), strat.flags.end(), + [](bool value) { return value; }), + "streaming: every chart dispatch exposes true"); + CHECK(!strat.realtime_flags.empty() + && std::all_of(strat.realtime_flags.begin(), + strat.realtime_flags.end(), + [](bool value) { return value; }), + "streaming: direct realtime dispatch exposes true"); + CHECK(!ta::ema_na_warmup_flag(), "streaming: ambient flag restored"); + CHECK(strat.stream_end(false), "streaming: stream ends cleanly"); +} + +class IndependenceHarness final : public BacktestEngine { +public: + std::vector chart_flags; + std::vector security_flags; + + IndependenceHarness() { + register_security_eval(0, "1", "1", /*lookahead_on=*/false, + /*gaps_on=*/false); + } + + void evaluate_security(int sec_id, const Bar&, bool) override { + if (sec_id == 0) { + security_flags.push_back(ta::ema_na_warmup_flag()); + } + } + + void on_bar(const Bar&) override { + chart_flags.push_back(ta::ema_na_warmup_flag()); + } +}; + +void test_chart_and_security_flags_are_independent() { + const auto bars = flat_bars(4); + ta::ema_na_warmup_flag() = false; + + IndependenceHarness chart_only; + chart_only.set_syminfo_metadata("chart_ema_na_warmup", 1.0); + chart_only.run(bars.data(), static_cast(bars.size()), "1", "1"); + CHECK(!chart_only.chart_flags.empty() + && std::all_of(chart_only.chart_flags.begin(), + chart_only.chart_flags.end(), + [](bool value) { return value; }), + "independence: chart flag on inside on_bar"); + CHECK(!chart_only.security_flags.empty() + && std::all_of(chart_only.security_flags.begin(), + chart_only.security_flags.end(), + [](bool value) { return !value; }), + "independence: chart flag does not leak into security evaluator"); + + IndependenceHarness security_only; + security_only.set_syminfo_metadata("security_range_start_na_warmup", 1.0); + security_only.run(bars.data(), static_cast(bars.size()), "1", "1"); + CHECK(!security_only.chart_flags.empty() + && std::all_of(security_only.chart_flags.begin(), + security_only.chart_flags.end(), + [](bool value) { return !value; }), + "independence: security flag does not leak into chart on_bar"); + CHECK(!security_only.security_flags.empty() + && std::all_of(security_only.security_flags.begin(), + security_only.security_flags.end(), + [](bool value) { return value; }), + "independence: existing security evaluator scope remains on"); + CHECK(!ta::ema_na_warmup_flag(), "independence: ambient flag restored"); +} + +class ThrowingHarness final : public BacktestEngine { +public: + bool observed = false; + + void on_bar(const Bar&) override { + observed = ta::ema_na_warmup_flag(); + throw std::runtime_error("chart warmup restoration probe"); + } +}; + +void test_thread_local_restored_after_exception() { + const auto bars = flat_bars(1); + + ta::ema_na_warmup_flag() = false; + ThrowingHarness enabled; + enabled.set_syminfo_metadata("chart_ema_na_warmup", 1.0); + enabled.run(bars.data(), static_cast(bars.size())); + CHECK(enabled.observed, "exception: enabled chart body observes true"); + CHECK(!enabled.last_error().empty(), "exception: run records thrown error"); + CHECK(!ta::ema_na_warmup_flag(), + "exception: enabled scope restores ambient false"); + + ta::ema_na_warmup_flag() = true; + ThrowingHarness disabled; + disabled.run(bars.data(), static_cast(bars.size())); + CHECK(!disabled.observed, + "exception: disabled chart scope masks ambient true inside on_bar"); + CHECK(ta::ema_na_warmup_flag(), + "exception: disabled scope restores ambient true"); + ta::ema_na_warmup_flag() = false; +} + +} // namespace + +int main() { + test_default_off_on_and_disable_zero(); + test_coof_dispatches_are_scoped(); + test_magnifier_dispatch_is_scoped(); + test_streaming_dispatch_is_scoped(); + test_chart_and_security_flags_are_independent(); + test_thread_local_restored_after_exception(); + + if (failures != 0) { + std::printf("%d check(s) FAILED\n", failures); + return 1; + } + std::printf("test_chart_ema_na_warmup passed.\n"); + return 0; +} From 2b47fcc302abfd60d93801c242ab64e5ff58f771 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 10:06:56 +0800 Subject: [PATCH 08/16] fix: preserve under-cap POOC entries before close_all (cherry picked from commit 0466c948d10323771b902d5d5ee483e5800c6b92) --- include/pineforge/engine.hpp | 6 +- src/engine_strategy_commands.cpp | 33 ++++-- tests/test_pooc_position_visibility.cpp | 130 ++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 12 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index c2b7545..e26eda5 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -2156,7 +2156,8 @@ class BacktestEngine { double& qty_to_close_out, bool& all_entries_match_out); void cancel_orders_for_full_close(const std::string& id, bool closing_long); - void cancel_same_bar_market_reentries_after_full_close(bool closed_long); + void cancel_same_bar_market_reentries_after_full_close( + bool closed_long, bool preserve_undercap_entries); // Same-bar close batching (TV one-fill-per-bar; see the field-block // comment at close_reserved_qty_). enqueue replaces the pending // same-bar close; flush executes the surviving one at bar close. @@ -2170,7 +2171,8 @@ class BacktestEngine { double matching_qty, bool closes_full_position, bool closes_fifo_qty, - bool closes_any_qty); + bool closes_any_qty, + bool preserve_undercap_entries); uint64_t queue_deferred_close_order( const std::string& id, const std::string& comment, diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 05558a8..39b437e 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -495,8 +495,12 @@ void BacktestEngine::strategy_close(const std::string& id, const std::string& co if (process_orders_on_close_ && !immediately) { freeze_script_position_view(); } + const bool ordinary_pooc_close_all = + pooc_can_fill_at_this_cursor && !immediately && id.empty() + && !coof_scheduler_active_; execute_immediate_close(id, comment, qty_to_close, matching_qty, - closes_full_position, closes_fifo_qty, closes_any_qty); + closes_full_position, closes_fifo_qty, closes_any_qty, + ordinary_pooc_close_all); return; } @@ -724,7 +728,8 @@ void BacktestEngine::flush_same_bar_close() { // must survive exactly as they did under the immediate path. execute_market_exit(broker_price); if (position_side_ == PositionSide::FLAT) { - cancel_same_bar_market_reentries_after_full_close(closed_long); + cancel_same_bar_market_reentries_after_full_close( + closed_long, /*preserve_undercap_entries=*/false); } } else { execute_partial_exit_qty(broker_price, target); @@ -1264,7 +1269,8 @@ void BacktestEngine::cancel_orders_for_full_close(const std::string& id, bool /* pending_orders_.end()); } -void BacktestEngine::cancel_same_bar_market_reentries_after_full_close(bool closed_long) { +void BacktestEngine::cancel_same_bar_market_reentries_after_full_close( + bool closed_long, bool preserve_undercap_entries) { const PositionSide closed_side = closed_long ? PositionSide::LONG : PositionSide::SHORT; pending_orders_.erase( std::remove_if( @@ -1274,14 +1280,19 @@ void BacktestEngine::cancel_same_bar_market_reentries_after_full_close(bool clos // Deferred full exits already remove same-direction market // entries through process_pending_orders' exit_closed_from_bar // machinery. POOC/immediate closes execute outside that loop, - // so mirror only the market-reentry cleanup here. Priced - // entries intentionally survive (covered by - // test_strategy_close_pooc_keeps_same_bar_pending_entry), and - // opposite-direction market entries remain valid reversals. + // so mirror only the market-reentry cleanup here. An ordinary + // POOC close_all preserves an entry created before it when the + // entry was under the pyramiding cap at placement; + // over-cap entries still drop. Explicit immediately=true and + // flush-time strategy.close(id) keep blanket cancellation. + // Priced entries intentionally survive, and opposite-direction + // market entries remain valid reversals. return o.type == OrderType::MARKET && o.created_bar == bar_index_ && o.created_position_side == closed_side - && o.is_long == closed_long; + && o.is_long == closed_long + && (!preserve_undercap_entries + || o.over_pyramiding_cap_at_placement); }), pending_orders_.end()); } @@ -1296,7 +1307,8 @@ void BacktestEngine::execute_immediate_close(const std::string& id, double matching_qty, bool closes_full_position, bool closes_fifo_qty, - bool closes_any_qty) { + bool closes_any_qty, + bool preserve_undercap_entries) { const double eps = kQtyEpsilon; size_t trades_before = trades_.size(); PositionSide side_before = position_side_; @@ -1309,7 +1321,8 @@ void BacktestEngine::execute_immediate_close(const std::string& id, execute_market_exit(broker_price); purge_exit_orders(); if (position_side_ == PositionSide::FLAT) { - cancel_same_bar_market_reentries_after_full_close(closed_long); + cancel_same_bar_market_reentries_after_full_close( + closed_long, preserve_undercap_entries); } } else if (closes_fifo_qty) { execute_partial_exit_qty(broker_price, qty_to_close); diff --git a/tests/test_pooc_position_visibility.cpp b/tests/test_pooc_position_visibility.cpp index 0051ce1..168af65 100644 --- a/tests/test_pooc_position_visibility.cpp +++ b/tests/test_pooc_position_visibility.cpp @@ -174,6 +174,128 @@ static void test_G2_pooc_entry_before_close_still_fires() { CHECK(s.add_placed); // the != 0 gate saw the real LONG before the close } +// R3/R4 — ordinary POOC close_all preserves a same-direction MARKET entry that +// was created BEFORE the close while still under the pyramiding cap. The close +// fills at C, then the surviving entry opens a fresh position at that same C. +// Anchored by a production long-side oracle and characterized symmetrically. +static void test_pooc_undercap_entry_before_closeall_survives(bool held_long) { + std::printf("R3/R4: POOC under-cap %s entry before close_all survives\n", + held_long ? "long" : "short"); + class Strat : public BacktestEngine { + public: + explicit Strat(bool held_long) : held_long_(held_long) { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 2; + process_orders_on_close_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("BASE", held_long_); + const double pos = signed_position_size(); + const bool holding = held_long_ ? pos > 0.0 : pos < 0.0; + if (bar_index_ == 1 && holding) { + strategy_entry("ADD", held_long_); // UNDER cap, before close + strategy_close_all(); + } + } + double ssize() const { return signed_position_size(); } + std::string entry_id() const { return open_trade_entry_id(0); } + private: + bool held_long_; + } s(held_long); + + s.run(bars4, 4); + CHECK(s.trade_count() == 1); // BASE closed by close_all + CHECK(near(s.ssize(), held_long ? 1.0 : -1.0)); // ADD survived and reopened + CHECK(s.entry_id() == "ADD"); +} + +// Control — the same source order at the pyramiding cap remains rejected. The +// close must flatten BASE without allowing the over-cap-at-placement ADD to reopen. +static void test_pooc_overcap_entry_before_closeall_drops(bool held_long) { + std::printf("control: POOC over-cap %s entry before close_all drops\n", + held_long ? "long" : "short"); + class Strat : public BacktestEngine { + public: + explicit Strat(bool held_long) : held_long_(held_long) { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + process_orders_on_close_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("BASE", held_long_); + const double pos = signed_position_size(); + const bool holding = held_long_ ? pos > 0.0 : pos < 0.0; + if (bar_index_ == 1 && holding) { + strategy_entry("ADD", held_long_); // OVER cap, before close + strategy_close_all(); + } + } + double ssize() const { return signed_position_size(); } + private: + bool held_long_; + } s(held_long); + + s.run(bars4, 4); + CHECK(s.trade_count() == 1); + CHECK(near(s.ssize(), 0.0)); +} + +// COOF control — the production oracle has calc_on_order_fills=false, so the +// ordinary-POOC carve-out above must not leak into the COOF scheduler. Keep the +// established COOF full-close cleanup: an under-cap same-direction MARKET add +// created before close_all at the ordinary C execution is cancelled. Direction +// symmetry guards both LONG and SHORT cleanup predicates. +static void test_coof_pooc_undercap_entry_before_closeall_still_cancels( + bool held_long) { + std::printf("control: COOF+POOC under-cap %s entry before close_all cancels\n", + held_long ? "long" : "short"); + class Strat : public BacktestEngine { + public: + explicit Strat(bool held_long) : held_long_(held_long) { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 2; + process_orders_on_close_ = true; + calc_on_order_fills_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0 && !base_placed_) { + base_placed_ = true; + strategy_entry("BASE", held_long_); + } + const double pos = signed_position_size(); + const bool holding = held_long_ ? pos > 0.0 : pos < 0.0; + if (bar_index_ == 1 && holding && !close_cluster_placed_) { + close_cluster_placed_ = true; + strategy_entry("ADD", held_long_); // UNDER cap, before close + strategy_close_all(); + } + } + double ssize() const { return signed_position_size(); } + bool close_cluster_placed() const { return close_cluster_placed_; } + private: + bool held_long_; + bool base_placed_ = false; + bool close_cluster_placed_ = false; + } s(held_long); + + s.run(bars4, 4); + CHECK(s.close_cluster_placed()); + CHECK(s.trade_count() == 1); + CHECK(near(s.ssize(), 0.0)); +} + // G3 — strategy.close(immediately=true) is DEFINED to reflect its fill at once, // so it must NOT be deferred: the mid-bar read is 0 and the prior same-dir // market re-entry is cancelled (test_integration :3896 shape). pyramiding=2. @@ -286,6 +408,14 @@ int main() { test_R2_pooc_closeid_flat_gate_blocks_reentry(); test_G1_non_pooc_unchanged(); test_G2_pooc_entry_before_close_still_fires(); + test_pooc_undercap_entry_before_closeall_survives(/*held_long=*/true); + test_pooc_undercap_entry_before_closeall_survives(/*held_long=*/false); + test_pooc_overcap_entry_before_closeall_drops(/*held_long=*/true); + test_pooc_overcap_entry_before_closeall_drops(/*held_long=*/false); + test_coof_pooc_undercap_entry_before_closeall_still_cancels( + /*held_long=*/true); + test_coof_pooc_undercap_entry_before_closeall_still_cancels( + /*held_long=*/false); test_G3_pooc_immediately_not_deferred(); test_G5a_pooc_pure_reversal_flips(); test_G5b_pooc_closeall_then_opposite_entry_flips(); From 4138b2b565337a2e6489bc349ccdb72130448701 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 18:12:27 +0800 Subject: [PATCH 09/16] fix: fully liquidate zero-cover margin calls (cherry picked from commit b56c6f1e959bd9cd270884509451decf08fafc08) --- src/engine_fills.cpp | 18 ++-- tests/test_margin_call.cpp | 197 +++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 7 deletions(-) diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 7196acd..a95df42 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -563,7 +563,11 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // qty_step_ == 0 (corpus default; the explicit-leverage p2/5x margin probes // never set it) leaves both q_min and qty_liq untouched -> byte-identical. if (qty_step_ > 0.0) { - q_min = std::floor(q_min / qty_step_) * qty_step_; + // The quotient can land microscopically below an exact integer because + // of binary representation (for example, one mathematical lot can be + // 0.99999999998 lots here). Use the same 1e-6-of-step guard as the + // downstream 4x quantizer so an exact grid value is not erased. + q_min = std::floor(q_min / qty_step_ + 1e-6) * qty_step_; } // A sub-lot 1x-long opening shortfall is untradeable dust. It is a no-op, // not a reason to force the generic finite-price cascade's one-step @@ -581,12 +585,12 @@ void BacktestEngine::process_margin_call(const Bar& bar) { double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_; if (floored <= kQtyEpsilon) { if (opening_affordability) return; - // A liquidation IS required (we passed the margin-shortfall gate) - // but the floored lot rounds to zero (sub-lot shortfall). Take the - // smallest step that still makes progress — one qty_step_, or the - // full residual if it is smaller — so the per-bar call loop cannot - // stall forever. - floored = std::min(qty_step_, qty); + // A finite-price liquidation IS required, but the documented + // minimum-restore quantity truncates to zero at the instrument lot + // precision. TradingView closes the whole residual in this zero- + // cover case rather than inventing a one-step nibble. Opening- + // affordability dust remains the explicit no-op above. + floored = qty; } qty_liq = floored; } diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index d3dcad2..a683832 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -243,6 +243,197 @@ static void test_short_margin_call_qty_step() { CHECK(!is_multiple_of(raw.trade_size(0), step)); } +// A $100-scale all-in short can breach margin by less than one quantity step. +// TV still emits a Margin-call trade, but its truncated cover amount is zero; +// the broker closes the full residual instead of fabricating a one-step nibble. +class ShortZeroCoverProbe : public MCEngine { +public: + explicit ShortZeroCoverProbe(double qty_step) { + initial_capital_ = 100.384250; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = qty_step; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("S", false, kNaN, kNaN, kNaN); + } + } +}; + +static void test_short_margin_call_zero_cover_closes_full_residual() { + std::printf("test_short_margin_call_zero_cover_closes_full_residual\n"); + std::vector bars = { + // Signal close freezes floor(100.38425 / 3788 / 0.0001) = 0.0265. + mk_bar(1000, 3788.00, 3788.00, 3788.00, 3788.00, 1.0), + // At HIGH: equity=100.37153, required=100.39472, so + // q_min=0.000006121... < qty_step and the truncated cover is zero. + mk_bar(2000, 3788.00, 3788.48, 3766.62, 3775.78, 1.0), + }; + + ShortZeroCoverProbe eng(/*qty_step=*/0.0001); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.entry_price(0), 3788.00)); + CHECK(near(eng.exit_price(0), 3788.48)); + CHECK(near(eng.trade_size(0), 0.0265)); + CHECK(near(eng.position_size(), 0.0)); +} + +static void test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble() { + std::printf("test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble\n"); + constexpr double step = 0.0001; + // For a 10@100 all-in short, q_min = 20 - 2000/adverse. This adverse is + // mathematically exactly one step, but the engine's equivalent arithmetic + // represents the quotient just below 1. A bare floor would erase the lot + // and incorrectly enter the zero-cover full-close fallback. + const double adverse = 2000.0 / (20.0 - step); + const double equity_at_high = 1000.0 - (adverse - 100.0) * 10.0; + const double q_min = 10.0 - equity_at_high / adverse; + CHECK(q_min < step); + CHECK(q_min / step + 1e-6 >= 1.0); + + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 100.0, adverse, 99.0, 100.0, 1.0), + }; + + ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/step); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.exit_price(0), 100.01, 1e-12)); + CHECK(near(eng.trade_size(0), 4.0 * step, 1e-12)); + CHECK(near(eng.position_size(), -(10.0 - 4.0 * step), 1e-12)); +} + +static void test_short_margin_call_just_below_step_still_zero_covers() { + std::printf("test_short_margin_call_just_below_step_still_zero_covers\n"); + constexpr double step = 0.0001; + constexpr double below_guard_ratio = 1.0 - 2e-6; + // This is genuinely below one step by more than the 1e-6 representation + // guard. It must still quantize to zero and close the full residual. + const double adverse = 2000.0 / (20.0 - step * below_guard_ratio); + const double equity_at_high = 1000.0 - (adverse - 100.0) * 10.0; + const double q_min = 10.0 - equity_at_high / adverse; + CHECK(q_min < step); + CHECK(q_min / step + 1e-6 < 1.0); + + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 100.0, adverse, 99.0, 100.0, 1.0), + }; + + ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/step); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.exit_price(0), 100.01, 1e-12)); + CHECK(near(eng.trade_size(0), 10.0, 1e-12)); + CHECK(near(eng.position_size(), 0.0, 1e-12)); +} + +static void test_short_margin_call_zero_cover_without_qty_step_stays_continuous() { + std::printf("test_short_margin_call_zero_cover_without_qty_step_stays_continuous\n"); + std::vector bars = { + mk_bar(1000, 3788.00, 3788.00, 3788.00, 3788.00, 1.0), + mk_bar(2000, 3788.00, 3788.48, 3766.62, 3775.78, 1.0), + }; + + ShortZeroCoverProbe eng(/*qty_step=*/0.0); + eng.run(bars.data(), (int)bars.size()); + + const double opened_qty = 100.384250 / 3788.00; + const double equity_at_high = 100.384250 + - (3788.48 - 3788.00) * opened_qty; + const double q_min = opened_qty - equity_at_high / 3788.48; + const double expected_liquidation = 4.0 * q_min; + + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.trade_size(0), expected_liquidation, 1e-9)); + CHECK(eng.trade_size(0) < opened_qty); + CHECK(near(eng.position_size(), -(opened_qty - expected_liquidation), 1e-9)); +} + +static void test_short_margin_call_nonzero_cover_keeps_four_x_nibble() { + std::printf("test_short_margin_call_nonzero_cover_keeps_four_x_nibble\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + // q_min=0.15085... = 1.50 qty steps. Floor-before-4x must + // therefore close 0.4, not the full 10-contract position. + mk_bar(2000, 100.0, 100.76, 99.0, 100.0, 1.0), + }; + + ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/0.1); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.exit_price(0), 100.76)); + CHECK(near(eng.trade_size(0), 0.4)); + CHECK(near(eng.position_size(), -9.6)); +} + +// Opening-affordability uses a separate, one-shot budget check. Its sub-lot +// shortfall is intentionally untradeable dust and must remain a no-op when the +// finite-price zero-cover fallback changes. +class ShortOpeningDustProbe : public MCEngine { +public: + bool saw_actionable_opening_event = false; + + ShortOpeningDustProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.015; + margin_short_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("S", false, kNaN, kNaN, /*qty=*/10.0); + } else if (bar_index_ == 1) { + // The next-open fill precedes this callback. Prove this fixture + // actually reaches the opening-affordability branch before its + // one-shot event is consumed at bar end. + saw_actionable_opening_event = opening_affordability_pending_ + && opening_affordability_eligible_ + && std::isfinite(opening_affordability_raw_fill_base_); + } + } +}; + +static void test_short_opening_affordability_zero_cover_remains_dust_noop() { + std::printf("test_short_opening_affordability_zero_cover_remains_dust_noop\n"); + std::vector bars = { + mk_bar(1000, 99.99, 99.99, 99.99, 99.99, 1.0), + // Required margin is 999.90. The 0.015% opening fee leaves equity + // 999.850015, so q_min=0.0004999... < the 1-contract step. + mk_bar(2000, 99.99, 99.99, 99.99, 99.99, 1.0), + }; + + ShortOpeningDustProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.saw_actionable_opening_event); + CHECK(eng.trade_count() == 0); + CHECK(near(eng.position_size(), -10.0)); + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); +} + static void test_short_margin_call_account_fx() { std::printf("test_short_margin_call_account_fx\n"); constexpr double account_fx = 2.0; @@ -1533,6 +1724,12 @@ static void test_long_leveraged_margin_call() { int main() { test_short_margin_call(); test_short_margin_call_qty_step(); + test_short_margin_call_zero_cover_closes_full_residual(); + test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble(); + test_short_margin_call_just_below_step_still_zero_covers(); + test_short_margin_call_zero_cover_without_qty_step_stays_continuous(); + test_short_margin_call_nonzero_cover_keeps_four_x_nibble(); + test_short_opening_affordability_zero_cover_remains_dust_noop(); test_short_margin_call_account_fx(); test_margin_liquidation_price_formula(); test_short_margin_call_disabled(); From c2c8f3a9a20935ccee1ae871d0204f0df95d824d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 18:28:08 +0800 Subject: [PATCH 10/16] fix: cover POOC pyramid adds with global exits (cherry picked from commit 27b7c2e98913d633750e1b596faf86eab1eb8ac7) --- include/pineforge/engine.hpp | 9 + src/engine_fills.cpp | 17 +- src/engine_strategy_commands.cpp | 88 ++++- tests/CMakeLists.txt | 1 + tests/test_pooc_global_full_exit.cpp | 485 +++++++++++++++++++++++++++ 5 files changed, 591 insertions(+), 9 deletions(-) create mode 100644 tests/test_pooc_global_full_exit.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index e26eda5..554e8d9 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -444,6 +444,14 @@ struct PendingOrder { std::numeric_limits::quiet_NaN(); std::string comment; // order comment for trade reporting bool requested_partial = false; // true iff caller passed qty_percent < 100 + // Narrow POOC global-full-exit candidate. ``qty`` deliberately keeps the + // normal finite reservation so sibling exits see and respect its capacity. + // At fill time this bit upgrades that one reservation to the full live + // position, covering same-bar MARKET pyramid adds that were already + // pending when the exit was placed. Any later admitted entry-like order + // clears the bit, making the finite qty the automatic conservative + // fallback—even when that later order is placed on a future bar. + bool pooc_global_full_exit_dynamic_qty = false; bool created_while_in_position = false; // true if position was open when order was placed // design-declined-reversal-close-leg: set at the KI-54 percent-of-equity // reversal-decline site when this pending FULL close was co-queued AFTER, @@ -2193,6 +2201,7 @@ class BacktestEngine { double& qp_io, bool& is_partial_io, double& reserved_qty_out); + void invalidate_unsafe_pooc_global_full_exit_dynamic_qty(); // execute_market_entry / execute_partial_exit_* helpers (defined in // engine_orders.cpp). diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index a95df42..5eae4bd 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -2346,11 +2346,16 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long) { double qp = std::isnan(order.qty_percent) ? 100.0 : std::clamp(order.qty_percent, 0.0, 100.0); - bool has_explicit_qty_to_close = !std::isnan(order.qty); + const bool dynamic_full_live_qty = + order.pooc_global_full_exit_dynamic_qty; + bool has_explicit_qty_to_close = + !dynamic_full_live_qty && !std::isnan(order.qty); double qty_before_exit = position_qty_; - bool is_partial = has_explicit_qty_to_close - ? order.qty < qty_before_exit - kFullQtyEps - : qp < 100.0 - kFullPercentEps; + bool is_partial = dynamic_full_live_qty + ? false + : (has_explicit_qty_to_close + ? order.qty < qty_before_exit - kFullQtyEps + : qp < 100.0 - kFullPercentEps); size_t trades_before_exit = trades_.size(); PositionSide side_before_exit = position_side_; @@ -2362,7 +2367,9 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric execute_partial_exit_by_entry(fill_price, order.from_entry); } } else { - if (has_explicit_qty_to_close) { + if (dynamic_full_live_qty) { + execute_market_exit(fill_price); + } else if (has_explicit_qty_to_close) { execute_partial_exit_qty(fill_price, order.qty); } else if (is_partial) { execute_partial_exit(fill_price, qp); diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 39b437e..fb36650 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -421,6 +421,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, } pending_orders_.push_back(std::move(order)); + invalidate_unsafe_pooc_global_full_exit_dynamic_qty(); } void BacktestEngine::strategy_close(const std::string& id, const std::string& comment, @@ -825,6 +826,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro preserved_seq, preserved_reserved_qty); double reserved_qty = std::numeric_limits::quiet_NaN(); + bool bind_global_full_exit_dynamic_qty = false; if (has_explicit_qty) { // Honour the explicit qty literally (clamped to the live position // and subject to the same already-reserved accounting). This is @@ -923,11 +925,72 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro break; // entry ids are unique in pending_orders_ } } - if (!bind_to_pending_reversal_entry - && !compute_exit_reserved_qty(from_entry, preserved_reserved_qty, - live_pos_qty, qp, is_partial, reserved_qty)) { - return; + + // POOC global-full-exit reservation: when the complete pending + // entry-like queue consists only of ordinary same-direction high-level + // MARKET adds created on this bar, each under the pyramiding cap, those + // adds fill at C before this later-created priced exit can trigger. A + // global (omitted from_entry) 100% bracket covers that post-add + // position on TradingView. Keep the normal finite reservation for + // sibling accounting, then mark this one order so the fill path closes + // the full live position after the adds have joined it. + // + // This is deliberately narrower than the reversal binding above: + // POOC only; every pending MARKET/ENTRY/RAW order must be an ordinary + // same-bar high-level MARKET entry on the same side at placement and + // now, under the pyramiding cap; at least one such add must exist; + // global full-percent default sizing only. Explicit exit qty uses the + // separate branch, while from_entry, partial, RAW_ORDER, priced, + // opposite, prior-bar, COOF-recalc, over-cap, and mixed-queue shapes + // retain the established frozen reservation. + bool eligible_global_full_exit_dynamic_qty = false; + if (from_entry.empty() + && process_orders_on_close_ + && !effectively_flat + && qp >= 100.0 - kFullPercentEps) { + bool found_qualifying_add = false; + bool entry_queue_is_bounded = true; + for (const auto& o : pending_orders_) { + if (o.type != OrderType::MARKET + && o.type != OrderType::ENTRY + && o.type != OrderType::RAW_ORDER) { + continue; + } + const PositionSide entry_dir = o.is_long + ? PositionSide::LONG : PositionSide::SHORT; + const bool qualifies = o.created_bar == bar_index_ + && o.type == OrderType::MARKET + && !o.created_during_coof_recalc + && !o.over_pyramiding_cap_at_placement + && entry_dir == position_side_ + && o.created_position_side == position_side_; + if (!qualifies) { + entry_queue_is_bounded = false; + break; + } + found_qualifying_add = true; + } + eligible_global_full_exit_dynamic_qty = + found_qualifying_add + && entry_queue_is_bounded; + } + + if (!bind_to_pending_reversal_entry) { + if (!compute_exit_reserved_qty( + from_entry, preserved_reserved_qty, live_pos_qty, + qp, is_partial, reserved_qty)) { + return; + } + eligible_global_full_exit_dynamic_qty = + eligible_global_full_exit_dynamic_qty + && !is_partial + && std::isfinite(reserved_qty) + && reserved_qty >= live_pos_qty - kFullQtyEps; } + + // The pending order stores this below after the common construction. + bind_global_full_exit_dynamic_qty = + eligible_global_full_exit_dynamic_qty; } PendingOrder order; @@ -946,6 +1009,8 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.qty_type = -1; order.qty_percent = qp; order.requested_partial = is_partial; + order.pooc_global_full_exit_dynamic_qty = + bind_global_full_exit_dynamic_qty; // OCA-name plumbing: ``strategy.exit`` supports oca_name (Pine v6) so // siblings in different OCA groups can fire independently. The cancel // sweep predicate (engine_fills.cpp::apply_filled_order_to_state → @@ -1146,6 +1211,21 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double } pending_orders_.push_back(std::move(order)); + invalidate_unsafe_pooc_global_full_exit_dynamic_qty(); +} + +void BacktestEngine::invalidate_unsafe_pooc_global_full_exit_dynamic_qty() { + // This helper is called only after a later entry-like placement was + // successfully admitted. The oracle covers adds already pending BEFORE + // the global exit, not any order emitted after it—even another same-side + // MARKET add. Clear every still-live marker, including candidates carried + // into a later bar; their finite ``qty`` remains the conservative + // reservation automatically. + for (auto& o : pending_orders_) { + if (o.type == OrderType::EXIT) { + o.pooc_global_full_exit_dynamic_qty = false; + } + } } // ──────────────────────────────────────────────────────────────────── diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cbe3f7c..fcc66cf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ set(TEST_SOURCES test_integration test_request_security test_historical_security_lookahead_projection + test_pooc_global_full_exit test_security_range_start_na_warmup test_chart_ema_na_warmup test_security_tf_validation diff --git a/tests/test_pooc_global_full_exit.cpp b/tests/test_pooc_global_full_exit.cpp new file mode 100644 index 0000000..410163a --- /dev/null +++ b/tests/test_pooc_global_full_exit.cpp @@ -0,0 +1,485 @@ +/* + * A global strategy.exit (omitted from_entry) armed after a same-direction + * high-level MARKET strategy.entry on a POOC bar must cover the position that + * exists after that queued entry fills. Freezing the exit reservation at the + * pre-add live quantity strands the new pyramid slice when the bracket fires. + */ + +#include +#include + +#include +#include +#include +#include +#include + +using namespace pineforge; + +namespace { + +int failures = 0; + +#define CHECK(cond, tag) do { \ + if (!(cond)) { \ + std::printf("FAIL: %s (line %d)\n", (tag), __LINE__); \ + ++failures; \ + } \ +} while (0) + +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +bool near(double lhs, double rhs, double tolerance = 1e-9) { + return std::fabs(lhs - rhs) <= tolerance; +} + +enum class QueuedEntryShape { + SameDirectionMarket, + OppositeMarket, + RawMarket, + PricedEntry, + CoofRecalcMarket, +}; + +struct CaseConfig { + bool pooc = true; + std::vector entry_shapes = { + QueuedEntryShape::SameDirectionMarket, + }; + std::vector entry_shapes_after_exit; + std::vector carried_entry_shapes; + std::string from_entry; + double qty_percent = 100.0; + double explicit_exit_qty = kNaN; + int pyramiding = 3; + bool second_global_exit = false; + bool prior_partial_global_exit = false; + bool replace_first_add_after_exit = false; + bool later_bar_same_direction_entry = false; +}; + +class ReservationProbe final : public BacktestEngine { +public: + explicit ReservationProbe(CaseConfig config) : config_(std::move(config)) { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = config_.pyramiding; + process_orders_on_close_ = config_.pooc; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("BASE", /*is_long=*/true, + kNaN, kNaN, /*qty=*/1.0); + for (std::size_t i = 0; + i < config_.carried_entry_shapes.size(); ++i) { + queue_carried_entry(config_.carried_entry_shapes[i], i); + } + } + if (bar_index_ == 2 && config_.later_bar_same_direction_entry + && position_side_ == PositionSide::LONG) { + strategy_entry("LATER_BAR_ADD", /*is_long=*/true, + kNaN, kNaN, /*qty=*/1.0); + for (const auto& order : pending_orders_) { + if (order.type == OrderType::EXIT && order.id == "EXIT") { + later_bar_exit_dynamic_qty_ = + order.pooc_global_full_exit_dynamic_qty; + } + } + } + if (bar_index_ != 1 || position_side_ != PositionSide::LONG) { + return; + } + + for (std::size_t i = 0; i < config_.entry_shapes.size(); ++i) { + queue_entry(config_.entry_shapes[i], i); + } + + if (config_.prior_partial_global_exit) { + strategy_exit("PARTIAL", "", + /*limit=*/104.0, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, + /*qty_percent=*/50.0, "partial sibling"); + } + + strategy_exit("EXIT", config_.from_entry, + /*limit=*/config_.later_bar_same_direction_entry + ? 150.0 : 105.0, + /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, + config_.qty_percent, "exit", + config_.explicit_exit_qty); + + if (config_.replace_first_add_after_exit) { + strategy_entry("ADD_0", /*is_long=*/true, + kNaN, kNaN, /*qty=*/1.0); + } + + if (config_.second_global_exit) { + strategy_exit("EXIT2", "", + /*limit=*/106.0, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, + /*qty_percent=*/100.0, "second exit"); + } + + for (std::size_t i = 0; + i < config_.entry_shapes_after_exit.size(); ++i) { + queue_entry(config_.entry_shapes_after_exit[i], + config_.entry_shapes.size() + i); + } + + for (const auto& order : pending_orders_) { + if (order.type != OrderType::EXIT) continue; + ++exit_order_count_; + if (order.id == "EXIT2") { + second_exit_captured_ = true; + } + if (order.id == "EXIT") { + captured_ = true; + exit_qty_is_nan_ = std::isnan(order.qty); + exit_qty_ = order.qty; + exit_qty_percent_ = order.qty_percent; + exit_dynamic_qty_ = + order.pooc_global_full_exit_dynamic_qty; + } + } + } + + bool captured() const { return captured_; } + bool exit_qty_is_nan() const { return exit_qty_is_nan_; } + double exit_qty() const { return exit_qty_; } + double exit_qty_percent() const { return exit_qty_percent_; } + double position_size() const { return signed_position_size(); } + int exit_order_count() const { return exit_order_count_; } + bool second_exit_captured() const { return second_exit_captured_; } + bool exit_dynamic_qty() const { return exit_dynamic_qty_; } + bool later_bar_exit_dynamic_qty() const { + return later_bar_exit_dynamic_qty_; + } + +private: + void queue_carried_entry(QueuedEntryShape shape, std::size_t ordinal) { + const std::string suffix = "_" + std::to_string(ordinal); + switch (shape) { + case QueuedEntryShape::PricedEntry: + strategy_entry("CARRIED_ENTRY" + suffix, + /*is_long=*/true, + /*limit=*/50.0, kNaN, /*qty=*/1.0); + break; + case QueuedEntryShape::RawMarket: + strategy_order("CARRIED_RAW" + suffix, + /*is_long=*/true, /*qty=*/1.0, + /*limit=*/50.0, /*stop=*/kNaN); + break; + default: + CHECK(false, "unsupported carried entry shape"); + break; + } + } + + void queue_entry(QueuedEntryShape shape, std::size_t ordinal) { + const std::string suffix = "_" + std::to_string(ordinal); + switch (shape) { + case QueuedEntryShape::SameDirectionMarket: + strategy_entry("ADD" + suffix, /*is_long=*/true, + kNaN, kNaN, /*qty=*/1.0); + break; + case QueuedEntryShape::OppositeMarket: + strategy_entry("REVERSE" + suffix, /*is_long=*/false, + kNaN, kNaN, /*qty=*/1.0); + break; + case QueuedEntryShape::RawMarket: + strategy_order("RAW_ADD" + suffix, + /*is_long=*/true, /*qty=*/1.0); + break; + case QueuedEntryShape::PricedEntry: + strategy_entry("PRICED_ADD" + suffix, /*is_long=*/true, + /*limit=*/99.0, kNaN, /*qty=*/1.0); + break; + case QueuedEntryShape::CoofRecalcMarket: + strategy_entry("COOF_ADD" + suffix, /*is_long=*/true, + kNaN, kNaN, /*qty=*/1.0); + // Pin the provenance guard in isolation. Scheduler behavior is + // covered by the dedicated COOF suites; this fixture only + // needs a same-bar MARKET carrying recalc provenance when the + // reservation decision runs. + for (auto& order : pending_orders_) { + if (order.id == "COOF_ADD" + suffix) { + order.created_during_coof_recalc = true; + } + } + break; + } + } + CaseConfig config_; + bool captured_ = false; + bool exit_qty_is_nan_ = false; + double exit_qty_ = kNaN; + double exit_qty_percent_ = kNaN; + int exit_order_count_ = 0; + bool second_exit_captured_ = false; + bool exit_dynamic_qty_ = false; + bool later_bar_exit_dynamic_qty_ = false; +}; + +Bar make_bar(double open, double high, double low, double close, + int64_t timestamp) { + return Bar{open, high, low, close, 1000.0, timestamp}; +} + +Bar bars[] = { + make_bar(100.0, 101.0, 99.0, 100.0, 900'000), + make_bar(100.0, 102.0, 98.0, 100.0, 1'800'000), + make_bar(100.0, 106.0, 98.0, 100.0, 2'700'000), + make_bar(100.0, 101.0, 99.0, 100.0, 3'600'000), +}; + +ReservationProbe run_case(CaseConfig config) { + ReservationProbe probe(std::move(config)); + probe.run(bars, static_cast(sizeof(bars) / sizeof(bars[0]))); + CHECK(probe.last_error().empty(), "case run succeeds"); + CHECK(probe.captured(), "exit reservation captured"); + return probe; +} + +void test_positive_global_full_exit_defers_and_flattens_add() { + ReservationProbe probe = run_case(CaseConfig{}); + CHECK(!probe.exit_qty_is_nan(), + "eligible global full exit keeps finite sibling reservation"); + CHECK(near(probe.exit_qty(), 1.0), + "eligible exit retains the pre-add finite fallback"); + CHECK(probe.exit_dynamic_qty(), + "eligible exit is marked for full-live fill sizing"); + CHECK(near(probe.exit_qty_percent(), 100.0), + "eligible exit remains a full-percent request"); + CHECK(probe.trade_count() == 2, + "global bracket closes base and same-bar add slices"); + CHECK(near(probe.position_size(), 0.0), + "global bracket leaves no stranded pyramid slice"); +} + +void test_explicit_exit_qty_keeps_literal_reservation() { + CaseConfig config; + config.explicit_exit_qty = 1.0; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), "explicit exit qty is never deferred"); + CHECK(near(probe.exit_qty(), 1.0), "explicit exit qty remains literal"); + CHECK(near(probe.position_size(), 1.0), + "explicit one-lot exit leaves the add slice open"); +} + +void test_partial_percent_keeps_frozen_reservation() { + CaseConfig config; + config.qty_percent = 50.0; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), "partial percent is never deferred"); + CHECK(near(probe.exit_qty(), 0.5), "partial percent reserves live fraction"); + CHECK(near(probe.exit_qty_percent(), 50.0), + "partial percent remains unchanged"); +} + +void test_from_entry_bound_exit_keeps_frozen_reservation() { + CaseConfig config; + config.from_entry = "BASE"; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), "from_entry-bound exit is never deferred"); + CHECK(near(probe.exit_qty(), 1.0), + "from_entry-bound exit reserves the live base lot"); +} + +void test_non_pooc_keeps_frozen_reservation() { + CaseConfig config; + config.pooc = false; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), "non-POOC exit is never deferred"); + CHECK(near(probe.exit_qty(), 1.0), + "non-POOC exit reserves the live position"); +} + +void test_overcap_market_entry_keeps_frozen_reservation() { + CaseConfig config; + config.pyramiding = 1; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), + "over-cap market entry does not defer reservation"); + CHECK(near(probe.exit_qty(), 1.0), + "over-cap market entry preserves live reservation"); +} + +void test_only_high_level_same_direction_market_qualifies() { + for (QueuedEntryShape shape : { + QueuedEntryShape::OppositeMarket, + QueuedEntryShape::RawMarket, + QueuedEntryShape::PricedEntry, + QueuedEntryShape::CoofRecalcMarket}) { + CaseConfig config; + config.entry_shapes = {shape}; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), + "non-qualifying queued entry does not defer reservation"); + CHECK(near(probe.exit_qty(), 1.0), + "non-qualifying queued entry preserves live reservation"); + } +} + +void test_opposite_before_qualifying_add_vetoes_deferred_reservation() { + CaseConfig config; + config.entry_shapes = { + QueuedEntryShape::OppositeMarket, + QueuedEntryShape::SameDirectionMarket, + }; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), + "opposite market plus qualifying add keeps frozen reservation"); + CHECK(near(probe.exit_qty(), 1.0), + "mixed-direction queue reserves only the live position"); +} + +void test_priced_or_raw_coexistence_vetoes_deferred_reservation() { + for (QueuedEntryShape extra : { + QueuedEntryShape::PricedEntry, + QueuedEntryShape::RawMarket}) { + CaseConfig config; + config.entry_shapes = { + extra, + QueuedEntryShape::SameDirectionMarket, + }; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), + "priced/RAW coexistence keeps frozen reservation"); + CHECK(near(probe.exit_qty(), 1.0), + "mixed entry-kind queue reserves only the live position"); + } +} + +void test_nonqualifying_order_after_exit_vetoes_deferred_reservation() { + for (QueuedEntryShape later : { + QueuedEntryShape::SameDirectionMarket, + QueuedEntryShape::OppositeMarket, + QueuedEntryShape::PricedEntry, + QueuedEntryShape::RawMarket}) { + CaseConfig config; + config.entry_shapes_after_exit = {later}; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), + "later nonqualifying order restores frozen reservation"); + CHECK(near(probe.exit_qty(), 1.0), + "later mixed queue reserves only the pre-add live position"); + CHECK(!probe.exit_dynamic_qty(), + "any later admitted entry-like order clears dynamic sizing"); + } +} + +void test_prior_bar_carried_entry_vetoes_deferred_reservation() { + for (QueuedEntryShape carried : { + QueuedEntryShape::PricedEntry, + QueuedEntryShape::RawMarket}) { + CaseConfig config; + config.carried_entry_shapes = {carried}; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), + "carried priced/RAW entry keeps frozen reservation"); + CHECK(near(probe.exit_qty(), 1.0), + "carried entry coexistence reserves the live position"); + CHECK(!probe.exit_dynamic_qty(), + "prior-bar carried entry never enables dynamic sizing"); + } +} + +void test_sibling_global_exit_preserves_first_reservation() { + CaseConfig config; + config.second_global_exit = true; + ReservationProbe probe = run_case(config); + CHECK(probe.exit_order_count() == 1, + "full first exit consumes sibling reservation capacity"); + CHECK(!probe.second_exit_captured(), + "second global exit is not admitted without capacity"); + CHECK(probe.exit_dynamic_qty(), + "first fully reserved exit keeps the bounded dynamic marker"); +} + +void test_prior_partial_sibling_blocks_dynamic_full_reservation() { + CaseConfig config; + config.prior_partial_global_exit = true; + ReservationProbe probe = run_case(config); + CHECK(probe.exit_order_count() == 2, + "partial sibling and remaining-capacity exit are both admitted"); + CHECK(!probe.exit_qty_is_nan(), + "remaining-capacity global exit keeps finite reservation"); + CHECK(near(probe.exit_qty(), 0.5), + "global exit reserves only capacity left by partial sibling"); + CHECK(!probe.exit_dynamic_qty(), + "partial sibling prevents full-live dynamic sizing"); +} + +void test_same_id_add_replacement_after_exit_clears_dynamic_sizing() { + CaseConfig config; + config.replace_first_add_after_exit = true; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_dynamic_qty(), + "post-exit same-id replacement clears dynamic sizing"); + CHECK(!probe.exit_qty_is_nan() && near(probe.exit_qty(), 1.0), + "same-id replacement retains the finite pre-add fallback"); +} + +void test_later_bar_entry_clears_resting_dynamic_sizing() { + CaseConfig config; + config.later_bar_same_direction_entry = true; + ReservationProbe probe = run_case(config); + CHECK(probe.exit_dynamic_qty(), + "pre-exit add initially enables dynamic sizing"); + CHECK(!probe.later_bar_exit_dynamic_qty(), + "later-bar admitted entry clears resting dynamic sizing"); +} + +void test_multiple_qualifying_adds_remain_covered() { + CaseConfig config; + config.entry_shapes = { + QueuedEntryShape::SameDirectionMarket, + QueuedEntryShape::SameDirectionMarket, + }; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_qty_is_nan(), + "multiple qualifying adds keep finite sibling reservation"); + CHECK(near(probe.exit_qty(), 1.0), + "multiple-add exit retains the one-lot fallback"); + CHECK(probe.exit_dynamic_qty(), + "multiple pre-exit qualifying adds enable dynamic sizing"); + CHECK(probe.trade_count() == 3, + "global bracket closes base and both qualifying adds"); + CHECK(near(probe.position_size(), 0.0), + "multiple qualifying adds leave no stranded slice"); +} + +} // namespace + +int main() { + test_positive_global_full_exit_defers_and_flattens_add(); + test_explicit_exit_qty_keeps_literal_reservation(); + test_partial_percent_keeps_frozen_reservation(); + test_from_entry_bound_exit_keeps_frozen_reservation(); + test_non_pooc_keeps_frozen_reservation(); + test_overcap_market_entry_keeps_frozen_reservation(); + test_only_high_level_same_direction_market_qualifies(); + test_opposite_before_qualifying_add_vetoes_deferred_reservation(); + test_priced_or_raw_coexistence_vetoes_deferred_reservation(); + test_nonqualifying_order_after_exit_vetoes_deferred_reservation(); + test_prior_bar_carried_entry_vetoes_deferred_reservation(); + test_sibling_global_exit_preserves_first_reservation(); + test_prior_partial_sibling_blocks_dynamic_full_reservation(); + test_same_id_add_replacement_after_exit_clears_dynamic_sizing(); + test_later_bar_entry_clears_resting_dynamic_sizing(); + test_multiple_qualifying_adds_remain_covered(); + if (failures != 0) { + std::printf("%d check(s) FAILED\n", failures); + return 1; + } + std::printf("test_pooc_global_full_exit passed.\n"); + return 0; +} From 07bde0bf7775796fbd590ce2981b0fe379b1a6a4 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 18:40:47 +0800 Subject: [PATCH 11/16] fix: preserve bounded POOC exit reservations (cherry picked from commit d8ac5c13e1343121d3941d492abe7ba6e5b11c49) --- include/pineforge/engine.hpp | 9 +++ src/engine_fills.cpp | 32 ++++++++ src/engine_strategy_commands.cpp | 12 ++- tests/test_pooc_global_full_exit.cpp | 109 +++++++++++++++++++++++++-- 4 files changed, 156 insertions(+), 6 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 554e8d9..64cc607 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -452,6 +452,15 @@ struct PendingOrder { // clears the bit, making the finite qty the automatic conservative // fallback—even when that later order is placed on a future bar. bool pooc_global_full_exit_dynamic_qty = false; + // Persistent half of the bounded POOC relation. Unlike ``dynamic_qty``, + // later entries do not clear this bit: pre-exit adds may still be waiting + // to fill when invalidation occurs. Each successfully filled bound add + // grows this EXIT's finite qty by its exact same-side position delta. + bool pooc_global_full_exit_tracks_bound_adds = false; + // Set only on qualifying high-level MARKET adds already pending when the + // tracking global EXIT is placed. Same-id replacement constructs a fresh + // PendingOrder and therefore drops the relation; later orders never get it. + bool pooc_global_full_exit_bound_add = false; bool created_while_in_position = false; // true if position was open when order was placed // design-declined-reversal-close-leg: set at the KI-54 percent-of-equity // reversal-decline site when this pending FULL close was co-queued AFTER, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 5eae4bd..21153c3 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1879,6 +1879,38 @@ void BacktestEngine::apply_filled_order_to_state( || std::abs(position_qty_ - position_qty_before_fill) > kQtyEpsilon || pyramid_entries_.size() != pyramid_lots_before_fill || trades_.size() != trades_before; + + // Bounded POOC global-exit growth. Only MARKET adds that were already + // pending when the one tracking EXIT was armed carry this relation bit. + // Grow its ordinary finite reservation by the actual same-side quantity + // delta—not requested qty—so fill-time rejection, zero-fill, reversal, or + // flat-open role changes add nothing. The tracking bit intentionally + // survives dynamic-marker invalidation: a post-exit order can be admitted + // before this pre-exit add reaches the POOC close fill point. + if (order.type == OrderType::MARKET + && order.pooc_global_full_exit_bound_add) { + const PositionSide requested_side = order.is_long + ? PositionSide::LONG : PositionSide::SHORT; + const bool successful_same_side_add = + requested_side == order.created_position_side + && position_side_before_fill == requested_side + && position_side_ == requested_side + && position_qty_ > position_qty_before_fill + kQtyEpsilon; + if (successful_same_side_add) { + const double added_qty = + position_qty_ - position_qty_before_fill; + for (auto& candidate : pending_orders_) { + if (candidate.type != OrderType::EXIT + || !candidate.pooc_global_full_exit_tracks_bound_adds + || !std::isfinite(candidate.qty)) { + continue; + } + candidate.qty += added_qty; + break; // reservation accounting admits at most one tracker + } + } + } + if (primary_fill_applied) { ++broker_fill_event_seq_; } diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index fb36650..222e89c 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -827,6 +827,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro double reserved_qty = std::numeric_limits::quiet_NaN(); bool bind_global_full_exit_dynamic_qty = false; + std::vector pooc_global_full_exit_bound_add_indices; if (has_explicit_qty) { // Honour the explicit qty literally (clamped to the live position // and subject to the same already-reserved accounting). This is @@ -950,7 +951,8 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro && qp >= 100.0 - kFullPercentEps) { bool found_qualifying_add = false; bool entry_queue_is_bounded = true; - for (const auto& o : pending_orders_) { + for (std::size_t i = 0; i < pending_orders_.size(); ++i) { + const PendingOrder& o = pending_orders_[i]; if (o.type != OrderType::MARKET && o.type != OrderType::ENTRY && o.type != OrderType::RAW_ORDER) { @@ -969,6 +971,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro break; } found_qualifying_add = true; + pooc_global_full_exit_bound_add_indices.push_back(i); } eligible_global_full_exit_dynamic_qty = found_qualifying_add @@ -1011,6 +1014,13 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.requested_partial = is_partial; order.pooc_global_full_exit_dynamic_qty = bind_global_full_exit_dynamic_qty; + order.pooc_global_full_exit_tracks_bound_adds = + bind_global_full_exit_dynamic_qty; + if (bind_global_full_exit_dynamic_qty) { + for (std::size_t index : pooc_global_full_exit_bound_add_indices) { + pending_orders_[index].pooc_global_full_exit_bound_add = true; + } + } // OCA-name plumbing: ``strategy.exit`` supports oca_name (Pine v6) so // siblings in different OCA groups can fire independently. The cancel // sweep predicate (engine_fills.cpp::apply_filled_order_to_state → diff --git a/tests/test_pooc_global_full_exit.cpp b/tests/test_pooc_global_full_exit.cpp index 410163a..91542ed 100644 --- a/tests/test_pooc_global_full_exit.cpp +++ b/tests/test_pooc_global_full_exit.cpp @@ -56,6 +56,8 @@ struct CaseConfig { bool prior_partial_global_exit = false; bool replace_first_add_after_exit = false; bool later_bar_same_direction_entry = false; + bool later_bar_sibling_exit = false; + bool defer_exit_until_bar4 = false; }; class ReservationProbe final : public BacktestEngine { @@ -79,14 +81,38 @@ class ReservationProbe final : public BacktestEngine { queue_carried_entry(config_.carried_entry_shapes[i], i); } } - if (bar_index_ == 2 && config_.later_bar_same_direction_entry + if (bar_index_ == 2 + && (config_.later_bar_same_direction_entry + || config_.later_bar_sibling_exit + || config_.defer_exit_until_bar4) && position_side_ == PositionSide::LONG) { - strategy_entry("LATER_BAR_ADD", /*is_long=*/true, - kNaN, kNaN, /*qty=*/1.0); + for (const auto& order : pending_orders_) { + if (order.type == OrderType::EXIT && order.id == "EXIT") { + post_fill_exit_qty_ = order.qty; + } + } + if (config_.later_bar_same_direction_entry) { + strategy_entry("LATER_BAR_ADD", /*is_long=*/true, + kNaN, kNaN, /*qty=*/1.0); + } + if (config_.later_bar_sibling_exit) { + strategy_exit("LATER_SIBLING", "", + /*limit=*/120.0, /*stop=*/kNaN, + /*trail_points=*/kNaN, + /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, + /*qty_percent=*/100.0, + "later sibling"); + } for (const auto& order : pending_orders_) { if (order.type == OrderType::EXIT && order.id == "EXIT") { later_bar_exit_dynamic_qty_ = order.pooc_global_full_exit_dynamic_qty; + post_fill_exit_qty_ = order.qty; + } + if (order.type == OrderType::EXIT + && order.id == "LATER_SIBLING") { + later_bar_sibling_captured_ = true; } } } @@ -106,9 +132,11 @@ class ReservationProbe final : public BacktestEngine { /*qty_percent=*/50.0, "partial sibling"); } + const bool defer_exit = config_.later_bar_same_direction_entry + || config_.later_bar_sibling_exit + || config_.defer_exit_until_bar4; strategy_exit("EXIT", config_.from_entry, - /*limit=*/config_.later_bar_same_direction_entry - ? 150.0 : 105.0, + /*limit=*/defer_exit ? 110.0 : 105.0, /*stop=*/kNaN, /*trail_points=*/kNaN, /*trail_offset=*/kNaN, /*trail_price=*/kNaN, @@ -162,6 +190,10 @@ class ReservationProbe final : public BacktestEngine { bool later_bar_exit_dynamic_qty() const { return later_bar_exit_dynamic_qty_; } + double post_fill_exit_qty() const { return post_fill_exit_qty_; } + bool later_bar_sibling_captured() const { + return later_bar_sibling_captured_; + } private: void queue_carried_entry(QueuedEntryShape shape, std::size_t ordinal) { @@ -226,6 +258,8 @@ class ReservationProbe final : public BacktestEngine { bool second_exit_captured_ = false; bool exit_dynamic_qty_ = false; bool later_bar_exit_dynamic_qty_ = false; + double post_fill_exit_qty_ = kNaN; + bool later_bar_sibling_captured_ = false; }; Bar make_bar(double open, double high, double low, double close, @@ -238,6 +272,7 @@ Bar bars[] = { make_bar(100.0, 102.0, 98.0, 100.0, 1'800'000), make_bar(100.0, 106.0, 98.0, 100.0, 2'700'000), make_bar(100.0, 101.0, 99.0, 100.0, 3'600'000), + make_bar(100.0, 111.0, 99.0, 100.0, 4'500'000), }; ReservationProbe run_case(CaseConfig config) { @@ -426,6 +461,10 @@ void test_same_id_add_replacement_after_exit_clears_dynamic_sizing() { "post-exit same-id replacement clears dynamic sizing"); CHECK(!probe.exit_qty_is_nan() && near(probe.exit_qty(), 1.0), "same-id replacement retains the finite pre-add fallback"); + CHECK(probe.trade_count() == 1, + "replacement add is not counted as a bound pre-exit fill"); + CHECK(near(probe.position_size(), 1.0), + "finite fallback leaves the unbound replacement add open"); } void test_later_bar_entry_clears_resting_dynamic_sizing() { @@ -436,6 +475,60 @@ void test_later_bar_entry_clears_resting_dynamic_sizing() { "pre-exit add initially enables dynamic sizing"); CHECK(!probe.later_bar_exit_dynamic_qty(), "later-bar admitted entry clears resting dynamic sizing"); + CHECK(near(probe.post_fill_exit_qty(), 2.0), + "filled pre-exit add grows finite reservation before invalidation"); + CHECK(probe.trade_count() == 2, + "finite exit closes base and the covered pre-exit add"); + CHECK(near(probe.position_size(), 1.0), + "finite exit leaves the later unbound add open"); +} + +void test_samebar_later_add_does_not_erase_preexit_add_coverage() { + CaseConfig config; + config.entry_shapes_after_exit = { + QueuedEntryShape::SameDirectionMarket, + }; + config.defer_exit_until_bar4 = true; + ReservationProbe probe = run_case(config); + CHECK(!probe.exit_dynamic_qty(), + "post-exit same-bar add clears dynamic sizing before fills"); + CHECK(near(probe.post_fill_exit_qty(), 2.0), + "pre-exit bound add still grows finite reservation at fill"); + CHECK(probe.trade_count() == 2, + "bounded reservation closes base and pre-exit add only"); + CHECK(near(probe.position_size(), 1.0), + "same-bar post-exit add remains outside bounded coverage"); +} + +void test_later_bar_sibling_sees_grown_finite_reservation() { + CaseConfig config; + config.later_bar_sibling_exit = true; + ReservationProbe probe = run_case(config); + CHECK(near(probe.post_fill_exit_qty(), 2.0), + "successful covered add grows first exit reservation"); + CHECK(!probe.later_bar_sibling_captured(), + "later sibling is rejected after bounded reservation growth"); + CHECK(probe.trade_count() == 2, + "first exit closes both bounded lots"); + CHECK(near(probe.position_size(), 0.0), + "later sibling scenario finishes flat"); +} + +void test_rejected_bound_add_does_not_inflate_finite_reservation() { + CaseConfig config; + config.entry_shapes = { + QueuedEntryShape::SameDirectionMarket, + QueuedEntryShape::SameDirectionMarket, + }; + config.pyramiding = 2; + config.defer_exit_until_bar4 = true; + ReservationProbe probe = run_case(config); + CHECK(near(probe.post_fill_exit_qty(), 2.0), + "only the one admitted bound add grows finite reservation"); + CHECK(probe.trade_count() == 2, + "rejected second add creates no extra covered trade"); + CHECK(near(probe.position_size(), 0.0), + "admitted base and add are fully covered"); } void test_multiple_qualifying_adds_remain_covered() { @@ -444,6 +537,7 @@ void test_multiple_qualifying_adds_remain_covered() { QueuedEntryShape::SameDirectionMarket, QueuedEntryShape::SameDirectionMarket, }; + config.defer_exit_until_bar4 = true; ReservationProbe probe = run_case(config); CHECK(!probe.exit_qty_is_nan(), "multiple qualifying adds keep finite sibling reservation"); @@ -451,6 +545,8 @@ void test_multiple_qualifying_adds_remain_covered() { "multiple-add exit retains the one-lot fallback"); CHECK(probe.exit_dynamic_qty(), "multiple pre-exit qualifying adds enable dynamic sizing"); + CHECK(near(probe.post_fill_exit_qty(), 3.0), + "each successful pre-exit add grows finite reservation exactly"); CHECK(probe.trade_count() == 3, "global bracket closes base and both qualifying adds"); CHECK(near(probe.position_size(), 0.0), @@ -475,6 +571,9 @@ int main() { test_prior_partial_sibling_blocks_dynamic_full_reservation(); test_same_id_add_replacement_after_exit_clears_dynamic_sizing(); test_later_bar_entry_clears_resting_dynamic_sizing(); + test_samebar_later_add_does_not_erase_preexit_add_coverage(); + test_later_bar_sibling_sees_grown_finite_reservation(); + test_rejected_bound_add_does_not_inflate_finite_reservation(); test_multiple_qualifying_adds_remain_covered(); if (failures != 0) { std::printf("%d check(s) FAILED\n", failures); From d7141de006c8a42b50d3b12e354a9b7401ebe5bd Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 14 Jul 2026 19:03:59 +0800 Subject: [PATCH 12/16] fix: scope zero-cover margin liquidation (cherry picked from commit 345d8047219606e6f883a32285020d58d4b5194a) --- include/pineforge/engine.hpp | 14 ++++++++++++++ src/engine_fills.cpp | 25 ++++++++++++++++++------- tests/test_margin_call.cpp | 36 ++++++++++++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 64cc607..551fe34 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -666,6 +666,12 @@ class BacktestEngine { // process_margin_call floors each liquidation lot DOWN to a multiple of // this, matching TradingView's per-instrument margin-call lot sizing. double qty_step_ = 0.0; + // Opt-in oracle candidate for the ambiguous finite-price margin case where + // the documented minimum restore quantity floors to zero. The established + // default makes progress by one quantity step; selected historical exports + // instead close the whole residual. Keep that alternative default-off so + // it cannot rewrite otherwise matching trade tapes. + bool margin_zero_cover_full_liquidation_ = false; int max_intraday_filled_orders_ = 0; // 0 = unlimited bool close_entries_rule_any_ = false; // true = "ANY", false = "FIFO" (default) // Percentage of margin required to open a long/short position. Default @@ -2488,6 +2494,14 @@ class BacktestEngine { if (key == "chart_ema_na_warmup") { chart_ema_na_warmup_ = std::isfinite(value) && value > 0.0; } + // Default-off verifier candidate for a finite-price margin call whose + // lot-quantized restore quantity is zero. Positive finite values close + // the residual; absent/zero/non-finite values preserve the established + // one-step progress fallback. + if (key == "margin_zero_cover_full_liquidation") { + margin_zero_cover_full_liquidation_ = + std::isfinite(value) && value > 0.0; + } // "qty_step" is the per-instrument lot increment used by the forced- // liquidation quantizer. Route it onto the dedicated member so the // codegen run(const Bar*, int) path (which never overwrites it) keeps diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 21153c3..4c21c92 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -565,9 +565,17 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (qty_step_ > 0.0) { // The quotient can land microscopically below an exact integer because // of binary representation (for example, one mathematical lot can be - // 0.99999999998 lots here). Use the same 1e-6-of-step guard as the - // downstream 4x quantizer so an exact grid value is not erased. - q_min = std::floor(q_min / qty_step_ + 1e-6) * qty_step_; + // 0.99999999998 lots here). The full-residual candidate uses the same + // 1e-6-of-step guard as the downstream 4x quantizer; the default keeps + // the established bare floor byte-for-byte. + double step_count = q_min / qty_step_; + if (margin_zero_cover_full_liquidation_) { + const double nearest_step = std::round(step_count); + if (std::abs(step_count - nearest_step) < 1e-6) { + step_count = nearest_step; + } + } + q_min = std::floor(step_count) * qty_step_; } // A sub-lot 1x-long opening shortfall is untradeable dust. It is a no-op, // not a reason to force the generic finite-price cascade's one-step @@ -587,10 +595,13 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (opening_affordability) return; // A finite-price liquidation IS required, but the documented // minimum-restore quantity truncates to zero at the instrument lot - // precision. TradingView closes the whole residual in this zero- - // cover case rather than inventing a one-step nibble. Opening- - // affordability dust remains the explicit no-op above. - floored = qty; + // precision. Exports disagree on this edge: some close the whole + // residual while others continue with a bounded nibble. Preserve + // the established one-step default, and expose the full-residual + // interpretation only through an explicit verifier candidate. + floored = margin_zero_cover_full_liquidation_ + ? qty + : std::min(qty_step_, qty); } qty_liq = floored; } diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index a683832..5a2144a 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -206,6 +206,7 @@ static void test_short_margin_call_qty_step() { const double step = 0.5; ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/step); + eng.set_syminfo_metadata("margin_zero_cover_full_liquidation", 1.0); eng.run(bars.data(), (int)bars.size()); CHECK(eng.trade_count() >= 1); @@ -276,6 +277,7 @@ static void test_short_margin_call_zero_cover_closes_full_residual() { }; ShortZeroCoverProbe eng(/*qty_step=*/0.0001); + eng.set_syminfo_metadata("margin_zero_cover_full_liquidation", 1.0); eng.run(bars.data(), (int)bars.size()); CHECK(eng.trade_count() == 1); @@ -286,6 +288,22 @@ static void test_short_margin_call_zero_cover_closes_full_residual() { CHECK(near(eng.position_size(), 0.0)); } +static void test_short_margin_call_zero_cover_defaults_to_one_step() { + std::printf("test_short_margin_call_zero_cover_defaults_to_one_step\n"); + std::vector bars = { + mk_bar(1000, 3788.00, 3788.00, 3788.00, 3788.00, 1.0), + mk_bar(2000, 3788.00, 3788.48, 3766.62, 3775.78, 1.0), + }; + + ShortZeroCoverProbe eng(/*qty_step=*/0.0001); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.trade_size(0), 0.0001)); + CHECK(near(eng.position_size(), -0.0264)); +} + static void test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble() { std::printf("test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble\n"); constexpr double step = 0.0001; @@ -296,15 +314,24 @@ static void test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble() const double adverse = 2000.0 / (20.0 - step); const double equity_at_high = 1000.0 - (adverse - 100.0) * 10.0; const double q_min = 10.0 - equity_at_high / adverse; + const double step_count = q_min / step; CHECK(q_min < step); - CHECK(q_min / step + 1e-6 >= 1.0); + CHECK(std::abs(step_count - std::round(step_count)) < 1e-6); std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), mk_bar(2000, 100.0, adverse, 99.0, 100.0, 1.0), }; + ShortLiqProbe default_eng(/*disable_mc=*/false, /*qty_step=*/step); + default_eng.run(bars.data(), (int)bars.size()); + CHECK(default_eng.trade_count() == 1); + CHECK(default_eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(default_eng.trade_size(0), step, 1e-12)); + CHECK(near(default_eng.position_size(), -(10.0 - step), 1e-12)); + ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/step); + eng.set_syminfo_metadata("margin_zero_cover_full_liquidation", 1.0); eng.run(bars.data(), (int)bars.size()); CHECK(eng.trade_count() == 1); @@ -323,8 +350,9 @@ static void test_short_margin_call_just_below_step_still_zero_covers() { const double adverse = 2000.0 / (20.0 - step * below_guard_ratio); const double equity_at_high = 1000.0 - (adverse - 100.0) * 10.0; const double q_min = 10.0 - equity_at_high / adverse; + const double step_count = q_min / step; CHECK(q_min < step); - CHECK(q_min / step + 1e-6 < 1.0); + CHECK(std::abs(step_count - std::round(step_count)) > 1e-6); std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), @@ -332,6 +360,7 @@ static void test_short_margin_call_just_below_step_still_zero_covers() { }; ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/step); + eng.set_syminfo_metadata("margin_zero_cover_full_liquidation", 1.0); eng.run(bars.data(), (int)bars.size()); CHECK(eng.trade_count() == 1); @@ -374,6 +403,7 @@ static void test_short_margin_call_nonzero_cover_keeps_four_x_nibble() { }; ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/0.1); + eng.set_syminfo_metadata("margin_zero_cover_full_liquidation", 1.0); eng.run(bars.data(), (int)bars.size()); CHECK(eng.trade_count() == 1); @@ -424,6 +454,7 @@ static void test_short_opening_affordability_zero_cover_remains_dust_noop() { }; ShortOpeningDustProbe eng; + eng.set_syminfo_metadata("margin_zero_cover_full_liquidation", 1.0); eng.run(bars.data(), (int)bars.size()); CHECK(eng.saw_actionable_opening_event); @@ -1725,6 +1756,7 @@ int main() { test_short_margin_call(); test_short_margin_call_qty_step(); test_short_margin_call_zero_cover_closes_full_residual(); + test_short_margin_call_zero_cover_defaults_to_one_step(); test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble(); test_short_margin_call_just_below_step_still_zero_covers(); test_short_margin_call_zero_cover_without_qty_step_stays_continuous(); From be9413e811552f51222bdb719b4ab047ab8c9c23 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 15 Jul 2026 05:48:52 +0800 Subject: [PATCH 13/16] fix: scope same-tick reversal sizing to paired brackets (cherry picked from commit 7638461c850dfbced3ef96acc396e546eeac0169) --- src/engine_fills.cpp | 82 +++++++++++++--- tests/test_same_tick_multi_entry_race.cpp | 112 ++++++++++++++++++++-- 2 files changed, 171 insertions(+), 23 deletions(-) diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 4c21c92..4fe1c5c 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace pineforge { @@ -1845,22 +1846,73 @@ void BacktestEngine::apply_filled_order_to_state( } if (order.type == OrderType::MARKET) { // TV same-tick multi-entry rule R* (see - // sequential_same_tick_reversal_fill): detect whether ANOTHER - // same-direction market entry with a DIFFERENT id, placed on the - // same on_bar, fills later at this same processing point. Orders - // after order_index in the sorted array are exactly the ones this - // pass has not yet evaluated (market orders always fill at the - // first processing point after placement, so a pending sibling - // here IS a same-tick fill). + // sequential_same_tick_reversal_fill): detect the paired-entry-block + // topology proven by the Jevond oracle. Both this entry and a later + // same-direction, different-id MARKET sibling must own live, + // actionable, default-sized full from_entry brackets created AFTER + // their respective entry calls on the same on_bar. A bare later entry + // is not enough: Rsantana queues an unbracketed primary reversal + // followed by a bracketed duplicate, and TV gives the primary the + // ordinary full reversal quantity rather than Jevond's sequential + // plain-transaction remainder. Deferred strategy.close EXIT orders, + // explicit/partial reservations, and pre-entry bracket reissues are + // deliberately excluded from this narrow oracle-backed shape. + // + // Orders after order_index in the sorted array are exactly the ones + // this pass has not yet evaluated (market orders always fill at the + // first processing point after placement, so an eligible sibling here + // IS a same-tick fill). bool later_same_tick_entry = false; - for (size_t j = order_index + 1; j < pending_orders_.size(); ++j) { - const PendingOrder& sib = pending_orders_[j]; - if (sib.type == OrderType::MARKET - && sib.is_long == order.is_long - && sib.id != order.id - && sib.created_bar == order.created_bar) { - later_same_tick_entry = true; - break; + const PositionSide requested_side = order.is_long + ? PositionSide::LONG : PositionSide::SHORT; + const bool is_reversal = position_side_ != PositionSide::FLAT + && position_side_ != requested_side; + if (is_reversal) { + // Build the child index once. Ordinary flat opens and adds skip all + // bracket scans, and a reversal remains linear in queue size. + std::unordered_map full_bracket_child_seq; + for (const PendingOrder& child : pending_orders_) { + const bool actionable = !std::isnan(child.limit_price) + || !std::isnan(child.stop_price) + || !std::isnan(child.trail_points) + || !std::isnan(child.trail_price) + || !std::isnan(child.profit_ticks) + || !std::isnan(child.loss_ticks); + const double qp = std::isnan(child.qty_percent) + ? 100.0 : child.qty_percent; + if (child.type != OrderType::EXIT + || child.from_entry.empty() + || child.created_bar != order.created_bar + || child.suppress_as_declined_reversal_close + || !actionable + || child.requested_partial + || !std::isnan(child.qty) + || qp < 100.0 - kFullPercentEps) { + continue; + } + auto [it, inserted] = full_bracket_child_seq.emplace( + child.from_entry, child.created_seq); + if (!inserted && child.created_seq > it->second) { + it->second = child.created_seq; + } + } + auto has_full_bracket_child = [&](const PendingOrder& entry) { + const auto it = full_bracket_child_seq.find(entry.id); + return it != full_bracket_child_seq.end() + && it->second > entry.created_seq; + }; + if (has_full_bracket_child(order)) { + for (size_t j = order_index + 1; j < pending_orders_.size(); ++j) { + const PendingOrder& sib = pending_orders_[j]; + if (sib.type == OrderType::MARKET + && sib.is_long == order.is_long + && sib.id != order.id + && sib.created_bar == order.created_bar + && has_full_bracket_child(sib)) { + later_same_tick_entry = true; + break; + } + } } } apply_market_order_fill(order, fill_price, bar, trail_best_path_state, diff --git a/tests/test_same_tick_multi_entry_race.cpp b/tests/test_same_tick_multi_entry_race.cpp index 91b6050..530a6dc 100644 --- a/tests/test_same_tick_multi_entry_race.cpp +++ b/tests/test_same_tick_multi_entry_race.cpp @@ -101,6 +101,10 @@ class RaceProbe : public BacktestEngine { int race_bar = 2; // bar issuing both entry blocks bool race_is_long = true; int second_race_bar = -1; // optional class-D repeat (-1 = none) + bool first_has_bracket = true; + bool last_has_bracket = true; + double first_bracket_qty_percent = 100.0; + double last_bracket_qty_percent = 100.0; // Brackets (long-side values; short-side mirrors around 100). // First entry's bracket fires strictly EARLIER on the path than the @@ -130,18 +134,30 @@ class RaceProbe : public BacktestEngine { void issue_race_blocks() { if (race_is_long) { strategy_entry("Long", true, kNaN, kNaN, kNaN, ""); - strategy_exit("Long Exit", "Long", first_limit_long, - first_stop_long, kNaN, kNaN, kNaN, 100.0, "", kNaN, ""); + if (first_has_bracket) { + strategy_exit("Long Exit", "Long", first_limit_long, + first_stop_long, kNaN, kNaN, kNaN, + first_bracket_qty_percent, "", kNaN, ""); + } strategy_entry("Wyckoff Long", true, kNaN, kNaN, kNaN, ""); - strategy_exit("Wyckoff Long Exit", "Wyckoff Long", last_limit_long, - last_stop_long, kNaN, kNaN, kNaN, 100.0, "", kNaN, ""); + if (last_has_bracket) { + strategy_exit("Wyckoff Long Exit", "Wyckoff Long", last_limit_long, + last_stop_long, kNaN, kNaN, kNaN, + last_bracket_qty_percent, "", kNaN, ""); + } } else { strategy_entry("Short", false, kNaN, kNaN, kNaN, ""); - strategy_exit("Short Exit", "Short", 200.0 - first_limit_long, - 200.0 - first_stop_long, kNaN, kNaN, kNaN, 100.0, "", kNaN, ""); + if (first_has_bracket) { + strategy_exit("Short Exit", "Short", 200.0 - first_limit_long, + 200.0 - first_stop_long, kNaN, kNaN, kNaN, + first_bracket_qty_percent, "", kNaN, ""); + } strategy_entry("Wyckoff Short", false, kNaN, kNaN, kNaN, ""); - strategy_exit("Wyckoff Short Exit", "Wyckoff Short", 200.0 - last_limit_long, - 200.0 - last_stop_long, kNaN, kNaN, kNaN, 100.0, "", kNaN, ""); + if (last_has_bracket) { + strategy_exit("Wyckoff Short Exit", "Wyckoff Short", 200.0 - last_limit_long, + 200.0 - last_stop_long, kNaN, kNaN, kNaN, + last_bracket_qty_percent, "", kNaN, ""); + } } } @@ -306,6 +322,82 @@ static void test_reversal_race_remainder_crosses_zero_first_id() { } } +// Rsantana discriminator — a duplicate later MARKET entry does not activate +// Jevond's sequential plain-transaction rule unless both entry blocks own +// their own full from_entry brackets. The primary entry here is unbracketed; +// TV therefore performs the ordinary full reversal under the primary id, and +// the bracketed duplicate is rejected by the pyramiding gate. +static void test_unbracketed_primary_reversal_keeps_full_qty() { + std::printf("test_unbracketed_primary_reversal_keeps_full_qty\n"); + RaceProbe p; + p.seed_bar = 0; + p.seed_is_long = false; + p.seed_qty = 50.0; // old short < q_plain, the old broad R* made 150 + p.race_bar = 2; + p.race_is_long = true; + p.first_has_bracket = false; + p.last_has_bracket = true; + auto bars = flat_feed(6); + p.run(bars.data(), (int)bars.size()); + + double closed_seed = 0.0; + for (const auto& t : p.closed) { + if (t.entry_id == "Seed") closed_seed += t.qty; + } + CHECK(near(closed_seed, 50.0, 1e-9)); + CHECK(p.final_side == PositionSide::LONG); + CHECK(p.open_lots.size() == 1); + if (p.open_lots.size() == 1) { + CHECK(p.open_lots[0].first == "Long"); + CHECK(near(p.open_lots[0].second, 200.0, 1e-9)); + } +} + +static void check_nonpaired_reversal_keeps_full_qty( + const char* label, bool first_has_bracket, bool last_has_bracket, + double first_qty_percent, double last_qty_percent) { + std::printf("%s\n", label); + RaceProbe p; + p.seed_bar = 0; + p.seed_is_long = false; + p.seed_qty = 50.0; + p.race_bar = 2; + p.race_is_long = true; + p.first_has_bracket = first_has_bracket; + p.last_has_bracket = last_has_bracket; + p.first_bracket_qty_percent = first_qty_percent; + p.last_bracket_qty_percent = last_qty_percent; + auto bars = flat_feed(6); + p.run(bars.data(), (int)bars.size()); + + CHECK(p.final_side == PositionSide::LONG); + CHECK(p.open_lots.size() == 1); + if (p.open_lots.size() == 1) { + CHECK(p.open_lots[0].first == "Long"); + CHECK(near(p.open_lots[0].second, 200.0, 1e-9)); + } +} + +// Both sides of the paired-bracket predicate are load-bearing. Neither a +// missing later child nor a partial child may activate Jevond R*. +static void test_unbracketed_later_reversal_keeps_full_qty() { + check_nonpaired_reversal_keeps_full_qty( + "test_unbracketed_later_reversal_keeps_full_qty", + true, false, 100.0, 100.0); +} + +static void test_partial_primary_bracket_keeps_full_qty() { + check_nonpaired_reversal_keeps_full_qty( + "test_partial_primary_bracket_keeps_full_qty", + true, true, 50.0, 100.0); +} + +static void test_partial_later_bracket_keeps_full_qty() { + check_nonpaired_reversal_keeps_full_qty( + "test_partial_later_bracket_keeps_full_qty", + true, true, 100.0, 50.0); +} + // Class D — same-direction position: both entries are rejected at execution // time; the position is unchanged and the live lot's bracket is refreshed // via from_entry binding. @@ -414,6 +506,10 @@ int main() { test_reversal_race_last_id_owns_entry(); test_reversal_race_operative_bracket_is_last(); test_reversal_race_remainder_crosses_zero_first_id(); + test_unbracketed_primary_reversal_keeps_full_qty(); + test_unbracketed_later_reversal_keeps_full_qty(); + test_partial_primary_bracket_keeps_full_qty(); + test_partial_later_bracket_keeps_full_qty(); test_same_direction_race_rejected(); test_poc_close_batch_then_sequential_entries(); test_single_entry_reversal_unchanged(); From 8524ccf7feecde53b870de6407a3d4758e5d5df6 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 15 Jul 2026 06:17:44 +0800 Subject: [PATCH 14/16] fix: honor frozen quantities for ANY exits (cherry picked from commit a7caa92aceea38644207df0eec6f3532349614e5) --- include/pineforge/engine.hpp | 7 +++-- src/engine_fills.cpp | 14 +++++++++- src/engine_orders.cpp | 27 ++++++++++++++----- tests/test_fills_edge.cpp | 51 ++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 551fe34..660899c 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -2056,6 +2056,9 @@ class BacktestEngine { void execute_partial_exit_qty(double fill_price, double qty_to_close); void execute_partial_exit(double fill_price, double qty_percent); void execute_partial_exit_by_entry(double fill_price, const std::string& from_entry); + void execute_partial_exit_by_entry_qty(double fill_price, + const std::string& from_entry, + double qty_to_close); void execute_partial_exit_by_entry_percent(double fill_price, const std::string& from_entry, double qty_percent); // KI-62: scratch (close dur-0) any same-bar same-id MARKET pyramid-add // slices still open after a from_entry priced bracket exit fills — TV's @@ -2228,8 +2231,8 @@ class BacktestEngine { // drains across all entries. Emits one close Trade per drained slice at // fill_price (already slippage-adjusted) and rebuilds pyramid_entries_ / // decrements position_qty_ by the amount drained. Returns the total qty - // drained. Shared by execute_partial_exit_qty and - // execute_partial_exit_by_entry_percent. + // drained. Shared by execute_partial_exit_qty and both entry-scoped + // partial-exit helpers. double fifo_drain(const std::string* from_entry, double qty_limit, double fill_price, bool was_long); void reset_position_state_to_flat(); diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 4fe1c5c..8dd63d0 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -2457,7 +2457,19 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric if (close_entries_rule_any_ && !order.from_entry.empty()) { // close_entries_rule="ANY": close only matching entries if (is_partial) { - execute_partial_exit_by_entry_percent(fill_price, order.from_entry, qp); + // A live-position strategy.exit freezes its percent-derived + // reservation into order.qty. Honor that absolute quantity after + // earlier same-bar siblings reduce the position; reapplying qp to + // the smaller live lot double-shrinks layered exits (Vimal's + // 40/30/30 TP stack). Only flat/deferred NaN reservations resolve + // their percentage at fill time. + if (has_explicit_qty_to_close) { + execute_partial_exit_by_entry_qty( + fill_price, order.from_entry, order.qty); + } else { + execute_partial_exit_by_entry_percent( + fill_price, order.from_entry, qp); + } } else { execute_partial_exit_by_entry(fill_price, order.from_entry); } diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index c5563c9..9286a60 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -256,16 +256,31 @@ void BacktestEngine::execute_partial_exit_by_entry(double fill_price, const std: } -// Internal helper: partially close entries matching from_entry by qty_percent. -void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price, - const std::string& from_entry, - double qty_percent) { +// Internal helper: close an exact quantity only from entries matching +// from_entry. Live-position strategy.exit calls freeze their percent-derived +// reservations into PendingOrder::qty; when layered siblings fill on one bar, +// that absolute reservation must survive earlier reductions of the position. +void BacktestEngine::execute_partial_exit_by_entry_qty( + double fill_price, const std::string& from_entry, double qty_to_close) { if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; + if (!std::isfinite(qty_to_close) || qty_to_close <= kQtyEpsilon) return; bool is_buy = (position_side_ == PositionSide::SHORT); fill_price = apply_fill_slippage(fill_price, is_buy); bool was_long = (position_side_ == PositionSide::LONG); + fifo_drain(&from_entry, qty_to_close, fill_price, was_long); + settle_position_after_partial_exit(); +} + + +// Internal helper: resolve a genuinely deferred percentage at fill time, then +// close that quantity only from entries matching from_entry. +void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price, + const std::string& from_entry, + double qty_percent) { + if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; + double matched_qty = 0.0; for (const auto& pe : pyramid_entries_) { if (pe.entry_id == from_entry) matched_qty += pe.qty; @@ -276,9 +291,7 @@ void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price, double qty_to_close = matched_qty * (pct / 100.0); if (qty_to_close <= kQtyEpsilon) return; - // Close FIFO, but only from entries matching from_entry. - fifo_drain(&from_entry, qty_to_close, fill_price, was_long); - settle_position_after_partial_exit(); + execute_partial_exit_by_entry_qty(fill_price, from_entry, qty_to_close); } diff --git a/tests/test_fills_edge.cpp b/tests/test_fills_edge.cpp index e63bc1d..e8dbcf8 100644 --- a/tests/test_fills_edge.cpp +++ b/tests/test_fills_edge.cpp @@ -338,6 +338,56 @@ static void test_partial_exit_by_entry_percent() { CHECK(near(p.signed_pos(), 3.0)); } +// A live-position strategy.exit freezes each percentage-derived reservation +// into PendingOrder::qty. Under close_entries_rule="ANY", a later sibling +// filling on the same bar must close that frozen absolute quantity from the +// matching entry id — it must not reapply qty_percent to the position already +// reduced by the earlier sibling (Vimal layered TP1/TP2/TP3 + residual TSL). +class LayeredPartialByEntryQty : public BacktestEngine { +public: + LayeredPartialByEntryQty() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 10.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + close_entries_rule_any_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 10.0, "long"); + if (position_side_ == PositionSide::LONG) { + strategy_exit("TP40", "L", /*limit=*/110.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/40.0, "tp40"); + strategy_exit("TP30", "L", /*limit=*/111.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/30.0, "tp30"); + } + } + double signed_pos() const { return signed_position_size(); } +}; + +static void test_layered_partial_by_entry_uses_frozen_qty() { + std::printf("test_layered_partial_by_entry_uses_frozen_qty\n"); + LayeredPartialByEntryQty p; + Bar bars[5] = { + {100, 100.5, 99.5, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, + {100, 112, 99, 100, 1000, kT0_UTC + 2 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 3 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + + CHECK(p.trade_count() == 2); + if (p.trade_count() == 2) { + CHECK(near(p.get_trade(0).qty, 4.0)); + CHECK(near(p.get_trade(0).exit_price, 110.0)); + CHECK(near(p.get_trade(1).qty, 3.0)); + CHECK(near(p.get_trade(1).exit_price, 111.0)); + } + CHECK(near(p.signed_pos(), 3.0)); +} + // ───────────────────────────────────────────────────────────────────── // 6. TV-pinned generic strategy.exit actionability. // @@ -777,6 +827,7 @@ int main() { test_two_sibling_exits_path_order(); test_cap_autoclose_at_bar_extreme_and_rollover(); test_partial_exit_by_entry_percent(); + test_layered_partial_by_entry_uses_frozen_qty(); test_no_actionable_exit_fresh_is_inert(); test_no_actionable_reissue_cancels_prior_exit(); test_inert_exit_does_not_bind_pending_entry(); From 3804475378141c02b0b25b1cb34795e96b35760c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 15 Jul 2026 07:17:27 +0800 Subject: [PATCH 15/16] fix: preserve two-call close replacement provenance (cherry picked from commit a34485963a3813b9cfb46876c4a560d5d2fbaaff) --- include/pineforge/engine.hpp | 21 ++- src/engine_orders.cpp | 2 + src/engine_run.cpp | 4 + src/engine_strategy_commands.cpp | 60 +++++++- tests/test_integration.cpp | 251 ++++++++++++++++++++++++++++++- 5 files changed, 324 insertions(+), 14 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 660899c..edc09b3 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -603,8 +603,13 @@ class BacktestEngine { // process_orders_on_close: every later close() call on the same bar // REPLACES the pending close order. Empirically (3commas grid-bot TV // exports, xau/xlm/pol/xrp — see fix/same-bar-multi-close-single-fill): - // - the FIRST replaced call's id-ledger is consumed silently (no - // fill, no trade rows); + // - the FIRST replaced call's id-ledger is provisionally consumed + // silently (no fill, no trade rows); + // - when both the prior and current batches contain exactly two calls, + // the prior batch's first admitted target is restored to that ledger. This is + // provenance from the broker replacement chain, not a physical-lot + // recount or ledger-minus-reservation calculation; + // - 3+ call batches never restore or create two-call provenance; // - intermediate replaced calls keep their ledgers intact; // - the SURVIVING (last nonzero-target) call fills min(ledger, // avail) at the bar close WITHOUT consuming its id-ledger; the @@ -620,11 +625,19 @@ class BacktestEngine { // full-position close still run at CALL time (unchanged timing). bool sb_close_active_ = false; int sb_close_bar_ = -1; - int sb_close_calls_ = 0; // nonzero-target close calls this bar - std::string sb_close_first_id_; // consumed when a 2nd call arrives + int sb_close_calls_ = 0; // effective distinct nonzero-target calls + std::string sb_close_first_id_; // first call, provisionally consumed at call 2 + double sb_close_first_target_ = 0.0; + bool sb_close_first_carry_valid_ = false; + double sb_close_first_carry_qty_ = 0.0; std::string sb_close_id_; // surviving order's id std::string sb_close_comment_; // surviving order's comment std::unordered_map close_reserved_qty_; + // Live exact-two-call replacement provenance. A survivor id maps to the + // prior batch's FIRST target and is valid only while its reservation is + // live. A later exact-two-call batch can restore precisely that slice if + // this id is its first replaced call (ENA's two-call replacement chain). + std::unordered_map close_two_call_first_qty_; // --- KI-64: POOC script-visible position freeze --- // Under process_orders_on_close, a strategy.close/close_all that fills diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 9286a60..f028faa 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -569,6 +569,7 @@ void BacktestEngine::reset_position_state_to_flat() { pyramid_entries_.clear(); id_unclosed_qty_.clear(); close_reserved_qty_.clear(); + close_two_call_first_qty_.clear(); consumed_partial_exit_ids_.clear(); } @@ -617,6 +618,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri pyramid_entries_.clear(); id_unclosed_qty_.clear(); close_reserved_qty_.clear(); + close_two_call_first_qty_.clear(); consumed_partial_exit_ids_.clear(); pyramid_entries_.push_back({fill_price, current_bar_.timestamp, qty, id, bar_index_}); id_unclosed_qty_[id] += qty; diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 69b8b1e..a582682 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -471,9 +471,13 @@ void BacktestEngine::reset_run_state() { sb_close_bar_ = -1; sb_close_calls_ = 0; sb_close_first_id_.clear(); + sb_close_first_target_ = 0.0; + sb_close_first_carry_valid_ = false; + sb_close_first_carry_qty_ = 0.0; sb_close_id_.clear(); sb_close_comment_.clear(); close_reserved_qty_.clear(); + close_two_call_first_qty_.clear(); fold_exit_path_extremes_ = false; fold_exit_trail_peak_ = std::numeric_limits::quiet_NaN(); last_exit_fill_was_trail_ = false; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 222e89c..749d141 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -637,6 +637,9 @@ void BacktestEngine::enqueue_same_bar_close(const std::string& id, sb_close_bar_ = bar_index_; sb_close_calls_ = 1; sb_close_first_id_ = id; + sb_close_first_target_ = target; + sb_close_first_carry_valid_ = false; + sb_close_first_carry_qty_ = 0.0; sb_close_id_ = id; sb_close_comment_ = comment; return; @@ -649,12 +652,30 @@ void BacktestEngine::enqueue_same_bar_close(const std::string& id, } sb_close_calls_ += 1; if (sb_close_calls_ == 2) { - // The FIRST replaced call's ledger is consumed silently: no fill, - // no trade rows, reservation released. Intermediate replaced - // calls (3rd+) keep their ledgers intact. + const auto reserved = close_reserved_qty_.find(sb_close_first_id_); + const auto provenance = + close_two_call_first_qty_.find(sb_close_first_id_); + if (reserved != close_reserved_qty_.end() + && provenance != close_two_call_first_qty_.end()) { + // Snapshot before releasing the old reservation. Restoration is + // deferred until flush proves the CURRENT batch also has exactly + // two calls; a later 3rd call invalidates this carry. + sb_close_first_carry_valid_ = true; + sb_close_first_carry_qty_ = provenance->second; + } + // Preserve the established provisional replacement rule immediately. + // Besides matching the broker lifecycle, this makes a later A,B,A + // source revisit observe A as zero-target while this batch is pending. id_unclosed_qty_.erase(sb_close_first_id_); close_reserved_qty_.erase(sb_close_first_id_); + close_two_call_first_qty_.erase(sb_close_first_id_); + } else if (sb_close_calls_ == 3) { + // The current batch is no longer exact-two. Keep the provisional + // ledger erase and discard the snapshotted two-call carry. + sb_close_first_carry_valid_ = false; + sb_close_first_carry_qty_ = 0.0; } + // Intermediate replaced calls (3rd+) keep their ledgers as before. sb_close_id_ = id; sb_close_comment_ = comment; } @@ -674,11 +695,19 @@ void BacktestEngine::flush_same_bar_close() { if (!sb_close_active_) return; const std::string id = sb_close_id_; const std::string comment = sb_close_comment_; - const bool sole_call = (sb_close_calls_ == 1); + const int batch_calls = sb_close_calls_; + const bool sole_call = (batch_calls == 1); + const std::string first_id = sb_close_first_id_; + const double first_target = sb_close_first_target_; + const bool first_carry_valid = sb_close_first_carry_valid_; + const double first_carry_qty = sb_close_first_carry_qty_; sb_close_active_ = false; sb_close_bar_ = -1; sb_close_calls_ = 0; sb_close_first_id_.clear(); + sb_close_first_target_ = 0.0; + sb_close_first_carry_valid_ = false; + sb_close_first_carry_qty_ = 0.0; sb_close_id_.clear(); sb_close_comment_.clear(); if (position_side_ == PositionSide::FLAT) return; @@ -714,6 +743,7 @@ void BacktestEngine::flush_same_bar_close() { id_unclosed_qty_.erase(it); } close_reserved_qty_.erase(id); + close_two_call_first_qty_.erase(id); } size_t trades_before = trades_.size(); @@ -744,19 +774,35 @@ void BacktestEngine::flush_same_bar_close() { trades_[ti].exit_comment = comment; trades_[ti].exit_id = "__close__" + id; } - if (position_side_ != side_before + const bool close_filled = position_side_ != side_before || std::abs(position_qty_ - qty_before) > eps - || trades_.size() != trades_before) { + || trades_.size() != trades_before; + if (close_filled) { ++broker_fill_event_seq_; if (coof_scheduler_active_ && coof_direct_fill_events_remaining_ > 0) { --coof_direct_fill_events_remaining_; } } - if (!sole_call && position_side_ != PositionSide::FLAT) { + if (!sole_call && close_filled + && position_side_ != PositionSide::FLAT) { // Surviving multi-call close: ledger NOT consumed (TV leaves the // id closable again later); the filled amount stays reserved // against the position for other ids' close targets. + if (batch_calls == 2 && first_carry_valid + && std::isfinite(first_carry_qty) && first_carry_qty > eps) { + // Exact two-call -> two-call replacement chain: restore the PRIOR + // batch's first admitted target, never ledger-reservation and + // never +=. ENA's L59 chain carries 0.0991 here; a 5->2 XAU chain + // has no provenance. Restore only after a real broker fill so a + // zero/deferred batch cannot resurrect a replaced ledger. + id_unclosed_qty_[first_id] = first_carry_qty; + } close_reserved_qty_[id] = target; + if (batch_calls == 2) { + close_two_call_first_qty_[id] = first_target; + } else { + close_two_call_first_qty_.erase(id); + } } } diff --git a/tests/test_integration.cpp b/tests/test_integration.cpp index b3a80e2..57f1ce4 100644 --- a/tests/test_integration.cpp +++ b/tests/test_integration.cpp @@ -2662,8 +2662,8 @@ static void test_position_reversal_state() { // calls on the SAME bar (the grid-bot pattern) collapse into a single // surviving fill — the LAST nonzero-target call wins, its id-ledger is NOT // consumed by the fill (the remainder stays closable by a later close of the -// same id), and the FIRST replaced call's ledger is consumed silently with -// no fill. Empirically derived from the 3commas grid-bot TradingView exports +// same id). The first replaced call's unreserved logical slot is consumed; +// intermediate replaced calls keep theirs. Empirically derived from 3commas // (xau/xlm/pol/xrp) — see fix/same-bar-multi-close-single-fill. class SameBarMultiCloseStrategy : public BacktestEngine { @@ -2683,7 +2683,7 @@ class SameBarMultiCloseStrategy : public BacktestEngine { if (bar_index_ == 0) strategy_entry("A", true, na, na, 1.0); if (bar_index_ == 1) strategy_entry("B", true, na, na, 2.0); if (bar_index_ == 2) { - strategy_close("A", "tpA"); // replaced: ledger consumed, no fill + strategy_close("A", "tpA"); // first replacement: ledger consumed strategy_close("B", "tpB"); // survivor: fills qty 2 (FIFO drain) } if (bar_index_ == 3) strategy_entry("B", true, na, na, 2.0); @@ -2726,6 +2726,248 @@ static void test_same_bar_multi_close_single_fill() { CHECK(near(strat.final_pos, 0.0)); } +class CloseReplacementProbeBase : public BacktestEngine { +public: + CloseReplacementProbeBase() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0; + slippage_ = 0; + pyramiding_ = 10; + process_orders_on_close_ = true; + } + double ledger(const std::string& id) const { + auto it = id_unclosed_qty_.find(id); + return it == id_unclosed_qty_.end() ? 0.0 : it->second; + } + double reservation(const std::string& id) const { + auto it = close_reserved_qty_.find(id); + return it == close_reserved_qty_.end() ? 0.0 : it->second; + } + double two_call_first_qty(const std::string& id) const { + auto it = close_two_call_first_qty_.find(id); + return it == close_two_call_first_qty_.end() ? 0.0 : it->second; + } +}; + +// ENA discriminator: only an exact-two-call batch may create provenance, and +// only a later exact-two-call batch may consume it. The carried quantity is +// the PRIOR batch's FIRST target (2), not B's ledger (6), reservation (3), or +// ledger-minus-reservation (3). +class TwoCallReplacementChainStrategy : public CloseReplacementProbeBase { +public: + double prior_res_b = -1.0; + double prior_first_b = -1.0; + double carried_ledger_b = -1.0; + double released_res_b = -1.0; + double released_first_b = -1.0; + double final_pos = -1.0; + void on_bar(const Bar&) override { + double na = std::numeric_limits::quiet_NaN(); + if (bar_index_ == 0) strategy_entry("A", true, na, na, 2.0); + if (bar_index_ == 1) strategy_entry("B", true, na, na, 3.0); + if (bar_index_ == 2) strategy_entry("C", true, na, na, 3.0); + if (bar_index_ == 3) { + strategy_close("A", "priorA"); + strategy_close("B", "priorB"); + } + if (bar_index_ == 4) { + prior_res_b = reservation("B"); + prior_first_b = two_call_first_qty("B"); + strategy_entry("B", true, na, na, 3.0); // ledger B: 3 -> 6 + } + if (bar_index_ == 5) { + strategy_close("B", "currentB"); + strategy_close("C", "currentC"); + } + if (bar_index_ == 6) { + carried_ledger_b = ledger("B"); + released_res_b = reservation("B"); + released_first_b = two_call_first_qty("B"); + strategy_entry("B", true, na, na, 3.0); // ledger B: 2 -> 5 + } + if (bar_index_ == 7) strategy_close("B", "finalB"); + if (bar_index_ == 8) final_pos = signed_position_size(); + } +}; + +static void test_exact_two_call_replacement_carries_prior_first_target() { + std::printf("test_exact_two_call_replacement_carries_prior_first_target\n"); + TwoCallReplacementChainStrategy strat; + Bar bars[] = { + {100, 101, 99, 100, 50, 60000}, + {100, 101, 99, 100, 50, 120000}, + {100, 101, 99, 100, 50, 180000}, + {100, 101, 99, 100, 50, 240000}, + {100, 101, 99, 100, 50, 300000}, + {100, 101, 99, 100, 50, 360000}, + {100, 101, 99, 100, 50, 420000}, + {100, 101, 99, 100, 50, 480000}, + {100, 101, 99, 100, 50, 540000}, + }; + strat.run(bars, 9); + + CHECK(near(strat.prior_res_b, 3.0)); + CHECK(near(strat.prior_first_b, 2.0)); + CHECK(near(strat.carried_ledger_b, 2.0)); + CHECK(near(strat.released_res_b, 0.0)); + CHECK(near(strat.released_first_b, 0.0)); + CHECK(near(strat.final_pos, 3.0)); + CHECK(strat.trade_count() == 6); + if (strat.trade_count() == 6) { + const double expected_qty[] = {2.0, 1.0, 2.0, 1.0, 2.0, 3.0}; + for (int i = 0; i < 6; ++i) { + CHECK(near(strat.get_trade(i).qty, expected_qty[i])); + } + } +} + +// XAU L37 control: a 3+ call batch creates a reservation but no two-call +// provenance, so a later two-call replacement keeps the legacy full erase. +// The subsequent sole close also clears the survivor's reservation/provenance, +// and close_all exercises the flat-position reset. +class ThreeToTwoReplacementStrategy : public CloseReplacementProbeBase { +public: + double prior_res_b = -1.0; + double prior_first_b = -1.0; + double ledger_b = -1.0; + double res_b = -1.0; + double first_b = -1.0; + double res_c_after_sole = -1.0; + double first_c_after_sole = -1.0; + double final_pos = -1.0; + size_t final_reservations = 99; + size_t final_provenance = 99; + void on_bar(const Bar&) override { + double na = std::numeric_limits::quiet_NaN(); + if (bar_index_ == 0) strategy_entry("A", true, na, na, 1.0); + if (bar_index_ == 1) strategy_entry("X", true, na, na, 1.0); + if (bar_index_ == 2) strategy_entry("B", true, na, na, 1.0); + if (bar_index_ == 3) strategy_entry("C", true, na, na, 1.0); + if (bar_index_ == 4) { + strategy_close("A", "priorA"); + strategy_close("X", "priorX"); + strategy_close("B", "priorB"); + } + if (bar_index_ == 5) { + prior_res_b = reservation("B"); + prior_first_b = two_call_first_qty("B"); + strategy_close("B", "currentB"); + strategy_close("C", "currentC"); + } + if (bar_index_ == 6) { + ledger_b = ledger("B"); + res_b = reservation("B"); + first_b = two_call_first_qty("B"); + strategy_close("C", "soleC"); + } + if (bar_index_ == 7) { + res_c_after_sole = reservation("C"); + first_c_after_sole = two_call_first_qty("C"); + strategy_close_all(); + } + if (bar_index_ == 8) { + final_pos = signed_position_size(); + final_reservations = close_reserved_qty_.size(); + final_provenance = close_two_call_first_qty_.size(); + } + } +}; + +static void test_three_call_batch_does_not_create_two_call_provenance() { + std::printf("test_three_call_batch_does_not_create_two_call_provenance\n"); + ThreeToTwoReplacementStrategy strat; + Bar bars[] = { + {100, 101, 99, 100, 50, 60000}, + {100, 101, 99, 100, 50, 120000}, + {100, 101, 99, 100, 50, 180000}, + {100, 101, 99, 100, 50, 240000}, + {100, 101, 99, 100, 50, 300000}, + {100, 101, 99, 100, 50, 360000}, + {100, 101, 99, 100, 50, 420000}, + {100, 101, 99, 100, 50, 480000}, + {100, 101, 99, 100, 50, 540000}, + }; + strat.run(bars, 9); + + CHECK(near(strat.prior_res_b, 1.0)); + CHECK(near(strat.prior_first_b, 0.0)); + CHECK(near(strat.ledger_b, 0.0)); + CHECK(near(strat.res_b, 0.0)); + CHECK(near(strat.first_b, 0.0)); + CHECK(near(strat.res_c_after_sole, 0.0)); + CHECK(near(strat.first_c_after_sole, 0.0)); + CHECK(near(strat.final_pos, 0.0)); + CHECK(strat.final_reservations == 0); + CHECK(strat.final_provenance == 0); +} + +// A prior exact-two batch does create provenance, but a third call in the +// current batch invalidates its provisional carry. Intermediate ledgers remain +// intact and a 3+ survivor must not create new two-call provenance. +class TwoToThreeReplacementStrategy : public CloseReplacementProbeBase { +public: + double prior_res_b = -1.0; + double prior_first_b = -1.0; + double ledger_b = -1.0; + double ledger_c = -1.0; + double ledger_d = -1.0; + double res_b = -1.0; + double res_d = -1.0; + double first_d = -1.0; + void on_bar(const Bar&) override { + double na = std::numeric_limits::quiet_NaN(); + if (bar_index_ == 0) strategy_entry("A", true, na, na, 1.0); + if (bar_index_ == 1) strategy_entry("B", true, na, na, 1.0); + if (bar_index_ == 2) strategy_entry("C", true, na, na, 1.0); + if (bar_index_ == 3) strategy_entry("D", true, na, na, 1.0); + if (bar_index_ == 4) { + strategy_close("A", "priorA"); + strategy_close("B", "priorB"); + } + if (bar_index_ == 5) { + prior_res_b = reservation("B"); + prior_first_b = two_call_first_qty("B"); + strategy_close("B", "currentB"); + strategy_close("C", "currentC"); + strategy_close("D", "currentD"); + } + if (bar_index_ == 6) { + ledger_b = ledger("B"); + ledger_c = ledger("C"); + ledger_d = ledger("D"); + res_b = reservation("B"); + res_d = reservation("D"); + first_d = two_call_first_qty("D"); + } + } +}; + +static void test_three_call_current_batch_invalidates_two_call_carry() { + std::printf("test_three_call_current_batch_invalidates_two_call_carry\n"); + TwoToThreeReplacementStrategy strat; + Bar bars[] = { + {100, 101, 99, 100, 50, 60000}, + {100, 101, 99, 100, 50, 120000}, + {100, 101, 99, 100, 50, 180000}, + {100, 101, 99, 100, 50, 240000}, + {100, 101, 99, 100, 50, 300000}, + {100, 101, 99, 100, 50, 360000}, + {100, 101, 99, 100, 50, 420000}, + }; + strat.run(bars, 7); + + CHECK(near(strat.prior_res_b, 1.0)); + CHECK(near(strat.prior_first_b, 1.0)); + CHECK(near(strat.ledger_b, 0.0)); + CHECK(near(strat.ledger_c, 1.0)); + CHECK(near(strat.ledger_d, 1.0)); + CHECK(near(strat.res_b, 0.0)); + CHECK(near(strat.res_d, 1.0)); + CHECK(near(strat.first_d, 0.0)); +} + // ---- 38b. process_orders_on_close: an exit gated on position visibility must // NOT fire on the entry bar. TradingView does not expose a just-placed market // entry through strategy.position_size until the next bar, so a regime/bias @@ -4465,6 +4707,9 @@ int main() { test_win_loss_tracking(); test_position_reversal_state(); test_same_bar_multi_close_single_fill(); + test_exact_two_call_replacement_carries_prior_first_target(); + test_three_call_batch_does_not_create_two_call_provenance(); + test_three_call_current_batch_invalidates_two_call_carry(); test_pooc_exit_not_triggered_on_entry_bar(); test_commission_deducted(); test_slippage_applied(); From 973c8fa3b1497fddc8e09b19415e5bf9da15e60f Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 15 Jul 2026 08:09:35 +0800 Subject: [PATCH 16/16] fix: keep replacement reservations physically backed (cherry picked from commit a50849d7839f0d2518b4eca8ca2c496287886eed) --- include/pineforge/engine.hpp | 39 +++++---- src/engine_fills.cpp | 44 +++++----- src/engine_strategy_commands.cpp | 38 ++++++-- tests/test_integration.cpp | 143 +++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 44 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index edc09b3..d4acbf5 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -612,13 +612,20 @@ class BacktestEngine { // - 3+ call batches never restore or create two-call provenance; // - intermediate replaced calls keep their ledgers intact; // - the SURVIVING (last nonzero-target) call fills min(ledger, - // avail) at the bar close WITHOUT consuming its id-ledger; the - // unconsumed amount stays reserved against the position - // (close_reserved_qty_) and caps other ids' later close targets - // via avail = position_qty_ - sum(reservations of other ids); + // avail) at the bar close. Its new reservation is capped to the + // post-fill position capacity left after older reservations and caps + // other ids' later targets via avail = position_qty_ - + // sum(reservations of other ids). A positive truncated reservation + // keeps the established survivor ledger; zero backing clears it; + // - exact-two provenance is created only when that survivor's actual + // fill remains fully backed after the fill; + // - a nonzero logical ledger with zero unreserved physical capacity is + // consumed without a broker fill, so a later same-id entry starts a + // new logical close cycle instead of accumulating a stale slice; // - a bar with exactly one close call behaves as before: fill = // min(ledger, avail), ledger consumed, reservation released. - // Calls whose target resolves to zero are no-ops (cannot survive). + // Calls whose target resolves to zero cannot survive; a logically live + // but physically blocked id still consumes its stale logical cycle. // The batched fill executes at the end-of-bar order-processing point // (dispatch_bar step 4 / magnifier last tick) at the same bar-close // price the immediate path used. Order cancels/purges tied to a @@ -2094,8 +2101,6 @@ class BacktestEngine { bool pending_flat_market_pair_scope_is_live() const; void finalize_pending_flat_market_pairs(const Bar& bar); void sort_orders_by_fill_phase(const Bar& bar); - bool pending_flat_market_pair_is_live(const PendingOrder& order) const; - void invalidate_pending_flat_market_pair(int64_t created_seq); // TradingView binds a valid, single/full, non-trailing strategy.exit to a // co-queued high-level MARKET parent. If that parent fills at the next open // and its stop is already breached, the newborn lot scratches at that open. @@ -2104,6 +2109,8 @@ class BacktestEngine { // multi-child groups. bool prearmed_market_parent_stop_gaps_at_open( const PendingOrder& order, const Bar& bar) const; + bool pending_flat_market_pair_is_live(const PendingOrder& order) const; + void invalidate_pending_flat_market_pair(int64_t created_seq); void compact_filled_pending_orders(const std::vector& filled_indices, int exit_closed_from_bar, uint64_t exit_closed_from_incarnation, @@ -2494,14 +2501,6 @@ class BacktestEngine { security_range_start_ms_ = 0; } } - // Historical batch-only lookahead projection. This is intentionally a - // default-off verifier candidate: regular execution and every stream - // path keep progressive HTF aggregation unless a caller explicitly - // supplies a positive finite metadata value. - if (key == "historical_security_lookahead_projection") { - historical_security_lookahead_projection_ = - std::isfinite(value) && value > 0.0; - } // Opt-in KI-55 chart-timeframe EMA warmup parity. This is a boolean // run configuration carried through the existing metadata channel: // positive finite values enable it; 0 / NaN / negative disable it. @@ -2510,8 +2509,16 @@ class BacktestEngine { if (key == "chart_ema_na_warmup") { chart_ema_na_warmup_ = std::isfinite(value) && value > 0.0; } + // Historical batch-only lookahead projection. This is intentionally a + // default-off verifier candidate: regular execution and every stream + // path keep progressive HTF aggregation unless a caller explicitly + // supplies a positive finite metadata value. + if (key == "historical_security_lookahead_projection") { + historical_security_lookahead_projection_ = + std::isfinite(value) && value > 0.0; + } // Default-off verifier candidate for a finite-price margin call whose - // lot-quantized restore quantity is zero. Positive finite values close + // lot-quantized restore quantity is zero. Positive finite values close // the residual; absent/zero/non-finite values preserve the established // one-step progress fallback. if (key == "margin_zero_cover_full_liquidation") { diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 8dd63d0..0f0ece1 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1057,28 +1057,6 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { }); } -bool BacktestEngine::pending_flat_market_pair_is_live( - const PendingOrder& order) const { - if (!pending_flat_market_pair_scope_is_live() - || order.type != OrderType::MARKET - || order.paired_flat_market_peer_seq <= 0 - || !std::isfinite(order.paired_flat_market_transaction_qty)) { - return false; - } - for (const PendingOrder& peer : pending_orders_) { - if (peer.created_seq != order.paired_flat_market_peer_seq) continue; - return peer.type == OrderType::MARKET - && peer.paired_flat_market_peer_seq == order.created_seq - && std::isfinite(peer.paired_flat_market_transaction_qty) - && peer.id != order.id - && peer.is_long != order.is_long - && peer.created_bar == order.created_bar - && peer.created_position_side == PositionSide::FLAT - && order.created_position_side == PositionSide::FLAT; - } - return false; -} - // A strategy.exit can be armed on the signal bar together with the MARKET // strategy.entry named by from_entry. The child is valid before the parent // fills: TradingView binds it to the eventual lot, and if the next open has @@ -1171,6 +1149,28 @@ bool BacktestEngine::prearmed_market_parent_stop_gaps_at_open( return false; } +bool BacktestEngine::pending_flat_market_pair_is_live( + const PendingOrder& order) const { + if (!pending_flat_market_pair_scope_is_live() + || order.type != OrderType::MARKET + || order.paired_flat_market_peer_seq <= 0 + || !std::isfinite(order.paired_flat_market_transaction_qty)) { + return false; + } + for (const PendingOrder& peer : pending_orders_) { + if (peer.created_seq != order.paired_flat_market_peer_seq) continue; + return peer.type == OrderType::MARKET + && peer.paired_flat_market_peer_seq == order.created_seq + && std::isfinite(peer.paired_flat_market_transaction_qty) + && peer.id != order.id + && peer.is_long != order.is_long + && peer.created_bar == order.created_bar + && peer.created_position_side == PositionSide::FLAT + && order.created_position_side == PositionSide::FLAT; + } + return false; +} + void BacktestEngine::invalidate_pending_flat_market_pair(int64_t created_seq) { if (created_seq <= 0) return; for (PendingOrder& order : pending_orders_) { diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 749d141..272465b 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -618,7 +618,17 @@ void BacktestEngine::enqueue_same_bar_close(const std::string& id, double avail = std::max(0.0, position_qty_ - close_reserved_other_qty(id)); double target = std::min(unclosed, avail); if (target <= eps) { - return; // zero-target call: no-op, cannot become the survivor + if (unclosed > eps && avail <= eps) { + // The logical slot was asked to close, but earlier surviving + // multi-close orders already reserve every physically available + // unit. TV emits no broker fill here, yet the close command still + // consumes this logical cycle: a later same-id entry must not be + // added to the stale ledger and double-close on its next signal. + id_unclosed_qty_.erase(id); + close_reserved_qty_.erase(id); + close_two_call_first_qty_.erase(id); + } + return; // no order/fill; a zero-target call cannot survive } // Same-bar source-order carry (see strategy_entry's tv_carry_qty): a @@ -785,9 +795,10 @@ void BacktestEngine::flush_same_bar_close() { } if (!sole_call && close_filled && position_side_ != PositionSide::FLAT) { - // Surviving multi-call close: ledger NOT consumed (TV leaves the - // id closable again later); the filled amount stays reserved - // against the position for other ids' close targets. + // A surviving multi-call close normally keeps its established ledger + // (TV leaves the id closable again later). The post-fill reservation + // below determines whether any physical backing remains; zero backing + // clears that ledger, while a positive truncated reservation keeps it. if (batch_calls == 2 && first_carry_valid && std::isfinite(first_carry_qty) && first_carry_qty > eps) { // Exact two-call -> two-call replacement chain: restore the PRIOR @@ -797,8 +808,23 @@ void BacktestEngine::flush_same_bar_close() { // zero/deferred batch cannot resurrect a replaced ledger. id_unclosed_qty_[first_id] = first_carry_qty; } - close_reserved_qty_[id] = target; - if (batch_calls == 2) { + // A reservation is bounded by physical capacity AFTER this fill. + // Existing reservations for other ids have first claim on the + // remaining position; blindly reserving the just-filled target can + // make total reservations exceed position_qty_ and suppress valid + // closes after the next entry. Preserve only the capacity left after + // those older claims (ETH grid-bot replacement chain). + const double actual_fill = std::max(0.0, qty_before - position_qty_); + const double reserve_capacity = + std::max(0.0, position_qty_ - close_reserved_other_qty(id)); + const double reserve = std::min(actual_fill, reserve_capacity); + if (reserve > eps) { + close_reserved_qty_[id] = reserve; + } else { + id_unclosed_qty_.erase(id); + close_reserved_qty_.erase(id); + } + if (batch_calls == 2 && reserve >= actual_fill - eps) { close_two_call_first_qty_[id] = first_target; } else { close_two_call_first_qty_.erase(id); diff --git a/tests/test_integration.cpp b/tests/test_integration.cpp index 57f1ce4..96a8b93 100644 --- a/tests/test_integration.cpp +++ b/tests/test_integration.cpp @@ -2968,6 +2968,147 @@ static void test_three_call_current_batch_invalidates_two_call_carry() { CHECK(near(strat.first_d, 0.0)); } +// A surviving multi-close may fill the entire position slice not already +// claimed by older reservations. In that case no physical backing remains for +// the survivor: its ledger/reservation/provenance must all clear. A later +// zero-available close also consumes its stale logical cycle, so a same-id +// re-entry starts fresh instead of accumulating an unfillable prior slice. +class ZeroBackedCloseReservationStrategy : public CloseReplacementProbeBase { +public: + double post_pos = -1.0; + double post_ledger_b = -1.0; + double post_res_b = -1.0; + double post_first_b = -1.0; + double post_total_res = -1.0; + double blocked_ledger_c = -1.0; + double reentry_ledger_c = -1.0; + double final_ledger_c = -1.0; + double final_pos = -1.0; + + double total_reservations() const { + double total = 0.0; + for (const auto& kv : close_reserved_qty_) total += kv.second; + return total; + } + + void on_bar(const Bar&) override { + double na = std::numeric_limits::quiet_NaN(); + if (bar_index_ == 0) strategy_entry("seed", true, na, na, 5.0); + if (bar_index_ == 1) { + // Model a FIFO history where logical ids overlap the five live + // physical units: R already owns two reserved units, while B's + // surviving close can fill the remaining three exactly. + id_unclosed_qty_.clear(); + close_reserved_qty_.clear(); + close_two_call_first_qty_.clear(); + id_unclosed_qty_["A"] = 1.0; + id_unclosed_qty_["B"] = 3.0; + id_unclosed_qty_["R"] = 2.0; + close_reserved_qty_["R"] = 2.0; + strategy_close("A", "first"); + strategy_close("B", "survivor"); + } + if (bar_index_ == 2) { + post_pos = signed_position_size(); + post_ledger_b = ledger("B"); + post_res_b = reservation("B"); + post_first_b = two_call_first_qty("B"); + post_total_res = total_reservations(); + + id_unclosed_qty_["C"] = 1.0; + strategy_close("C", "blocked"); // no unreserved physical qty + blocked_ledger_c = ledger("C"); + strategy_entry("C", true, na, na, 1.0); + } + if (bar_index_ == 3) { + reentry_ledger_c = ledger("C"); + strategy_close("C", "fresh-cycle"); + } + if (bar_index_ == 4) { + final_ledger_c = ledger("C"); + final_pos = signed_position_size(); + } + } +}; + +static void test_zero_backed_close_reservation_clears_stale_cycle() { + std::printf("test_zero_backed_close_reservation_clears_stale_cycle\n"); + ZeroBackedCloseReservationStrategy strat; + Bar bars[] = { + {100, 101, 99, 100, 50, 60000}, + {100, 101, 99, 100, 50, 120000}, + {100, 101, 99, 100, 50, 180000}, + {100, 101, 99, 100, 50, 240000}, + {100, 101, 99, 100, 50, 300000}, + }; + strat.run(bars, 5); + + CHECK(near(strat.post_pos, 2.0)); + CHECK(near(strat.post_ledger_b, 0.0)); + CHECK(near(strat.post_res_b, 0.0)); + CHECK(near(strat.post_first_b, 0.0)); + CHECK(near(strat.post_total_res, 2.0)); + CHECK(near(strat.blocked_ledger_c, 0.0)); + CHECK(near(strat.reentry_ledger_c, 1.0)); + CHECK(near(strat.final_ledger_c, 0.0)); + CHECK(near(strat.final_pos, 2.0)); + CHECK(strat.trade_count() == 2); +} + +// ENA control: a positive truncated reservation is still a live replacement +// chain. Keep the survivor's established logical ledger, but clamp the new +// physical reservation and drop exact-two provenance because the fill is not +// fully backed. This is intentionally distinct from the zero-backed ETH case. +class PositiveTruncatedCloseReservationStrategy : public CloseReplacementProbeBase { +public: + double final_pos = -1.0; + double ledger_b = -1.0; + double res_b = -1.0; + double first_b = -1.0; + double total_res = -1.0; + + void on_bar(const Bar&) override { + double na = std::numeric_limits::quiet_NaN(); + if (bar_index_ == 0) strategy_entry("seed", true, na, na, 6.0); + if (bar_index_ == 1) { + id_unclosed_qty_.clear(); + close_reserved_qty_.clear(); + close_two_call_first_qty_.clear(); + id_unclosed_qty_["A"] = 1.0; + id_unclosed_qty_["B"] = 4.0; + id_unclosed_qty_["R"] = 1.0; + close_reserved_qty_["R"] = 1.0; + strategy_close("A", "first"); + strategy_close("B", "survivor"); + } + if (bar_index_ == 2) { + final_pos = signed_position_size(); + ledger_b = ledger("B"); + res_b = reservation("B"); + first_b = two_call_first_qty("B"); + total_res = 0.0; + for (const auto& kv : close_reserved_qty_) total_res += kv.second; + } + } +}; + +static void test_positive_truncated_close_reservation_keeps_ledger_only() { + std::printf("test_positive_truncated_close_reservation_keeps_ledger_only\n"); + PositiveTruncatedCloseReservationStrategy strat; + Bar bars[] = { + {100, 101, 99, 100, 50, 60000}, + {100, 101, 99, 100, 50, 120000}, + {100, 101, 99, 100, 50, 180000}, + }; + strat.run(bars, 3); + + CHECK(near(strat.final_pos, 2.0)); + CHECK(near(strat.ledger_b, 4.0)); + CHECK(near(strat.res_b, 1.0)); + CHECK(near(strat.first_b, 0.0)); + CHECK(near(strat.total_res, 2.0)); +} + // ---- 38b. process_orders_on_close: an exit gated on position visibility must // NOT fire on the entry bar. TradingView does not expose a just-placed market // entry through strategy.position_size until the next bar, so a regime/bias @@ -4710,6 +4851,8 @@ int main() { test_exact_two_call_replacement_carries_prior_first_target(); test_three_call_batch_does_not_create_two_call_provenance(); test_three_call_current_batch_invalidates_two_call_carry(); + test_zero_backed_close_reservation_clears_stale_cycle(); + test_positive_truncated_close_reservation_keeps_ledger_only(); test_pooc_exit_not_triggered_on_entry_bar(); test_commission_deducted(); test_slippage_applied();