From e46e5e26bb6fdc025ceceb02df74c853abfaca1c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 15 Jul 2026 22:39:46 +0800 Subject: [PATCH 1/2] fix: preserve same-id stops across deferred close all --- include/pineforge/engine.hpp | 39 +- src/engine_fills.cpp | 114 ++++-- src/engine_run.cpp | 20 +- src/engine_strategy_commands.cpp | 75 +++- tests/test_close_all_coqueued_entry.cpp | 504 +++++++++++++++++++++++- 5 files changed, 698 insertions(+), 54 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 31d816a..c6d5da9 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -182,6 +182,10 @@ struct PendingOrder { int oca_type; // 0=none, 1=cancel, 2=reduce int created_bar; // bar_index when order was created int64_t created_seq = 0; + // Fresh identity for this exact pending-order object. Unlike created_seq, + // which intentionally survives same-id replacement to keep broker ordering + // stable, incarnation is never copied or reused by a replacement. + uint64_t incarnation = 0; // Entry stop-limit activation is durable broker state. Once the stop leg // fires, later bars—and later COOF scheduler segments on the same bar— // evaluate only the live limit leg until the order fills or is replaced. @@ -254,6 +258,15 @@ struct PendingOrder { // gate ran. See classify_order_eligibility / compact_filled_pending_orders // and test_close_all_coqueued_entry.cpp. bool over_pyramiding_cap_at_placement = false; + // Call-bar provenance for the one deferred close_all whose post-fill + // cleanup may preserve this order. Set only on a PRIOR-bar, pure-STOP + // strategy.entry that was under the pyramiding cap and reused the id of a + // physically-live same-side pyramid lot when close_all was called. The + // cleanup requires both this value AND the paired incarnation below to + // equal the ACTUAL order that flattened, so a same-call-bar earlier RAW or + // close(id) fill cannot impersonate close_all. -1/0 means no provenance. + int same_id_stop_deferred_close_all_bar = -1; + uint64_t same_id_stop_deferred_close_all_incarnation = 0; // KI-65 dual same-bar opposite entry (probe pf-probe-ki65-dual-entry- // precedence): true when this priced (stop/limit) ENTRY was placed from // FLAT and an EARLIER same-on_bar OPPOSITE-direction MARKET entry is @@ -789,6 +802,7 @@ class BacktestEngine { bool historical_security_lookahead_projection_ = false; bool historical_security_lookahead_projection_active_ = false; int64_t next_order_seq_ = 1; + uint64_t next_order_incarnation_ = 1; // TV: at most one priced ENTRY "open" event per bar; persists across // multiple process_pending_orders calls (bar magnifier) and dual-pass // opposing-stop resolution (see engine_fills.cpp). @@ -989,6 +1003,7 @@ class BacktestEngine { CoofFillResult process_next_pending_order(const Bar& bar, bool allow_market_orders, int& exit_closed_from_bar, + uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long); // TradingView forced-liquidation (margin call). Finite-price liquidation @@ -2015,6 +2030,7 @@ class BacktestEngine { 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, bool exit_closed_was_long); // Apply a fill to engine state: dispatches by order.type to the // per-type apply_*_order_fill helpers below, plus runs the risk @@ -2027,6 +2043,7 @@ class BacktestEngine { const Bar& bar, double& trail_best_path_state, int& exit_closed_from_bar, + uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long, std::vector& filled_indices); // design-declined-reversal-close-leg: called at the KI-54 reversal-decline @@ -2047,6 +2064,7 @@ class BacktestEngine { double& trail_best_path_state); void apply_exit_order_fill(PendingOrder& order, double fill_price, int& exit_closed_from_bar, + uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long); // Freeze the reserved qty of LAYERED strategy.exit legs (a qty_percent<100 // partial + a sibling default/100% leg) that were armed while the position @@ -2061,6 +2079,7 @@ class BacktestEngine { void apply_raw_order_fill(PendingOrder& order, double fill_price, double& trail_best_path_state, int& exit_closed_from_bar, + uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long); void materialize_relative_exit_prices_for_live_position(); @@ -2075,7 +2094,8 @@ class BacktestEngine { PendingOrder& order, int opposing_pass, internal::DualEntryStopPathWinner dual_entry_path, const std::unordered_set& pass0_opposing_skip_ids, - int exit_closed_from_bar, bool exit_closed_was_long, const Bar& bar); + int exit_closed_from_bar, uint64_t exit_closed_from_incarnation, + bool exit_closed_was_long, const Bar& bar); struct FillEvaluation { enum class Kind { Fill, NoFill, DeferredToOpposingPass }; Kind kind; @@ -2114,14 +2134,15 @@ class BacktestEngine { bool closes_full_position, bool closes_fifo_qty, bool closes_any_qty); - void queue_deferred_close_order(const std::string& id, - const std::string& comment, - double qty_to_close, - double matching_qty, - bool closes_full_position, - bool closes_any_qty, - double consumed_ledger_qty = - std::numeric_limits::quiet_NaN()); + uint64_t queue_deferred_close_order( + const std::string& id, + const std::string& comment, + double qty_to_close, + double matching_qty, + bool closes_full_position, + bool closes_any_qty, + double consumed_ledger_qty = + std::numeric_limits::quiet_NaN()); void clear_existing_exit_order(const std::string& id, const std::string& from_entry, bool has_trail_request, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index b1fabc0..502a5e9 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -13,6 +13,38 @@ namespace pineforge { using namespace internal; +namespace { + +// Both post-full-close cleanup sites must use this exact predicate. The +// physical same-id fact is snapshotted when deferred close_all is called, +// because the filling close drains pyramid_entries_ before cleanup runs. +bool preserves_same_id_stop_across_deferred_close_all( + const PendingOrder& order, + int exit_closed_from_bar, + uint64_t exit_closed_from_incarnation, + bool exit_closed_was_long) { + const PositionSide closed_side = + exit_closed_was_long ? PositionSide::LONG : PositionSide::SHORT; + return exit_closed_from_bar >= 0 + && order.same_id_stop_deferred_close_all_bar == exit_closed_from_bar + && exit_closed_from_incarnation > 0 + && order.same_id_stop_deferred_close_all_incarnation + == exit_closed_from_incarnation + && order.type == OrderType::ENTRY + && order.created_bar < exit_closed_from_bar + && order.is_long == exit_closed_was_long + && order.created_position_side == closed_side + && !order.over_pyramiding_cap_at_placement + && std::isfinite(order.stop_price) + && std::isnan(order.limit_price) + && std::isnan(order.trail_points) + && std::isnan(order.trail_price) + && std::isnan(order.trail_offset) + && !order.stop_limit_activated; +} + +} // namespace + // strategy_entry / strategy_close / strategy_close_all / strategy_exit // moved to engine_strategy_commands.cpp. @@ -34,6 +66,7 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { } int exit_closed_from_bar = -1; // created_bar of the last full-close exit + uint64_t exit_closed_from_incarnation = 0; bool exit_closed_was_long = false; // direction of the closed position // Reusable member scratchpad (capacity persists across calls; avoids a @@ -53,16 +86,17 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { if (opposing_pass == 1 && pass0_opposing_skip_ids.empty()) break; std::vector& filled_indices = scratch_filled_indices_; filled_indices.clear(); - // TV cancels pending SAME-DIRECTION entries placed on a prior on_bar when - // a full strategy.exit closes the position on this bar. Opposite-direction - // (reversal) entries still fire. Entries placed on the SAME on_bar as the - // fired exit also still fire (close + entry reversal placed together). + // TV generally cancels stale SAME-DIRECTION entries after a full exit. + // Opposite entries, same-call-bar under-cap co-queues, resting pure LIMITs, + // and the physically-live same-ID pure-STOP close_all cell are the narrow + // independently-proven exceptions below. for (size_t i = 0; i < pending_orders_.size(); i++) { PendingOrder& order = pending_orders_[i]; auto eligibility = classify_order_eligibility( order, opposing_pass, dual_entry_path_, pass0_opposing_skip_ids, - exit_closed_from_bar, exit_closed_was_long, bar); + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long, bar); if (eligibility == OrderEligibility::Remove) { invalidate_pending_flat_market_pair(order.created_seq); filled_indices.push_back(i); @@ -82,11 +116,14 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { apply_filled_order_to_state( order, i, fill.fill_price, fill.is_limit_fill, bar, trail_best_path_state, - exit_closed_from_bar, exit_closed_was_long, + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long, filled_indices); materialize_relative_exit_prices_for_live_position(); } - compact_filled_pending_orders(filled_indices, exit_closed_from_bar, exit_closed_was_long); + compact_filled_pending_orders( + filled_indices, exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long); } // opposing_pass // If position is flat after processing, purge remaining exit orders — but @@ -107,6 +144,7 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( const Bar& bar, bool allow_market_orders, int& exit_closed_from_bar, + uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long) { CoofFillResult result; @@ -177,7 +215,8 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( PendingOrder& order = pending_orders_[i]; auto eligibility = classify_order_eligibility( order, opposing_pass, dual_entry_path, pass0_opposing_skip_ids, - exit_closed_from_bar, exit_closed_was_long, bar); + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long, bar); if (eligibility == OrderEligibility::Remove) { invalidate_pending_flat_market_pair(order.created_seq); filled_indices.push_back(i); @@ -287,7 +326,8 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( order, candidate.order_index, candidate.fill.fill_price, candidate.fill.is_limit_fill, bar, trail_best_path_state, exit_closed_from_bar, - exit_closed_was_long, filled_indices); + exit_closed_from_incarnation, exit_closed_was_long, + filled_indices); materialize_relative_exit_prices_for_live_position(); const uint64_t produced = broker_fill_event_seq_ - events_before; @@ -300,7 +340,9 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( std::unique(filled_indices.begin(), filled_indices.end()), filled_indices.end()); compact_filled_pending_orders( - filled_indices, exit_closed_from_bar, exit_closed_was_long); + filled_indices, exit_closed_from_bar, + exit_closed_from_incarnation, + exit_closed_was_long); if (side_before_fill == PositionSide::FLAT && position_side_ != PositionSide::FLAT) { // The old cycle's same-direction cleanup has already swept @@ -308,6 +350,7 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( // Orders born in its subsequent recalcs belong to the new // position cycle and must not inherit the old close marker. exit_closed_from_bar = -1; + exit_closed_from_incarnation = 0; } if (position_side_ == PositionSide::FLAT) { purge_exit_orders(/*retain_for_pending_entries=*/true); @@ -323,7 +366,9 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( std::unique(filled_indices.begin(), filled_indices.end()), filled_indices.end()); compact_filled_pending_orders( - filled_indices, exit_closed_from_bar, exit_closed_was_long); + filled_indices, exit_closed_from_bar, + exit_closed_from_incarnation, + exit_closed_was_long); } // No fill consumed this segment, so the broker reached its endpoint and @@ -1011,6 +1056,7 @@ void BacktestEngine::invalidate_pending_flat_market_pair(int64_t created_seq) { void BacktestEngine::compact_filled_pending_orders( const std::vector& filled_indices, int exit_closed_from_bar, + uint64_t exit_closed_from_incarnation, bool exit_closed_was_long) { if (filled_indices.empty()) return; // filled_indices is built by push_back(i) with i strictly increasing over @@ -1039,13 +1085,20 @@ void BacktestEngine::compact_filled_pending_orders( // reaches compaction without ever entering filled_indices, so without // this term it would be wiped here even though classify spared it (the // reverted M1 hit exactly this — R-KEEP-stop failed under a classify-only - // fix). Over-cap co-queues and prior-bar carries are still compacted away. + // fix). Over-cap co-queues and ordinary different-ID prior-bar carries + // are still compacted away; the shared helper below owns the one proven + // prior-bar same-ID pure-STOP close_all exception. bool coqueued_within_cap = pending_orders_[read].created_bar == exit_closed_from_bar && !pending_orders_[read].over_pyramiding_cap_at_placement; + bool same_id_stop_preserved_by_deferred_close_all = + preserves_same_id_stop_across_deferred_close_all( + pending_orders_[read], exit_closed_from_bar, + exit_closed_from_incarnation, exit_closed_was_long); bool stale_same_direction_entry_after_exit = exit_closed_from_bar >= 0 && !coqueued_within_cap + && !same_id_stop_preserved_by_deferred_close_all && (pending_orders_[read].type == OrderType::ENTRY || pending_orders_[read].type == OrderType::MARKET) && pending_orders_[read].is_long == exit_closed_was_long @@ -1074,6 +1127,7 @@ void BacktestEngine::apply_filled_order_to_state( const Bar& bar, double& trail_best_path_state, int& exit_closed_from_bar, + uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long, std::vector& filled_indices) { auto decline_and_cancel = [&]() { @@ -1680,10 +1734,14 @@ void BacktestEngine::apply_filled_order_to_state( } else if (order.type == OrderType::ENTRY) { apply_entry_order_fill(order, fill_price, bar, trail_best_path_state); } else if (order.type == OrderType::EXIT) { - apply_exit_order_fill(order, fill_price, exit_closed_from_bar, exit_closed_was_long); + apply_exit_order_fill( + order, fill_price, exit_closed_from_bar, + exit_closed_from_incarnation, exit_closed_was_long); } else if (order.type == OrderType::RAW_ORDER) { apply_raw_order_fill(order, fill_price, trail_best_path_state, - exit_closed_from_bar, exit_closed_was_long); + exit_closed_from_bar, + exit_closed_from_incarnation, + exit_closed_was_long); } fold_exit_path_extremes_ = false; fold_exit_trail_peak_ = std::numeric_limits::quiet_NaN(); @@ -2084,6 +2142,7 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_price, int& exit_closed_from_bar, + 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); @@ -2135,6 +2194,7 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric if (position_side_ == PositionSide::FLAT && side_before_exit != PositionSide::FLAT) { exit_closed_from_bar = order.created_bar; + exit_closed_from_incarnation = order.incarnation; exit_closed_was_long = (side_before_exit == PositionSide::LONG); } } @@ -2199,6 +2259,7 @@ void BacktestEngine::reconcile_deferred_layered_exits(const std::string& entry_i void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price, double& trail_best_path_state, int& exit_closed_from_bar, + uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long) { if (position_side_ == PositionSide::FLAT) { fill_price = apply_fill_slippage(fill_price, order.is_long); @@ -2284,6 +2345,7 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price execute_market_exit(fill_price); if (position_side_ == PositionSide::FLAT) { exit_closed_from_bar = order.created_bar; + exit_closed_from_incarnation = order.incarnation; exit_closed_was_long = (side_before_raw == PositionSide::LONG); } } @@ -2378,7 +2440,8 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( PendingOrder& order, int opposing_pass, internal::DualEntryStopPathWinner dual_entry_path, const std::unordered_set& pass0_opposing_skip_ids, - int exit_closed_from_bar, bool exit_closed_was_long, const Bar& bar) { + int exit_closed_from_bar, uint64_t exit_closed_from_incarnation, + bool exit_closed_was_long, const Bar& bar) { using internal::DualEntryStopPathWinner; // design-declined-reversal-close-leg: a close flagged at the KI-54 reversal // decline is held atomically with the refused reversal — Remove it from both @@ -2520,9 +2583,10 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( } } - // Cancel pending SAME-DIRECTION entry orders placed on a prior on_bar - // when a full strategy.exit has fired on this bar. Opposite-direction - // entries (reversal via stop/limit-then-new-signal) still fire. + // Cancel stale SAME-DIRECTION entry orders when a full strategy.exit has + // fired on this bar. Opposite-direction entries (reversal via + // stop/limit-then-new-signal) still fire, as do the narrow proven + // same-direction carve-outs below. // Restrict the wipe to entries actually ADDED to the just-closed // position (created_position_side matches the closed direction). PositionSide closed_side = @@ -2553,20 +2617,26 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( // add placed OVER the pyramiding cap is one TV rejects at placement, and the // fill-time gate misses it because the co-queued close zeroes // position_entry_count_ first — so over_pyramiding_cap_at_placement keeps it - // in the wipe. PRIOR-bar carries (created_bar < the close's call bar) are - // always cancelled — the deferred-flip carry this wipe exists for - // (test_deferred_flip_carry_close_only.cpp, probes 72/80/93). The reverted + // in the wipe. Ordinary PRIOR-bar carries remain cancelled — the + // deferred-flip carry this wipe exists for (test_deferred_flip_carry_close_only.cpp, + // probes 72/80/93). The shared helper below excludes only a pure STOP with + // the physically-live same-ID deferred-close_all provenance. The reverted // M1 used the created_bar term alone and un-cancelled over-cap co-queues // (probe65 732→1463; the composite bracket fell below strong). bool coqueued_within_cap = order.created_bar == exit_closed_from_bar && !order.over_pyramiding_cap_at_placement; + bool same_id_stop_preserved_by_deferred_close_all = + preserves_same_id_stop_across_deferred_close_all( + order, exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long); if (exit_closed_from_bar >= 0 && (order.type == OrderType::MARKET || order.type == OrderType::ENTRY) && order.is_long == exit_closed_was_long && order.created_position_side == closed_side && !resting_limit_entry_carry - && !coqueued_within_cap) { + && !coqueued_within_cap + && !same_id_stop_preserved_by_deferred_close_all) { return OrderEligibility::Remove; } diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 27f5192..0781d3f 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -222,6 +222,7 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { constexpr int kCoofLoopGuard = 1 << 20; uint64_t fill_events = 0; int exit_closed_from_bar = -1; + uint64_t exit_closed_from_incarnation = 0; bool exit_closed_was_long = false; snapshot_coof_script_state(); @@ -276,7 +277,8 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { current_bar_ = point; CoofFillResult fill = process_next_pending_order( point, /*allow_market_orders=*/true, - exit_closed_from_bar, exit_closed_was_long); + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long); if (fill.filled) { // A fill at this POINT (cursor == path[next_waypoint-1]) puts the // in-flight leg at path[next_waypoint-1] -> path[next_waypoint], @@ -306,7 +308,8 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { coof_hist_path_index_ = next_waypoint - 1; CoofFillResult fill = process_next_pending_order( segment, /*allow_market_orders=*/false, - exit_closed_from_bar, exit_closed_was_long); + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long); coof_evaluating_path_segment_ = false; if (fill.filled) { const bool reached_target = @@ -369,7 +372,8 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { current_bar_ = close_point; CoofFillResult fill = process_next_pending_order( close_point, /*allow_market_orders=*/true, - exit_closed_from_bar, exit_closed_was_long); + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long); if (!fill.filled) break; fill_events += fill.fill_events; } @@ -809,6 +813,7 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( const uint64_t max_fill_events = static_cast(ticks.size()); uint64_t fill_events = 0; int exit_closed_from_bar = -1; + uint64_t exit_closed_from_incarnation = 0; bool exit_closed_was_long = false; snapshot_coof_script_state(); coof_scheduler_active_ = true; @@ -843,7 +848,8 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( current_bar_ = point; CoofFillResult fill = process_next_pending_order( point, /*allow_market_orders=*/true, - exit_closed_from_bar, exit_closed_was_long); + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long); if (fill.filled) { consume_fill( fill, cursor_is_close, @@ -873,7 +879,8 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( coof_evaluating_path_segment_ = true; CoofFillResult fill = process_next_pending_order( segment, /*allow_market_orders=*/false, - exit_closed_from_bar, exit_closed_was_long); + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long); coof_evaluating_path_segment_ = false; if (fill.filled) { cursor_ts = target.timestamp; @@ -914,7 +921,8 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( current_bar_ = close_point; CoofFillResult fill = process_next_pending_order( close_point, /*allow_market_orders=*/true, - exit_closed_from_bar, exit_closed_was_long); + exit_closed_from_bar, exit_closed_from_incarnation, + exit_closed_was_long); if (!fill.filled) break; fill_events += fill.fill_events; } diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 90cd660..059cd39 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -225,6 +225,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.oca_type = oca_type; order.created_bar = bar_index_; order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++; + order.incarnation = next_order_incarnation_++; order.created_during_coof_recalc = coof_fill_recalc_active_; order.coof_born_at_close_recalc = coof_fill_recalc_active_ && coof_cursor_is_bar_close_; @@ -502,9 +503,57 @@ void BacktestEngine::strategy_close(const std::string& id, const std::string& co const double consumed_ledger_qty = (default_fifo_close && !immediate_ledger_recredit) ? qty_to_close : std::numeric_limits::quiet_NaN(); - queue_deferred_close_order(id, comment, qty_to_close, matching_qty, - closes_full_position, closes_any_qty, - consumed_ledger_qty); + const uint64_t deferred_close_incarnation = queue_deferred_close_order( + id, comment, qty_to_close, matching_qty, + closes_full_position, closes_any_qty, + consumed_ledger_qty); + + // TradingView keeps one narrowly identifiable prior-bar broker order + // across an ordinary deferred close_all: a pure STOP strategy.entry that + // reuses the id of a physically-live lot on the held side and was within + // the pyramiding cap at placement. Snapshot physical pyramid_entries_ + // here, before the close later drains them; id_unclosed_qty_ is purposely + // not used because default-FIFO close(id) can make that logical ledger + // disagree with the actually-live lot roster. Pair the snapshot with the + // newly queued close_all's fresh incarnation: created_seq intentionally + // survives same-id replacement for ordering, so it is not an identity. + // Sharing the call bar alone is also insufficient when an earlier RAW or + // ANY close(id) flattens first. No later full-close call globally clears + // this stamp: a coexisting earlier close_all still owns it, while a + // cancelled/replaced close_all can never be impersonated because its + // incarnation is never reused. + if (closes_full_position && id.empty() && !process_orders_on_close_) { + const bool closing_long = position_side_ == PositionSide::LONG; + for (PendingOrder& pending : pending_orders_) { + const bool pure_stop_entry = + pending.type == OrderType::ENTRY + && std::isfinite(pending.stop_price) + && std::isnan(pending.limit_price) + && std::isnan(pending.trail_points) + && std::isnan(pending.trail_price) + && std::isnan(pending.trail_offset) + && !pending.stop_limit_activated; + if (!pure_stop_entry + || pending.created_bar >= bar_index_ + || pending.is_long != closing_long + || pending.created_position_side != position_side_ + || pending.over_pyramiding_cap_at_placement) { + continue; + } + const bool has_physically_live_same_id_lot = + std::any_of( + pyramid_entries_.begin(), pyramid_entries_.end(), + [&](const PyramidEntry& lot) { + return lot.entry_id == pending.id + && lot.qty > kQtyEpsilon; + }); + if (has_physically_live_same_id_lot) { + pending.same_id_stop_deferred_close_all_bar = bar_index_; + pending.same_id_stop_deferred_close_all_incarnation = + deferred_close_incarnation; + } + } + } // A default-FIFO close consumes id_unclosed_qty_ while resolving its // target above. When the command was born after an already-consumed POOC // close, its market order expires without a broker tick; keep the logical @@ -876,6 +925,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.oca_type = oca_name.empty() ? 0 : 1; // strategy.exit semantics: cancel order.created_bar = bar_index_; order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++; + order.incarnation = next_order_incarnation_++; order.created_during_coof_recalc = coof_fill_recalc_active_; order.coof_born_at_close_recalc = coof_fill_recalc_active_ && coof_cursor_is_bar_close_; @@ -1006,6 +1056,7 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double order.oca_type = oca_type; order.created_bar = bar_index_; order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++; + order.incarnation = next_order_incarnation_++; order.created_during_coof_recalc = coof_fill_recalc_active_; order.coof_born_at_close_recalc = coof_fill_recalc_active_ && coof_cursor_is_bar_close_; @@ -1263,13 +1314,14 @@ void BacktestEngine::execute_immediate_close(const std::string& id, // be matched at the next bar's open by process_pending_orders. Mirrors // the qty / qty_percent shape that the partial-exit dispatch in // execute_immediate_close would have produced for the same flags. -void BacktestEngine::queue_deferred_close_order(const std::string& id, - const std::string& comment, - double qty_to_close, - double matching_qty, - bool closes_full_position, - bool closes_any_qty, - double consumed_ledger_qty) { +uint64_t BacktestEngine::queue_deferred_close_order( + const std::string& id, + const std::string& comment, + double qty_to_close, + double matching_qty, + bool closes_full_position, + bool closes_any_qty, + double consumed_ledger_qty) { const double eps = kQtyEpsilon; PendingOrder order; order.id = "__close__" + id; @@ -1295,6 +1347,7 @@ void BacktestEngine::queue_deferred_close_order(const std::string& id, order.oca_type = 0; order.created_bar = bar_index_; order.created_seq = next_order_seq_++; + order.incarnation = next_order_incarnation_++; order.created_during_coof_recalc = coof_fill_recalc_active_; order.coof_born_at_close_recalc = coof_fill_recalc_active_ && coof_cursor_is_bar_close_; @@ -1313,7 +1366,9 @@ void BacktestEngine::queue_deferred_close_order(const std::string& id, // debited (ANY rule / explicit qty / close_all). order.suppressed_close_consumed_ledger_qty = consumed_ledger_qty; + const uint64_t incarnation = order.incarnation; pending_orders_.push_back(std::move(order)); + return incarnation; } // Capture seq + reserved qty of an existing pending exit with the diff --git a/tests/test_close_all_coqueued_entry.cpp b/tests/test_close_all_coqueued_entry.cpp index 681d9fe..ef6da4f 100644 --- a/tests/test_close_all_coqueued_entry.cpp +++ b/tests/test_close_all_coqueued_entry.cpp @@ -1,12 +1,13 @@ /* * test_close_all_coqueued_entry.cpp — M1v2 (NARROWED close-co-queue fix). * - * After a DEFERRED full-close exit fills and flattens on a bar, a pending - * SAME-direction MARKET/ENTRY order co-queued on the close's own call bar is - * REMOVED iff it is a prior-bar carry (created_bar != exit_closed_from_bar) OR - * it was OVER the pyramiding cap at placement. A co-queued same-direction entry - * that was WITHIN the cap at placement SURVIVES (TradingView keeps it: a market - * fills at the next open; a stop fires when its level is later touched). + * After a DEFERRED full-close exit fills and flattens on a bar, the ordinary + * stale same-direction MARKET/ENTRY wipe keeps three independently pinned + * exceptions: an under-cap same-call-bar co-queue, a resting prior-bar pure + * LIMIT, and (for close_all only) a prior-bar under-cap pure STOP whose id still + * names a physically-live same-side lot when close_all is called. Everything + * else — different-id carries, stop-limits, close(id), and over-cap adds — + * remains in the wipe. * * Why the extra "over cap" term (vs the reverted M1, which used created_bar * alone): the engine enforces pyramiding at FILL time, and the co-queued full @@ -86,6 +87,72 @@ class ProbeBase : public BacktestEngine { double pos_size() const { return signed_position_size(); } }; +// The provenance identity is carried by all three broker schedulers. Keep one +// compact probe that can run through ordinary COOF bars or real lower-TF +// magnifier endpoints, and can replace the deferred close_all with a RAW order +// using its synthetic id. Fill-recalc bodies are deliberately inert: these +// controls isolate broker-order provenance rather than script re-emission. +class CoofIncarnationProbe final : public ProbeBase { +public: + explicit CoofIncarnationProbe(bool replace_close_all) + : ProbeBase(4), replace_close_all_(replace_close_all) { + calc_on_order_fills_ = true; + } + + void on_bar(const Bar&) override { + if (coof_fill_recalc_active_) return; + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) { + strategy_close_all(); + if (replace_close_all_) + strategy_order("__close__", true, /*qty=*/1.0); + } + } + +private: + bool replace_close_all_; +}; + +static void run_coof_incarnation_control(bool replace_close_all, + bool magnifier) { + CoofIncarnationProbe p(replace_close_all); + if (!magnifier) { + Bar bars[5] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), + mk(100, 102, 99, 100, 1'800'000), + mk(100, 102, 88, 90, 2'400'000), + mk( 90, 91, 89, 90, 3'000'000), + }; + p.run(bars, 5); + } else { + // Two 1-minute bars compose each 2-minute chart bar. On chart bar 3, + // the close/RAW fills at the first lower-bar open and the STOP is only + // reached by a later lower-bar endpoint. + Bar lower[10] = { + mk(100, 100, 100, 100, 0), + mk(100, 100, 100, 100, 60'000), + mk(100, 101, 99, 100, 120'000), + mk(100, 102, 99, 100, 180'000), + mk(100, 101, 99, 100, 240'000), + mk(100, 102, 99, 100, 300'000), + mk(100, 102, 99, 100, 360'000), + mk(100, 100, 88, 90, 420'000), + mk( 90, 91, 89, 90, 480'000), + mk( 90, 91, 89, 90, 540'000), + }; + p.run(lower, 10, "1", "2", /*bar_magnifier=*/true, + /*magnifier_samples=*/4, MagnifierDistribution::ENDPOINTS); + } + + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), replace_close_all ? 0.0 : -1.0)); +} + // ───────────────────────────────────────────────────────────────────── // R-KEEP-mkt (pyramiding=4, HEADROOM: TWO same-dir opens). // A market entry co-queued with close_all on the close's own call bar, while @@ -132,7 +199,8 @@ static void test_R_KEEP_mkt_undercap_survives() { // ───────────────────────────────────────────────────────────────────── // R-KEEP-stop (pyramiding=4, HEADROOM, SHORT). A same-direction stop entry // co-queued with close_all while UNDER cap survives and fills when its level is -// later touched (the probe's 04-27 22:00 short-stop @1793.76 flavor). +// later touched. This pins the existing same-call-bar rule; the exact 04-27 +// prior-bar/same-ID shape is pinned separately below. // // bar0: entry("S0", short mkt) // bar1: S0 fills @100 → SHORT 1. entry("S1", short mkt) @@ -378,6 +446,415 @@ static void test_G_opposite_unchanged_ki64() { CHECK(near(p.pos_size(), -1.0)); // opposite entry survives (unchanged pre/post) } +// ───────────────────────────────────────────────────────────────────── +// R-KEEP-prior-stop-fill-bar (the exact pyramid probe shape). A pure STOP +// strategy.entry reusing the physically-live entry id was armed one bar BEFORE +// close_all was called. TradingView keeps this broker order: when close_all +// fills at the next open and the same bar subsequently reaches the stop, the +// old short closes and the pending same-id short opens. +// +// This pins classify_order_eligibility: the STOP is reached later in the same +// pending-order pass after the deferred close_all has set exit_closed_from_bar. +// ───────────────────────────────────────────────────────────────────── +static void test_R_KEEP_priorbar_same_id_stop_touched_on_close_fill_bar() { + std::printf("R-KEEP-prior-stop-fill-bar (physical same-id STOP survives close_all)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) strategy_close_all(); + } + }; + Probe p; + Bar bars[5] = { + mk(100, 100, 100, 100, 600'000), // bar0: queue physical S + mk(100, 102, 99, 100, 1'200'000), // bar1: S fills; arm pending S@90 + mk(100, 102, 99, 100, 1'800'000), // bar2: deferred close_all call + mk(100, 102, 88, 90, 2'400'000), // bar3: close fills, then S@90 fires + mk( 90, 91, 89, 90, 3'000'000), // bar4: settle + }; + p.run(bars, 5); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), -1.0)); // pre-fix: 0.0 (eligibility removes S) +} + +// ───────────────────────────────────────────────────────────────────── +// R-KEEP-prior-stop-later (same provenance, but the close-fill bar does NOT +// touch the stop). The STOP must survive end-of-pass compaction and fill on a +// later bar. This is deliberately separate from the prior test so a patch to +// only one of the two cleanup sites cannot pass. +// ───────────────────────────────────────────────────────────────────── +static void test_R_KEEP_priorbar_same_id_stop_survives_compaction() { + std::printf("R-KEEP-prior-stop-later (physical same-id STOP survives compaction)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) strategy_close_all(); + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), // bar0: queue physical S + mk(100, 102, 99, 100, 1'200'000), // bar1: S fills; arm pending S@90 + mk(100, 102, 99, 100, 1'800'000), // bar2: deferred close_all call + mk(100, 102, 99, 100, 2'400'000), // bar3: close fills; S remains untouched + mk( 95, 95, 88, 90, 3'000'000), // bar4: S@90 must still be live + mk( 90, 91, 89, 90, 3'600'000), // bar5: settle + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), -1.0)); // pre-fix: 0.0 (compaction removes S) +} + +// ───────────────────────────────────────────────────────────────────── +// G-no-physical-lot. The logical id ledger and physical FIFO lot roster are +// intentionally made to disagree: close("B") consumes B's logical quantity +// but FIFO closes physical A, leaving only physical B. A later pending entry A +// therefore must NOT receive the same-id carve-out. This kills an implementation +// that consults id_unclosed_qty_ instead of pyramid_entries_. +// ───────────────────────────────────────────────────────────────────── +static void test_G_no_physical_same_id_stop_still_cancelled() { + std::printf("G-no-physical-lot (logical A without physical A is cancelled)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("A", true); + if (bar_index_ == 1) strategy_entry("B", true); + if (bar_index_ == 2) strategy_close("B"); + if (bar_index_ == 3) + strategy_entry("A", true, kNaN, /*stop=*/120.0, kNaN, + "logical-only same-id stop"); + if (bar_index_ == 4) strategy_close_all(); + } + }; + Probe p; + Bar bars[8] = { + mk(100, 100, 100, 100, 600'000), // bar0: queue A + mk(100, 100, 100, 100, 1'200'000), // bar1: A fills; queue B + mk(100, 100, 100, 100, 1'800'000), // bar2: B fills; close("B") + mk(100, 100, 100, 100, 2'400'000), // bar3: FIFO closes physical A; arm A@120 + mk(100, 100, 100, 100, 3'000'000), // bar4: close_all call; only physical B exists + mk(100, 100, 100, 100, 3'600'000), // bar5: close_all fills; A@120 cancelled + mk(125, 130, 125, 128, 4'200'000), // bar6: would trigger A if wrongly preserved + mk(128, 128, 128, 128, 4'800'000), // bar7: settle + }; + p.run(bars, 8); + CHECK(p.trade_count() == 2); + CHECK(near(p.pos_size(), 0.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// G-close-id. Physical same-id provenance is not enough by itself: this new +// exception is pinned to deferred close_all only. A prior-bar same-id STOP +// remains cancelled after a full strategy.close(id). +// ───────────────────────────────────────────────────────────────────── +static void test_G_close_id_same_id_priorbar_stop_still_cancelled() { + std::printf("G-close-id (same-id STOP remains cancelled by close(id))\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) strategy_close("S"); + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), + mk(100, 102, 99, 100, 1'800'000), + mk(100, 102, 99, 100, 2'400'000), // close("S") fills; pending S cancelled + mk( 95, 95, 88, 90, 3'000'000), // would trigger S if scope leaked + mk( 90, 91, 89, 90, 3'600'000), + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), 0.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// G-LIMIT-characterization. Prior-bar pure LIMIT carry already has its own +// proven carve-out and must remain unchanged by the new pure-STOP provenance. +// ───────────────────────────────────────────────────────────────────── +static void test_G_same_id_priorbar_limit_carry_unchanged() { + std::printf("G-LIMIT (existing prior-bar pure LIMIT carry unchanged)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", true); + if (bar_index_ == 1) + strategy_entry("L", true, /*limit=*/90.0, kNaN, kNaN, + "prior-bar same-id limit"); + if (bar_index_ == 2) strategy_close_all(); + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), + mk(100, 102, 99, 100, 1'800'000), + mk(100, 102, 99, 100, 2'400'000), // close_all fills; L@90 remains resting + mk( 95, 96, 88, 90, 3'000'000), // L@90 fills + mk( 90, 91, 89, 90, 3'600'000), + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), 1.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// G-stop-limit. Reusing a physically-live id is still insufficient when the +// pending entry has both stop and limit legs. The new exception is pure STOP +// only; this prior-bar same-id stop-limit remains tied to the closed cycle and +// must be cancelled. +// ───────────────────────────────────────────────────────────────────── +static void test_G_same_id_priorbar_stop_limit_still_cancelled() { + std::printf("G-stop-limit (same-id prior-bar stop-limit remains cancelled)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", true); + if (bar_index_ == 1) + strategy_entry("L", true, /*limit=*/115.0, /*stop=*/110.0, + kNaN, "prior-bar same-id stop-limit"); + if (bar_index_ == 2) strategy_close_all(); + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), // physical L; arm stop-limit + mk(100, 102, 99, 100, 1'800'000), // close_all call + mk(100, 102, 99, 100, 2'400'000), // close_all fills; stop-limit cancelled + mk(100, 120, 99, 115, 3'000'000), // would trigger and fill if preserved + mk(115, 116, 114, 115, 3'600'000), + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), 0.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// G-RAW-before-close_all. The pending STOP has valid physical same-id +// provenance when close_all is CALLED, but an earlier-created opposite RAW +// market order is the instruction that actually flattens at the next open. +// Both orders share one created_bar, so call-bar provenance alone incorrectly +// attributes the RAW flatten to close_all and preserves S. Actual close-order +// identity must keep S in the stale-cycle wipe. +// ───────────────────────────────────────────────────────────────────── +static void test_G_raw_before_close_all_does_not_authorize_same_id_stop() { + std::printf("G-RAW-before-close_all (RAW flatten cannot authorize STOP)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) { + strategy_order("RAW", true, /*qty=*/1.0); + strategy_close_all(); + } + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), // physical S; arm S@90 + mk(100, 102, 99, 100, 1'800'000), // RAW first, then close_all + mk(100, 102, 99, 100, 2'400'000), // RAW actually flattens; S must drop + mk( 95, 95, 88, 90, 3'000'000), // would trigger leaked S + mk( 90, 91, 89, 90, 3'600'000), + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), 0.0)); // call-bar-only patch: -1.0 +} + +// ───────────────────────────────────────────────────────────────────── +// G-ANY-close-before-close_all. Same collision through a high-level EXIT: +// under close_entries_rule="ANY", close("S") keeps from_entry=S and therefore +// coexists with the later global close_all. It is earlier in source/created_seq +// and actually flattens. Sharing close_all's call bar must not grant S the +// close_all-only STOP preservation. +// ───────────────────────────────────────────────────────────────────── +static void test_G_any_close_id_before_close_all_does_not_authorize_same_id_stop() { + std::printf("G-ANY-close-before-close_all (close(id) flatten cannot authorize STOP)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) { close_entries_rule_any_ = true; } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) { + strategy_close("S"); + strategy_close_all(); + } + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), // physical S; arm S@90 + mk(100, 102, 99, 100, 1'800'000), // close(S) first, then close_all + mk(100, 102, 99, 100, 2'400'000), // close(S) actually flattens + mk( 95, 95, 88, 90, 3'000'000), // would trigger leaked S + mk( 90, 91, 89, 90, 3'600'000), + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), 0.0)); // call-bar-only patch: -1.0 +} + +// ───────────────────────────────────────────────────────────────────── +// R-KEEP-close_all-replacement. A second close_all on the same call bar +// replaces the first deferred global close. The STOP provenance must refresh +// to the replacement order's fresh incarnation; binding it to the cancelled +// first order would make the real second close fail the identity gate and lose S. +// ───────────────────────────────────────────────────────────────────── +static void test_R_KEEP_replaced_close_all_refreshes_stop_identity() { + std::printf("R-KEEP-close_all-replacement (STOP follows surviving close identity)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) { + strategy_close_all(); + strategy_close_all(); + } + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), + mk(100, 102, 99, 100, 1'800'000), // first close replaced by second + mk(100, 102, 99, 100, 2'400'000), // surviving close_all fills + mk( 95, 95, 88, 90, 3'000'000), // S remains live and fires + mk( 90, 91, 89, 90, 3'600'000), + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), -1.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// G-incarnation-replacement. created_seq is deliberately replacement-stable: +// a same-id order preserves its sorting slot. Therefore a RAW order using the +// synthetic close_all id "__close__" inherits close_all's created_seq while +// replacing that EXIT. The RAW fill must not impersonate the cancelled +// close_all; preservation needs a fresh, never-reused order incarnation. +// ───────────────────────────────────────────────────────────────────── +static void test_G_raw_same_id_replacement_cannot_impersonate_close_all() { + std::printf("G-incarnation-replacement (RAW __close__ cannot impersonate close_all)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) { + strategy_close_all(); + // Replaces internal pending id "__close__" and intentionally + // inherits its created_seq ordering slot. + strategy_order("__close__", true, /*qty=*/1.0); + } + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), + mk(100, 102, 99, 100, 1'800'000), // close_all replaced by RAW + mk(100, 102, 99, 100, 2'400'000), // RAW flattens; S must drop + mk( 95, 95, 88, 90, 3'000'000), // would trigger leaked S + mk( 90, 91, 89, 90, 3'600'000), + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), 0.0)); // created_seq identity patch: -1.0 +} + +// ───────────────────────────────────────────────────────────────────── +// R-KEEP-close_all-before-ANY-close. The later close("S") is also a full +// close, but under close_entries_rule="ANY" it coexists with the earlier bare +// close_all rather than cancelling it. close_all fills first by source order, +// so its physically-live same-ID STOP provenance must remain intact. A global +// "clear every stamp on any later full close" loses S incorrectly. +// ───────────────────────────────────────────────────────────────────── +static void test_R_KEEP_close_all_before_any_close_id_preserves_stop() { + std::printf("R-KEEP-close_all-before-ANY-close (surviving close_all owns stamp)\n"); + class Probe : public ProbeBase { + public: + Probe() : ProbeBase(4) { close_entries_rule_any_ = true; } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 1) + strategy_entry("S", false, kNaN, /*stop=*/90.0, kNaN, + "prior-bar same-id stop"); + if (bar_index_ == 2) { + strategy_close_all(); + strategy_close("S"); + } + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 102, 99, 100, 1'200'000), + mk(100, 102, 99, 100, 1'800'000), // close_all first; close(S) coexists + mk(100, 102, 99, 100, 2'400'000), // close_all actually flattens + mk( 95, 95, 88, 90, 3'000'000), // S must remain live and fire + mk( 90, 91, 89, 90, 3'600'000), + }; + p.run(bars, 6); + CHECK(p.trade_count() == 1); + CHECK(near(p.pos_size(), -1.0)); // global stamp clearing: 0.0 +} + +// The ordinary COOF and magnifier schedulers must report the incarnation of +// the order that actually flattened the position. A real close_all authorizes +// its bound prior-bar STOP; a RAW same-id replacement must not impersonate it. +static void test_R_KEEP_coof_and_magnifier_close_all_incarnation() { + std::printf("R-KEEP-COOF (close_all incarnation survives both schedulers)\n"); + run_coof_incarnation_control(/*replace_close_all=*/false, + /*magnifier=*/false); + run_coof_incarnation_control(/*replace_close_all=*/false, + /*magnifier=*/true); +} + +static void test_G_coof_and_magnifier_raw_replacement_incarnation() { + std::printf("G-COOF (RAW replacement cannot impersonate in either scheduler)\n"); + run_coof_incarnation_control(/*replace_close_all=*/true, + /*magnifier=*/false); + run_coof_incarnation_control(/*replace_close_all=*/true, + /*magnifier=*/true); +} + int main() { test_R_KEEP_mkt_undercap_survives(); test_R_KEEP_stop_undercap_survives(); @@ -386,6 +863,19 @@ int main() { test_G_DROP_mkt_overcap_pyr2(); test_G_carry_priorbar_still_cancelled(); test_G_opposite_unchanged_ki64(); + test_R_KEEP_priorbar_same_id_stop_touched_on_close_fill_bar(); + test_R_KEEP_priorbar_same_id_stop_survives_compaction(); + test_G_no_physical_same_id_stop_still_cancelled(); + test_G_close_id_same_id_priorbar_stop_still_cancelled(); + test_G_same_id_priorbar_limit_carry_unchanged(); + test_G_same_id_priorbar_stop_limit_still_cancelled(); + test_G_raw_before_close_all_does_not_authorize_same_id_stop(); + test_G_any_close_id_before_close_all_does_not_authorize_same_id_stop(); + test_R_KEEP_replaced_close_all_refreshes_stop_identity(); + test_G_raw_same_id_replacement_cannot_impersonate_close_all(); + test_R_KEEP_close_all_before_any_close_id_preserves_stop(); + test_R_KEEP_coof_and_magnifier_close_all_incarnation(); + test_G_coof_and_magnifier_raw_replacement_incarnation(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; } From 7d84fb3f5890b9a941e3a988bc591c57e3636aa5 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 15 Jul 2026 22:31:23 +0800 Subject: [PATCH 2/2] fix: bind frozen reversals to position cycle --- include/pineforge/engine.hpp | 17 + src/engine_fills.cpp | 56 ++- src/engine_orders.cpp | 23 +- src/engine_strategy_commands.cpp | 1 + tests/test_deferred_flip_carry_close_only.cpp | 366 ++++++++++++++++++ 5 files changed, 443 insertions(+), 20 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index c6d5da9..73ccdbd 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -239,6 +239,11 @@ struct PendingOrder { // (seg_i == 2) or off-path (seg_i < 0) rolls to the next bar. bool coof_cascade_inflight_fires = false; PositionSide created_position_side = PositionSide::FLAT; + // Monotonic identity of the live position instance at placement. Side + // alone is insufficient: a resting order can survive LONG -> SHORT -> + // LONG and must not be mistaken for an order born in the later LONG + // cycle. Zero means the order was created while broker-flat. + int64_t created_position_cycle_seq = 0; // True when a successful strategy.close/close_all call earlier in this // same on_bar already targeted live quantity. This remains distinct from // created_position_side: an immediate full close makes the engine truly @@ -332,6 +337,13 @@ struct PendingOrder { // fires the chain resets to qty=1 (matching TV's behaviour at // probe 52 trade 113). Preserving the largest observed carry // across re-placements would over-extend chains. + // + // The same placement snapshot also pins the equality-only M2 rule for a + // priced explicit-FIXED same-cycle reversal: frozen broker transaction = + // this held qty + the order's own quantized qty. If later same-direction + // adds grow the live opposite position to exactly that transaction, the + // fill closes to flat and opens no new leg. Other size relations retain + // legacy reversal semantics; see apply_entry_order_fill. double tv_carry_qty = 0.0; // Quantity frozen at PLACEMENT (signal) time for a DEFAULT-sized (qty=na) // market order whose default sizing is price-dependent (percent_of_equity @@ -537,6 +549,11 @@ class BacktestEngine { double position_qty_ = 0.0; int position_entry_count_ = 0; // number of entries in current direction (for pyramiding) int position_open_bar_ = -1; // bar_index_ when position was opened (for exit delay) + // Exact position-instance provenance for pending orders whose semantics + // depend on the position cycle in which they were placed. A fresh open or + // reversal gets a new nonzero id; same-direction pyramid adds retain it. + int64_t position_cycle_seq_ = 0; + int64_t next_position_cycle_seq_ = 1; std::vector pyramid_entries_; // individual entries for trade reporting // Per-entry-id UNCLOSED quantity ledger, used ONLY by strategy.close(id) // under the default FIFO close-entries rule to decide how much to close. diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 502a5e9..13f6708 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -2077,17 +2077,29 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri // and re-arms; the ungated engine wrongly opened the reversed leg at the // stale level (25 phantom/early shorts on that probe). // A SAME-cycle reverse (created_position_side == the reversed side — the - // stop was placed while already holding the position it flips) DOES open - // the new leg, so it is excluded by the created!=current test. Deferred-flip - // carry entries that fire from FLAT are untouched (position_side_==FLAT). - // Approximation (no ground truth): created_position_side == position_side_ - // stands in for "placed in THIS position instance's cycle". A double flip - // (created LONG, position flips SHORT then LONG again with the order still - // pending) reads as same-cycle and opens — out of scope. + // stop was placed while already holding the position it flips) ordinarily + // opens the new leg. There is one independently pinned exception: for an + // explicit-FIXED priced entry, TV freezes the broker transaction at + // placement as ``held_qty + own_qty``. If later same-direction adds make + // the live opposite position EXACTLY that frozen transaction when the + // order fills, the transaction is consumed by the close and no open-leg + // remainder exists. The equality-only scope is deliberate: the census + // pins all seven M2 rows at equality, while the ordinary H=1/live=1/Q=1 + // (live < frozen) population must keep the legacy full reversal. No + // live>frozen behavior is inferred. Default/dynamic qty, MARKET orders, + // created-FLAT KI-65 orders, and prior-cycle carries are also excluded. + // Deferred-flip carry entries that fire from FLAT remain untouched + // (position_side_==FLAT). + // Position-cycle identity is load-bearing here. Side equality alone would + // misclassify a resting order that survives LONG -> SHORT -> LONG as born + // in the later LONG instance and could turn its legacy reversal into an + // incorrect close-only fill. PositionSide entry_req = order.is_long ? PositionSide::LONG : PositionSide::SHORT; - bool close_only_opposite = + const bool opposite_live_position = position_side_ != PositionSide::FLAT - && entry_req != position_side_ + && entry_req != position_side_; + const bool prior_cycle_close_only = + opposite_live_position && order.created_position_side != position_side_ // KI-65: a flat-armed priced entry reversing a position opened THIS bar // by an EARLIER opposite MARKET entry fully reverses (holds its own @@ -2096,6 +2108,29 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri // existed (STOP-first / placement-rejected cells leave it false, so // they keep the close-only single-close semantics). && !order.reverses_same_bar_market_from_flat; + const bool explicit_fixed_qty = + std::isfinite(order.qty) + && order.qty > kQtyEpsilon + && (order.qty_type < 0 + || order.qty_type == static_cast(QtyType::FIXED)); + const bool priced_entry = + !std::isnan(order.stop_price) || !std::isnan(order.limit_price); + const double fixed_own_qty = explicit_fixed_qty + ? std::abs(apply_qty_step(order.qty)) + : std::numeric_limits::quiet_NaN(); + const double frozen_reversal_tx = order.tv_carry_qty + fixed_own_qty; + const bool same_cycle_frozen_tx_exact_flat = + opposite_live_position + && order.created_position_side == position_side_ + && order.created_position_cycle_seq > 0 + && order.created_position_cycle_seq == position_cycle_seq_ + && priced_entry + && explicit_fixed_qty + && order.tv_carry_qty > kQtyEpsilon + && std::isfinite(frozen_reversal_tx) + && std::abs(position_qty_ - frozen_reversal_tx) <= kQtyEpsilon; + const bool close_only_opposite = + prior_cycle_close_only || same_cycle_frozen_tx_exact_flat; execute_market_entry(order.id, order.is_long, fill_price, order.qty, order.qty_type, order.created_position_side, close_only_opposite, /*is_priced_entry=*/true, @@ -2108,7 +2143,7 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri || (position_entry_count_ != count_before) || (trades_.size() != trades_before_entry); - bool was_priced_entry = !std::isnan(order.stop_price) || !std::isnan(order.limit_price); + bool was_priced_entry = priced_entry; if (did_execute) { double trail_best_after_fill = trail_best_price_; if (!pyramid_entries_.empty()) pyramid_entries_.back().entry_comment = order.comment; @@ -2267,6 +2302,7 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price double qty = !std::isnan(order.frozen_default_qty) ? order.frozen_default_qty : (std::isnan(order.qty) ? calc_qty(fill_price) : order.qty); position_side_ = order.is_long ? PositionSide::LONG : PositionSide::SHORT; + position_cycle_seq_ = next_position_cycle_seq_++; position_entry_price_ = fill_price; // The shared post-dispatch hook queues the new fill's event. Clear any // prior-cycle provenance first; RAW_ORDER opens do not route through diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 0e750d1..cc2e787 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -124,10 +124,12 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d return; } - // ``close_only_opposite`` reaches here only for a created_position_side != - // FLAT reduce-only flip (the FLAT bracket case returned above via - // close_opposite_then_enter): a deferred-flip carry that flips the opposite - // position without opening its own leg. + // ``close_only_opposite`` reaches here for a created_position_side != FLAT + // reduce-only flip (the FLAT bracket case returned above via + // close_opposite_then_enter). This is either a deferred-flip carry that + // reverses a later position cycle, or the equality-only same-cycle frozen + // transaction whose whole broker movement is consumed by the close. Both + // close the live opposite position without opening their own leg. flip_market_position_to(id, is_long, fill_price, explicit_qty, explicit_qty_type, /*close_only=*/close_only_opposite); } @@ -539,6 +541,7 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, // when the FIFO loop drained the position. void BacktestEngine::reset_position_state_to_flat() { position_side_ = PositionSide::FLAT; + position_cycle_seq_ = 0; position_entry_price_ = 0.0; opening_affordability_pending_ = false; opening_affordability_eligible_ = false; @@ -583,6 +586,7 @@ void BacktestEngine::settle_position_after_partial_exit() { void BacktestEngine::open_fresh_position(PositionSide requested, double fill_price, double qty, const std::string& id) { position_side_ = requested; + position_cycle_seq_ = next_position_cycle_seq_++; position_entry_price_ = fill_price; // The shared post-dispatch lifecycle hook queues the new fill's event. // Clear prior-cycle provenance now so reversals cannot expose it even @@ -853,12 +857,11 @@ void BacktestEngine::flip_market_position_to(const std::string& id, bool is_long } if (close_only) { - // Deferred-flip carry reduce-only: this priced entry was armed during - // a prior position cycle and only flips the (later-opened) opposite - // position — its open leg is superseded by the same-id re-issue and - // re-arms at the modified level. Close the whole opposite position and - // stay flat; do NOT open. (See apply_entry_order_fill's - // close_only_opposite gate: created_position_side != position_side_.) + // Priced-entry reduce-only cases: either this order was armed during a + // prior cycle and flips a later opposite position, or its same-cycle + // frozen transaction exactly equals the grown live opposite position. + // In both cases close the whole position and stay flat; do NOT open. + // See apply_entry_order_fill's close_only_opposite predicates. reset_position_state_to_flat(); return; } diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 059cd39..2b3b5bd 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -235,6 +235,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.coof_born_mid_bar = coof_fill_recalc_active_ && !coof_recalc_at_bar_open_; order.created_position_side = position_side_; + 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 diff --git a/tests/test_deferred_flip_carry_close_only.cpp b/tests/test_deferred_flip_carry_close_only.cpp index 7956712..30293f3 100644 --- a/tests/test_deferred_flip_carry_close_only.cpp +++ b/tests/test_deferred_flip_carry_close_only.cpp @@ -189,9 +189,375 @@ static void test_same_cycle_reverse_still_opens() { CHECK(near(p.pos_size(), -1.0)); } +// ────────────────────────────────────────────────────────────────── +// Same-cycle frozen reversal transaction, exact-equality cell. +// +// A priced explicit-FIXED opposite entry placed while holding H contracts +// freezes a broker transaction of H + Q. If same-direction adds grow the +// live position to exactly that frozen transaction before the priced order +// fills, TradingView consumes the whole transaction closing the live position +// and has no remainder with which to open the requested side. +// +// bar0: place market L1 qty 1 +// bar1: L1 fills -> LONG 1; arm S stop qty 1 (H=1, frozen tx=2), then +// place same-direction market L2 qty 1 +// bar2: L2 fills -> live LONG 2 +// bar3: S triggers; live 2 == frozen tx 2 -> close both longs, stay FLAT +// +// Pre-fix the ordinary same-cycle reversal path closes both longs and opens a +// fresh SHORT 1. This is the campaign's seven-row M2 residual in minimal form. +// ────────────────────────────────────────────────────────────────── +static void test_same_cycle_frozen_transaction_exactly_flattens_long() { + std::printf("test_same_cycle_frozen_transaction_exactly_flattens_long\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 2; + syminfo_mintick_ = 0.01; + } + double pos_size() const { return signed_position_size(); } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L1", true, kNaN, kNaN, 1.0, "base long"); + } + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_entry("S", false, kNaN, /*stop=*/95.0, 1.0, + "frozen short reversal"); + strategy_entry("L2", true, kNaN, kNaN, 1.0, + "intervening long add"); + } + } + }; + Probe p; + Bar bars[5] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), // L1 fills; place S then L2 + mk(100, 100, 100, 100, 1'800'000), // L2 fills; S remains untouched + mk(100, 100, 94, 96, 2'400'000), // S triggers against live LONG 2 + mk( 96, 97, 95, 96, 3'000'000), + }; + p.run(bars, 5); + + CHECK(near(p.pos_size(), 0.0)); // RED: pre-fix ends SHORT 1 + CHECK(p.trade_count() == 2); + if (p.trade_count() == 2) { + CHECK(p.get_trade(0).is_long); + CHECK(p.get_trade(1).is_long); + CHECK(near(p.get_trade(0).qty, 1.0)); + CHECK(near(p.get_trade(1).qty, 1.0)); + CHECK(near(p.get_trade(0).exit_price, 95.0)); + CHECK(near(p.get_trade(1).exit_price, 95.0)); + } +} + +// Mirrored exact-equality cell: SHORT 1, arm long stop Q=1, add SHORT 1, +// then fill against live SHORT 2. The frozen transaction is also 2, so the +// fill closes both shorts and opens no long remainder. +static void test_same_cycle_frozen_transaction_exactly_flattens_short() { + std::printf("test_same_cycle_frozen_transaction_exactly_flattens_short\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 2; + syminfo_mintick_ = 0.01; + } + double pos_size() const { return signed_position_size(); } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("S1", false, kNaN, kNaN, 1.0, "base short"); + } + if (bar_index_ == 1 && position_side_ == PositionSide::SHORT) { + strategy_entry("L", true, kNaN, /*stop=*/105.0, 1.0, + "frozen long reversal"); + strategy_entry("S2", false, kNaN, kNaN, 1.0, + "intervening short add"); + } + } + }; + Probe p; + Bar bars[5] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(100, 100, 100, 100, 1'800'000), + mk(100, 106, 100, 104, 2'400'000), + mk(104, 105, 103, 104, 3'000'000), + }; + p.run(bars, 5); + + CHECK(near(p.pos_size(), 0.0)); // RED: pre-fix ends LONG 1 + CHECK(p.trade_count() == 2); + if (p.trade_count() == 2) { + CHECK(!p.get_trade(0).is_long); + CHECK(!p.get_trade(1).is_long); + CHECK(near(p.get_trade(0).exit_price, 105.0)); + CHECK(near(p.get_trade(1).exit_price, 105.0)); + } +} + +// Mutation control: an explicit-FIXED priced reversal with no intervening add +// has live=1 while its placement-frozen transaction is H+Q=2. The new rule is +// equality-only, so live(QtyType::CASH)); + strategy_entry("L2", true, kNaN, kNaN, 1.0); + } + } + }; + Probe p; + Bar bars[5] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(100, 100, 100, 100, 1'800'000), + mk(100, 100, 94, 96, 2'400'000), + mk( 96, 97, 95, 96, 3'000'000), + }; + p.run(bars, 5); + + CHECK(p.pos_size() < 0.0); + CHECK(std::abs(p.pos_size()) < 0.1); + CHECK(p.trade_count() == 2); +} + +// Mutation control: side equality is not cycle identity. S is armed in the +// first LONG cycle (H=1/Q=1), survives a LONG -> SHORT -> fresh LONG2 sequence, +// then triggers with live=2. Although side and quantity equal the positive +// cell, the order predates this position instance and keeps legacy reversal +// behavior, ending SHORT1 rather than close-only FLAT. +static void test_double_flip_same_side_is_not_same_position_cycle() { + std::printf("test_double_flip_same_side_is_not_same_position_cycle\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 2; + syminfo_mintick_ = 0.01; + } + double pos_size() const { return signed_position_size(); } + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 1.0); + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_entry("S", false, kNaN, /*stop=*/90.0, 1.0); + strategy_entry("F", false, kNaN, kNaN, 1.0); + } + if (bar_index_ == 2 && position_side_ == PositionSide::SHORT) + strategy_entry("G", true, kNaN, kNaN, 2.0); + } + }; + Probe p; + Bar bars[6] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), // L fills; arm S and queue F + mk(100, 100, 100, 100, 1'800'000), // F fills -> SHORT1; queue G + mk(100, 100, 100, 100, 2'400'000), // G fills -> fresh LONG2 + mk(100, 100, 89, 91, 3'000'000), // old S triggers against LONG2 + mk( 91, 92, 90, 91, 3'600'000), + }; + p.run(bars, 6); + + CHECK(near(p.pos_size(), -1.0)); // side-only patch: 0.0 + CHECK(p.trade_count() == 3); +} + +// Mutation control: MARKET reversals do not carry a priced-order frozen +// transaction and must retain ordinary close-and-open behavior. +static void test_market_same_cycle_reversal_unchanged() { + std::printf("test_market_same_cycle_reversal_unchanged\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 1; + } + double pos_size() const { return signed_position_size(); } + 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_entry("S", false, kNaN, kNaN, 1.0, + "ordinary market reversal"); + } + }; + Probe p; + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(100, 100, 100, 100, 1'800'000), + mk(100, 100, 100, 100, 2'400'000), + }; + p.run(bars, 4); + + CHECK(near(p.pos_size(), -1.0)); + CHECK(p.trade_count() == 1); +} + +// Mutation control: equality is the only newly pinned size relation. With +// H=1/Q=1 but two intervening adds, live=3 > frozen transaction 2; retain the +// legacy full reversal to SHORT 1 rather than inferring a partial reduction. +static void test_same_cycle_live_greater_than_frozen_unchanged() { + std::printf("test_same_cycle_live_greater_than_frozen_unchanged\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 3; + syminfo_mintick_ = 0.01; + } + double pos_size() const { return signed_position_size(); } + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L1", true, kNaN, kNaN, 1.0); + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_entry("S", false, kNaN, /*stop=*/95.0, 1.0); + strategy_entry("L2", true, kNaN, kNaN, 1.0); + strategy_entry("L3", true, kNaN, kNaN, 1.0); + } + } + }; + Probe p; + Bar bars[5] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(100, 100, 100, 100, 1'800'000), + mk(100, 100, 94, 96, 2'400'000), + mk( 96, 97, 95, 96, 3'000'000), + }; + p.run(bars, 5); + + CHECK(near(p.pos_size(), -1.0)); + CHECK(p.trade_count() == 3); +} + +// Mutation control: even when H + the FIXED default happens to equal the live +// position, qty=na is not an explicit-FIXED oracle cell and stays on the +// legacy full-reversal path. +static void test_default_fixed_exact_size_unchanged() { + std::printf("test_default_fixed_exact_size_unchanged\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 2; + syminfo_mintick_ = 0.01; + } + double pos_size() const { return signed_position_size(); } + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L1", true, kNaN, kNaN, 1.0); + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_entry("S", false, kNaN, /*stop=*/95.0, kNaN); + strategy_entry("L2", true, kNaN, kNaN, 1.0); + } + } + }; + Probe p; + Bar bars[5] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(100, 100, 100, 100, 1'800'000), + mk(100, 100, 94, 96, 2'400'000), + mk( 96, 97, 95, 96, 3'000'000), + }; + p.run(bars, 5); + + CHECK(near(p.pos_size(), -1.0)); + CHECK(p.trade_count() == 2); +} + int main() { test_carry_stop_flips_opposite_close_only(); test_same_cycle_reverse_still_opens(); + test_same_cycle_frozen_transaction_exactly_flattens_long(); + test_same_cycle_frozen_transaction_exactly_flattens_short(); + test_same_cycle_explicit_fixed_live_less_than_frozen_unchanged(); + test_same_cycle_finite_cash_qty_unchanged(); + test_double_flip_same_side_is_not_same_position_cycle(); + test_market_same_cycle_reversal_unchanged(); + test_same_cycle_live_greater_than_frozen_unchanged(); + test_default_fixed_exact_size_unchanged(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; }