diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 40211f5..e124338 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -198,6 +198,20 @@ struct PendingOrder { // Exact clean-room two-call rules must fail closed on this provenance // rather than mistaking retained priority for current source order. bool created_by_same_id_replacement = false; + // For a strategy.exit replacement, the unique incarnation of the exact + // matching (id, from_entry) EXIT object it replaced. Zero for a fresh + // child. This correlates retained broker priority with a concrete prior + // child rather than a replacement-shaped call sequence created later. + uint64_t replaced_exit_order_incarnation = 0; + // Incarnation of the live priced ENTRY removed by strategy.cancel(id) + // earlier in the same source evaluation, copied only onto the first fresh + // same-id strategy.entry call and then consumed. Zero means there is no + // exact named-cancel -> fresh-recreate provenance. + uint64_t recreated_after_named_cancelled_entry_incarnation = 0; + // Incarnation of the unique attached EXIT child that was still live when + // the parent above was named-cancelled. Copied with the parent cancel + // token and consumed by the same fresh recreate call. + uint64_t named_cancel_surviving_exit_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. @@ -698,6 +712,15 @@ class BacktestEngine { // Historical fill-triggered recalculation is strictly opt-in. The false // branch in dispatch_bar remains the legacy control path. bool calc_on_order_fills_ = false; + // Narrow ordinary-POOC broker ordering rule for one exact book shape: + // while truly flat, a single reissued from_entry bracket can retain an + // older sequence slot than its same-source-bar pure-stop parent after the + // prior parent was explicitly cancelled and freshly recreated. + // That exact two-order book scans the parent first so the child can inspect + // the post-entry path before the close-time script body. The metadata key + // remains as an explicit A/B override; ordinary execution enables the + // TV-pinned rule by default. + bool flat_retained_child_fresh_parent_order_ = true; QtyType default_qty_type_ = QtyType::FIXED; double default_qty_value_ = 1.0; int pyramiding_ = 1; // max additional entries in same direction @@ -950,6 +973,16 @@ class BacktestEngine { // two-call book. std::unordered_set default_flat_market_gross_disqualified_bars_; + // Evaluation-scoped tombstones for live priced ENTRY objects actually + // removed by strategy.cancel(id). invoke_chart_on_bar clears the map + // before each script execution; the first fresh same-id strategy.entry + // consumes the unique cancelled incarnation. + struct NamedEntryCancelContext { + uint64_t entry_incarnation = 0; + uint64_t surviving_exit_incarnation = 0; + }; + std::unordered_map + named_entry_cancelled_incarnation_in_current_eval_; // strategy.exit partial orders are one-shot per open position for a given id std::unordered_set consumed_partial_exit_ids_; @@ -2292,6 +2325,7 @@ class BacktestEngine { const std::string& from_entry, bool has_trail_request, int64_t& preserved_seq_out, + uint64_t& replaced_incarnation_out, double& preserved_reserved_qty_out); bool compute_exit_reserved_qty(const std::string& from_entry, double preserved_reserved_qty, @@ -2585,6 +2619,10 @@ class BacktestEngine { margin_zero_cover_full_liquidation_ = std::isfinite(value) && value > 0.0; } + if (key == "flat_retained_child_fresh_parent_order") { + flat_retained_child_fresh_parent_order_ = + std::isfinite(value) && value > 0.0; + } if (key == "intraday_cap_skip_noop_market_fills") { intraday_cap_skip_noop_market_fills_ = std::isfinite(value) && value > 0.0; diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index a7ff789..b67cc74 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1276,6 +1276,55 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { live_flat_market_pair_seqs.insert(order.created_seq); } } + + // Default-on retained-child / fresh-parent rule. A child retained across + // an explicit parent cancellation can keep an older broker slot than the + // freshly recreated parent; reissuing the child on that same source bar + // preserves those unequal priorities even though created_bar is now equal. The + // ordinary sequence tie-break then scans the child while flat (Skip) and + // the parent afterwards, so POOC on_bar observes a transient position + // before the second broker pass can revisit the child. + // + // Keep the comparator edge acyclic by admitting only an exact two-object + // book and recording immutable incarnation ids before stable_sort. Every + // provenance predicate below is intentional: ordinary POOC, no COOF or + // magnifier scheduler, one prior-bar freshly recreated pure-stop ENTRY, one + // same-source-bar flat-born full non-trailing EXIT child with older broker + // priority, and no OCA grouping. + uint64_t retained_child_parent_first_incarnation = 0; + uint64_t retained_child_later_incarnation = 0; + int64_t retained_child_parent_effective_seq = 0; + int64_t retained_child_later_effective_seq = 0; + if (pending_orders_.size() == 2) { + const PendingOrder* parent = nullptr; + const PendingOrder* child = nullptr; + for (const PendingOrder& order : pending_orders_) { + if (order.type == OrderType::ENTRY) { + parent = ℴ + } else if (order.type == OrderType::EXIT) { + child = ℴ + } + } + const RetainedChildFreshParentOrderContext context{ + flat_retained_child_fresh_parent_order_, + position_side_ == PositionSide::FLAT, + process_orders_on_close_, + calc_on_order_fills_, + coof_scheduler_active_, + bar_magnifier_enabled_, + stream_warmup_mode_, + stream_phase_ == StreamPhase::IDLE, + pending_orders_.size(), + bar_index_, + }; + if (retained_child_fresh_parent_order_pair( + context, parent, child)) { + retained_child_parent_first_incarnation = parent->incarnation; + retained_child_later_incarnation = child->incarnation; + retained_child_parent_effective_seq = child->created_seq; + retained_child_later_effective_seq = parent->created_seq; + } + } // A single relative strategy.exit armed while FLAT has no concrete // stop/limit until its earlier same-bar from_entry parent fills. It // otherwise looks like a phase-0 market order and sorts before a non-gap @@ -1397,6 +1446,21 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { int pa = fill_phase(a); int pb = fill_phase(b); if (pa != pb) return pa < pb; + if (retained_child_parent_first_incarnation != 0) { + auto effective_seq = [&](const PendingOrder& order) { + if (order.incarnation + == retained_child_parent_first_incarnation) { + return retained_child_parent_effective_seq; + } + if (order.incarnation == retained_child_later_incarnation) { + return retained_child_later_effective_seq; + } + return order.created_seq; + }; + const int64_t a_seq = effective_seq(a); + const int64_t b_seq = effective_seq(b); + if (a_seq != b_seq) return a_seq < b_seq; + } auto is_entry_same_as_current_position = [&](const PendingOrder& o) { return (o.type == OrderType::MARKET || o.type == OrderType::ENTRY) && ((position_side_ == PositionSide::LONG && o.is_long) diff --git a/src/engine_internal.hpp b/src/engine_internal.hpp index 17c5ea4..759a811 100644 --- a/src/engine_internal.hpp +++ b/src/engine_internal.hpp @@ -20,6 +20,7 @@ #include +#include #include #include #include @@ -55,6 +56,105 @@ inline constexpr double kPathPosEps = 1e-12; inline constexpr double kSegmentDenomEps = 1e-15; inline constexpr double kPathTimeEps = 1e-12; +struct RetainedChildFreshParentOrderContext { + bool enabled = false; + bool broker_flat = false; + bool process_orders_on_close = false; + bool calc_on_order_fills = false; + bool coof_scheduler_active = false; + bool bar_magnifier_enabled = false; + bool stream_warmup_mode = false; + bool stream_idle = false; + std::size_t pending_count = 0; + int bar_index = -1; +}; + +// Exact lifecycle predicate for the ordinary-POOC ordering exception where a +// retained strategy.exit child has an older broker slot than the pure-STOP +// parent explicitly cancelled and freshly recreated in the same evaluation. +// Keeping the full predicate runtime-private and side-effect-free makes every +// exclusion independently unit-testable; the comparator only swaps two +// immutable scalar sequence keys after this returns true. +inline bool retained_child_fresh_parent_order_pair( + const RetainedChildFreshParentOrderContext& ctx, + const PendingOrder* parent, + const PendingOrder* child) { + if (!ctx.enabled + || !ctx.broker_flat + || !ctx.process_orders_on_close + || ctx.calc_on_order_fills + || ctx.coof_scheduler_active + || ctx.bar_magnifier_enabled + || ctx.stream_warmup_mode + || !ctx.stream_idle + || ctx.pending_count != 2 + || parent == nullptr + || child == nullptr) { + return false; + } + + const uint64_t cancelled_incarnation = + parent->recreated_after_named_cancelled_entry_incarnation; + const uint64_t surviving_exit_incarnation = + parent->named_cancel_surviving_exit_incarnation; + const bool parent_is_exact_fresh_stop = + parent->type == OrderType::ENTRY + && parent->created_position_side == PositionSide::FLAT + && !parent->created_by_same_id_replacement + && cancelled_incarnation != 0 + && cancelled_incarnation < parent->incarnation + && cancelled_incarnation != child->incarnation + && surviving_exit_incarnation > cancelled_incarnation + && surviving_exit_incarnation < parent->incarnation + && parent->created_bar == ctx.bar_index - 1 + && std::isnan(parent->qty) + && !parent->created_during_coof_recalc + && !parent->created_after_position_close_in_bar + && !parent->over_pyramiding_cap_at_placement + && !parent->stop_limit_activated + && std::isfinite(parent->stop_price) + && std::isnan(parent->limit_price) + && std::isnan(parent->trail_points) + && std::isnan(parent->trail_price) + && std::isnan(parent->trail_offset) + && parent->oca_name.empty() + && parent->oca_type == 0; + const double child_qp = std::isnan(child->qty_percent) + ? 100.0 : child->qty_percent; + const bool child_is_exact_retained_bracket = + child->type == OrderType::EXIT + && !child->from_entry.empty() + && child->created_by_same_id_replacement + && child->replaced_exit_order_incarnation + == surviving_exit_incarnation + && !child->created_while_in_position + && child->created_position_side == PositionSide::FLAT + && child->created_bar == ctx.bar_index - 1 + && !child->created_during_coof_recalc + && !child->created_after_position_close_in_bar + && !child->requested_partial + && std::isnan(child->qty) + && child_qp >= 100.0 - kFullPercentEps + && std::isfinite(child->stop_price) + && std::isfinite(child->limit_price) + && std::isnan(child->profit_ticks) + && std::isnan(child->loss_ticks) + && std::isnan(child->trail_points) + && std::isnan(child->trail_price) + && std::isnan(child->trail_offset) + && child->oca_name.empty() + && child->oca_type == 0; + return parent_is_exact_fresh_stop + && child_is_exact_retained_bracket + && child->from_entry == parent->id + && child->created_seq < parent->created_seq + && child->incarnation != 0 + && parent->incarnation != 0 + && parent->incarnation + < std::numeric_limits::max() + && child->incarnation == parent->incarnation + 1; +} + // Among flat pending opposite ENTRY stop-only orders, which stop price is touched // first on the synthesized OHLC path (exactly one long + one short, both touched). // Forward-declared in with the same underlying type diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 1175fb4..29d6e0d 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -36,6 +36,7 @@ void BacktestEngine::invoke_chart_on_bar(const Bar& bar) { } } scope(chart_ema_na_warmup_); + named_entry_cancelled_incarnation_in_current_eval_.clear(); on_bar(bar); } @@ -511,6 +512,7 @@ void BacktestEngine::reset_run_state() { pending_orders_.clear(); pending_flat_market_pair_disqualified_bars_.clear(); default_flat_market_gross_disqualified_bars_.clear(); + named_entry_cancelled_incarnation_in_current_eval_.clear(); pending_close_qty_in_bar_ = 0.0; pos_view_freeze_bar_ = -1; // KI-64: fresh run starts with no frozen view sb_close_active_ = false; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index d03a7ad..5671fcf 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -86,6 +86,15 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, int qty_type) { if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return; + NamedEntryCancelContext named_cancel_context; + const auto named_cancel = + named_entry_cancelled_incarnation_in_current_eval_.find(id); + if (named_cancel + != named_entry_cancelled_incarnation_in_current_eval_.end()) { + named_cancel_context = named_cancel->second; + named_entry_cancelled_incarnation_in_current_eval_.erase(named_cancel); + } + // TradingView intraday-cap freeze gate (Pine docs: // ``strategy.risk.max_intraday_filled_orders``): // "If the limit is reached during the day, the strategy is closed @@ -311,6 +320,12 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++; order.incarnation = next_order_incarnation_++; order.created_by_same_id_replacement = preserved_seq > 0; + if (preserved_seq == 0) { + order.recreated_after_named_cancelled_entry_incarnation = + named_cancel_context.entry_incarnation; + order.named_cancel_surviving_exit_incarnation = + named_cancel_context.surviving_exit_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_; @@ -1021,9 +1036,11 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro // return follows matching-EXIT removal but precedes all sizing and // reservation work. trail_offset alone is intentionally insufficient. int64_t discarded_seq = 0; + uint64_t discarded_incarnation = 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); + discarded_seq, discarded_incarnation, + discarded_reserved_qty); return; } bool has_explicit_qty = !std::isnan(qty); @@ -1062,9 +1079,11 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro } int64_t preserved_seq = 0; + uint64_t replaced_incarnation = 0; double preserved_reserved_qty = std::numeric_limits::quiet_NaN(); clear_existing_exit_order(id, from_entry, has_trail_request, - preserved_seq, preserved_reserved_qty); + preserved_seq, replaced_incarnation, + preserved_reserved_qty); double reserved_qty = std::numeric_limits::quiet_NaN(); bool bind_global_full_exit_dynamic_qty = false; @@ -1274,6 +1293,8 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.created_bar = bar_index_; order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++; order.incarnation = next_order_incarnation_++; + order.created_by_same_id_replacement = preserved_seq > 0; + order.replaced_exit_order_incarnation = replaced_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_; @@ -1375,6 +1396,15 @@ void BacktestEngine::strategy_cancel(const std::string& id) { order.created_bar); } } + uint64_t surviving_exit_incarnation = 0; + int surviving_exit_count = 0; + for (const PendingOrder& order : pending_orders_) { + if (order.type == OrderType::EXIT && order.from_entry == id) { + ++surviving_exit_count; + surviving_exit_incarnation = order.incarnation; + } + } + uint64_t removed_priced_entry_incarnation = 0; for (const PendingOrder& order : pending_orders_) { if (order.id == id) { const bool entry_like = @@ -1392,9 +1422,20 @@ void BacktestEngine::strategy_cancel(const std::string& id) { bar_index_); } } + if (order.type == OrderType::ENTRY && order.incarnation != 0) { + removed_priced_entry_incarnation = order.incarnation; + } invalidate_pending_flat_market_pair(order.created_seq); } } + if (removed_priced_entry_incarnation != 0 + && surviving_exit_count == 1 + && surviving_exit_incarnation != 0) { + named_entry_cancelled_incarnation_in_current_eval_[id] = + {removed_priced_entry_incarnation, surviving_exit_incarnation}; + } else if (removed_priced_entry_incarnation != 0) { + named_entry_cancelled_incarnation_in_current_eval_.erase(id); + } pending_orders_.erase( std::remove_if(pending_orders_.begin(), pending_orders_.end(), [&](const PendingOrder& o) { return o.id == id; }), @@ -1835,14 +1876,17 @@ void BacktestEngine::clear_existing_exit_order(const std::string& id, const std::string& from_entry, bool has_trail_request, int64_t& preserved_seq_out, + uint64_t& replaced_incarnation_out, double& preserved_reserved_qty_out) { bool had_existing_order = false; preserved_seq_out = 0; + replaced_incarnation_out = 0; preserved_reserved_qty_out = std::numeric_limits::quiet_NaN(); for (const auto& o : pending_orders_) { if (o.type == OrderType::EXIT && o.id == id && o.from_entry == from_entry) { had_existing_order = true; preserved_seq_out = o.created_seq; + replaced_incarnation_out = o.incarnation; if (!std::isnan(o.qty)) { preserved_reserved_qty_out = o.qty; } diff --git a/tests/test_prearmed_exit_path_cursor.cpp b/tests/test_prearmed_exit_path_cursor.cpp index acacda3..4e6371c 100644 --- a/tests/test_prearmed_exit_path_cursor.cpp +++ b/tests/test_prearmed_exit_path_cursor.cpp @@ -8,13 +8,17 @@ * must exit next bar; B/D touch the stop after entry and must exit same bar. */ +#include #include #include #include +#include #include #include +#include "../src/engine_internal.hpp" + using namespace pineforge; static int tests_passed = 0; @@ -65,6 +69,466 @@ class RestingBracketProbe final : public BacktestEngine { Cell cell_; }; +// Vasudev-shaped cancellation topology. X is armed with an original E, +// survives an explicit E cancellation, then keeps its older priority when X is +// reissued alongside a freshly recreated E. Both live objects have the same +// created_bar but X still has the lower created_seq. At the next broker +// scan the legacy sequence order visits X while truly flat, skips it, and fills +// E afterwards. With POOC the close-time on_bar therefore observes the +// transient position before the second broker scan revisits X. +enum class BookVariant { + ExactPair, + FreshChild, + PostCancelDoubleReissue, + MissingParentCancel, + InterleavedThird, + MultipleChildren, +}; + +class FreshParentProbe final : public BacktestEngine { +public: + FreshParentProbe(Cell cell, int parent_first_factor, + BookVariant variant = BookVariant::ExactPair) + : cell_(cell), variant_(variant) { + 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; + if (parent_first_factor >= 0) { + set_syminfo_metadata( + "flat_retained_child_fresh_parent_order", + parent_first_factor ? 1.0 : 0.0); + } + } + + void on_bar(const Bar&) override { + const bool is_long = + cell_ == Cell::LongPre || cell_ == Cell::LongPost; + const double child_stop = is_long ? 90.0 : 110.0; + const double child_limit = is_long ? 130.0 : 70.0; + if (bar_index_ == 0) { + strategy_entry("E", is_long, kNaN, + is_long ? 130.0 : 70.0, + kNaN, "original parent"); + if (variant_ != BookVariant::FreshChild + && variant_ != BookVariant::PostCancelDoubleReissue) { + strategy_exit("X", "E", child_limit, child_stop, + kNaN, kNaN, kNaN, 100.0, + "retained child"); + } + if (variant_ == BookVariant::InterleavedThird) { + strategy_entry("U", is_long, kNaN, + is_long ? 1'000.0 : 1.0, + 1.0, "unrelated resting parent"); + } else if (variant_ == BookVariant::MultipleChildren) { + strategy_exit("Y", "E", child_limit, child_stop, + kNaN, kNaN, kNaN, 100.0, + "second retained child"); + } + } else if (bar_index_ == 1) { + for (const PendingOrder& order : pending_orders_) { + if (order.type == OrderType::ENTRY && order.id == "E") { + cancelled_parent_incarnation = order.incarnation; + } + if (order.type == OrderType::EXIT && order.id == "X" + && order.from_entry == "E") { + surviving_child_incarnation_at_cancel = + order.incarnation; + } + } + if (variant_ == BookVariant::MissingParentCancel) { + // Construct the same final topology after a non-command + // removal. The production rule must require the named-cancel + // tombstone, not merely infer cancellation from absence. + pending_orders_.erase( + std::remove_if( + pending_orders_.begin(), pending_orders_.end(), + [](const PendingOrder& order) { + return order.type == OrderType::ENTRY + && order.id == "E"; + }), + pending_orders_.end()); + } else { + strategy_cancel("E"); + } + if (variant_ == BookVariant::PostCancelDoubleReissue) { + strategy_exit("X", "E", child_limit, child_stop, + kNaN, kNaN, kNaN, 100.0, + "post-cancel fresh child"); + strategy_entry("E", is_long, kNaN, + is_long ? 110.0 : 90.0, + kNaN, "fresh parent"); + strategy_exit("X", "E", child_limit, child_stop, + kNaN, kNaN, kNaN, 100.0, + "post-cancel reissued child"); + } else if (variant_ == BookVariant::FreshChild + || variant_ == BookVariant::MissingParentCancel) { + strategy_exit("X", "E", child_limit, child_stop, + kNaN, kNaN, kNaN, 100.0, + variant_ == BookVariant::FreshChild + ? "fresh child" : "retained child"); + strategy_entry("E", is_long, kNaN, + is_long ? 110.0 : 90.0, + kNaN, "fresh parent"); + } else { + strategy_entry("E", is_long, kNaN, + is_long ? 110.0 : 90.0, + kNaN, "fresh parent"); + strategy_exit("X", "E", child_limit, child_stop, + kNaN, kNaN, kNaN, 100.0, + "retained child"); + } + if (variant_ == BookVariant::MultipleChildren) { + strategy_exit("Y", "E", child_limit, child_stop, + kNaN, kNaN, kNaN, 100.0, + "second retained child"); + } + const PendingOrder* parent = nullptr; + const PendingOrder* child = nullptr; + for (const PendingOrder& order : pending_orders_) { + if (order.type == OrderType::ENTRY && order.id == "E") { + parent = ℴ + } + if (order.type == OrderType::EXIT && order.id == "X" + && order.from_entry == "E") { + child = ℴ + } + } + pending_book_size_on_reissue = pending_orders_.size(); + fresh_parent_shape_seen = parent != nullptr && child != nullptr + && !parent->created_by_same_id_replacement + && child->created_seq < parent->created_seq + && child->created_bar == parent->created_bar; + parent_cancel_provenance_seen = parent != nullptr + && parent->recreated_after_named_cancelled_entry_incarnation + != 0; + parent_cancel_token_exact = parent != nullptr + && cancelled_parent_incarnation != 0 + && parent->recreated_after_named_cancelled_entry_incarnation + == cancelled_parent_incarnation; + parent_cancel_child_token_exact = parent != nullptr + && surviving_child_incarnation_at_cancel != 0 + && parent->named_cancel_surviving_exit_incarnation + == surviving_child_incarnation_at_cancel; + cancel_token_consumed = + named_entry_cancelled_incarnation_in_current_eval_.find("E") + == named_entry_cancelled_incarnation_in_current_eval_.end(); + parent_then_child_incarnations = parent != nullptr + && child != nullptr + && parent->incarnation + < std::numeric_limits::max() + && child->incarnation == parent->incarnation + 1; + child_reissue_provenance_seen = child != nullptr + && child->created_by_same_id_replacement; + child_replacement_token_exact = child != nullptr + && parent != nullptr + && surviving_child_incarnation_at_cancel != 0 + && parent->named_cancel_surviving_exit_incarnation + == surviving_child_incarnation_at_cancel + && child->replaced_exit_order_incarnation + == surviving_child_incarnation_at_cancel; + } else if (bar_index_ == 2) { + position_seen_on_trigger_bar = signed_position_size(); + } + } + + bool fresh_parent_shape_seen = false; + bool parent_cancel_provenance_seen = false; + bool parent_cancel_token_exact = false; + bool parent_cancel_child_token_exact = false; + bool cancel_token_consumed = false; + bool parent_then_child_incarnations = false; + bool child_reissue_provenance_seen = false; + bool child_replacement_token_exact = false; + uint64_t cancelled_parent_incarnation = 0; + uint64_t surviving_child_incarnation_at_cancel = 0; + std::size_t pending_book_size_on_reissue = 0; + double position_seen_on_trigger_bar = kNaN; + +private: + Cell cell_; + BookVariant variant_; +}; + +class CancelTokenScopeProbe final : public BacktestEngine { +public: + CancelTokenScopeProbe() { + process_orders_on_close_ = true; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("E", true, kNaN, 130.0, kNaN, + "cancelled parent"); + strategy_exit("X", "E", 130.0, 90.0, + kNaN, kNaN, kNaN, 100.0, + "surviving child"); + for (const PendingOrder& order : pending_orders_) { + if (order.type == OrderType::ENTRY && order.id == "E") { + cancelled_incarnation = order.incarnation; + } + if (order.type == OrderType::EXIT && order.id == "X") { + surviving_child_incarnation = order.incarnation; + } + } + strategy_cancel("E"); + const auto token = + named_entry_cancelled_incarnation_in_current_eval_.find("E"); + same_eval_token_seen = token + != named_entry_cancelled_incarnation_in_current_eval_.end() + && token->second.entry_incarnation == cancelled_incarnation + && token->second.surviving_exit_incarnation + == surviving_child_incarnation; + } else if (bar_index_ == 1) { + token_cleared_before_next_eval = + named_entry_cancelled_incarnation_in_current_eval_.empty(); + strategy_entry("E", true, kNaN, 130.0, kNaN, + "later fresh parent"); + for (const PendingOrder& order : pending_orders_) { + if (order.type == OrderType::ENTRY && order.id == "E") { + later_parent_has_no_token = + order.recreated_after_named_cancelled_entry_incarnation + == 0; + } + } + } + } + + uint64_t cancelled_incarnation = 0; + uint64_t surviving_child_incarnation = 0; + bool same_eval_token_seen = false; + bool token_cleared_before_next_eval = false; + bool later_parent_has_no_token = false; +}; + +enum class SortMutation { + ExactDefaultOn, + FactorOff, + BrokerLive, + NonPooc, + CalcOnFills, + CoofScheduler, + Magnifier, + StreamWarmup, + StreamRealtime, + ParentReplacement, + MissingCancelToken, + MissingSurvivingChildToken, + MismatchedChildReplacementToken, + CancelTokenEqualsParent, + CancelTokenEqualsChild, + ParentCreatedLive, + ChildCreatedLive, + ParentAfterClose, + ChildAfterClose, + ParentStopLimitActivated, + ChildDifferentCreatedBar, + ParentMissingStop, + ParentHasLimit, + ExplicitParentQty, + ExplicitChildQty, + FreshChild, + ChildRequestedPartial, + ChildPercentPartial, + TrailingChild, + ChildOcaName, + ChildOcaType, + ProfitRelativeChild, + LossRelativeChild, + MismatchedFromEntry, + ChildZeroIncarnation, + ParentZeroIncarnation, + EqualIncarnations, + ChildReissuedBeforeParent, + InterveningIncarnation, + NormalSourceOrder, +}; + +static bool retained_child_predicate_accepts(SortMutation mutation) { + internal::RetainedChildFreshParentOrderContext context{ + true, // enabled + true, // broker flat + true, // POOC + false, // COOF option + false, // COOF scheduler + false, // magnifier + false, // stream warmup + true, // stream idle + 2, + 2, + }; + + PendingOrder child{}; + child.id = "X"; + child.from_entry = "E"; + child.type = OrderType::EXIT; + child.created_seq = 1; + child.incarnation = 12; + child.created_by_same_id_replacement = true; + child.replaced_exit_order_incarnation = 10; + child.created_bar = 1; + child.created_position_side = PositionSide::FLAT; + child.qty = kNaN; + child.qty_percent = 100.0; + child.stop_price = 90.0; + child.limit_price = 130.0; + child.profit_ticks = kNaN; + child.loss_ticks = kNaN; + child.trail_points = kNaN; + child.trail_price = kNaN; + child.trail_offset = kNaN; + + PendingOrder parent{}; + parent.id = "E"; + parent.type = OrderType::ENTRY; + parent.created_seq = 2; + parent.incarnation = 11; + parent.recreated_after_named_cancelled_entry_incarnation = 9; + parent.named_cancel_surviving_exit_incarnation = 10; + parent.created_bar = 1; + parent.created_position_side = PositionSide::FLAT; + parent.qty = kNaN; + parent.stop_price = 110.0; + parent.limit_price = kNaN; + parent.trail_points = kNaN; + parent.trail_price = kNaN; + parent.trail_offset = kNaN; + + switch (mutation) { + case SortMutation::ExactDefaultOn: + break; + case SortMutation::FactorOff: + context.enabled = false; + break; + case SortMutation::BrokerLive: + context.broker_flat = false; + break; + case SortMutation::NonPooc: + context.process_orders_on_close = false; + break; + case SortMutation::CalcOnFills: + context.calc_on_order_fills = true; + break; + case SortMutation::CoofScheduler: + context.coof_scheduler_active = true; + break; + case SortMutation::Magnifier: + context.bar_magnifier_enabled = true; + break; + case SortMutation::StreamWarmup: + context.stream_warmup_mode = true; + break; + case SortMutation::StreamRealtime: + context.stream_idle = false; + break; + case SortMutation::ParentReplacement: + parent.created_by_same_id_replacement = true; + break; + case SortMutation::MissingCancelToken: + parent.recreated_after_named_cancelled_entry_incarnation = 0; + break; + case SortMutation::MissingSurvivingChildToken: + parent.named_cancel_surviving_exit_incarnation = 0; + break; + case SortMutation::MismatchedChildReplacementToken: + child.replaced_exit_order_incarnation = 8; + break; + case SortMutation::CancelTokenEqualsParent: + parent.recreated_after_named_cancelled_entry_incarnation = + parent.incarnation; + break; + case SortMutation::CancelTokenEqualsChild: + parent.recreated_after_named_cancelled_entry_incarnation = + child.incarnation; + break; + case SortMutation::ParentCreatedLive: + parent.created_position_side = PositionSide::LONG; + break; + case SortMutation::ChildCreatedLive: + child.created_position_side = PositionSide::LONG; + break; + case SortMutation::ParentAfterClose: + parent.created_after_position_close_in_bar = true; + break; + case SortMutation::ChildAfterClose: + child.created_after_position_close_in_bar = true; + break; + case SortMutation::ParentStopLimitActivated: + parent.stop_limit_activated = true; + break; + case SortMutation::ChildDifferentCreatedBar: + child.created_bar = 0; + break; + case SortMutation::ParentMissingStop: + parent.stop_price = kNaN; + break; + case SortMutation::ParentHasLimit: + parent.limit_price = 110.0; + break; + case SortMutation::ExplicitParentQty: + parent.qty = 1.0; + break; + case SortMutation::ExplicitChildQty: + child.qty = 1.0; + break; + case SortMutation::FreshChild: + child.created_by_same_id_replacement = false; + break; + case SortMutation::ChildRequestedPartial: + child.requested_partial = true; + break; + case SortMutation::ChildPercentPartial: + child.qty_percent = 50.0; + break; + case SortMutation::TrailingChild: + child.trail_points = 10.0; + break; + case SortMutation::ChildOcaName: + child.oca_name = "group"; + break; + case SortMutation::ChildOcaType: + child.oca_type = 1; + break; + case SortMutation::ProfitRelativeChild: + child.profit_ticks = 10.0; + break; + case SortMutation::LossRelativeChild: + child.loss_ticks = 10.0; + break; + case SortMutation::MismatchedFromEntry: + child.from_entry = "OTHER"; + break; + case SortMutation::ChildZeroIncarnation: + child.incarnation = 0; + break; + case SortMutation::ParentZeroIncarnation: + parent.incarnation = 0; + break; + case SortMutation::EqualIncarnations: + parent.incarnation = child.incarnation; + break; + case SortMutation::ChildReissuedBeforeParent: + parent.incarnation = 12; + child.incarnation = 11; + break; + case SortMutation::InterveningIncarnation: + child.incarnation = 13; + break; + case SortMutation::NormalSourceOrder: + child.created_seq = 2; + parent.created_seq = 1; + break; + } + return internal::retained_child_fresh_parent_order_pair( + context, &parent, &child); +} + static Bar bar(double o, double h, double l, double c, int64_t ts) { return {o, h, l, c, 1'000.0, ts}; } @@ -102,6 +566,189 @@ static void check_cell(Cell cell, bool is_long, bool pre_entry_touch) { CHECK(trade.exit_bar_index == (pre_entry_touch ? 2 : 1)); } +static void check_fresh_parent_cell(Cell cell, bool is_long, + bool pre_entry_touch, + int parent_first_factor) { + FreshParentProbe probe(cell, parent_first_factor); + Bar bars[4] = { + bar(100.0, 101.0, 99.0, 100.0, 900'000), + bar(100.0, 105.0, 95.0, 100.0, 1'800'000), + cell == Cell::LongPre + ? bar(100.0, 120.0, 80.0, 105.0, 2'700'000) + : cell == Cell::LongPost + ? bar(100.0, 115.0, 80.0, 105.0, 2'700'000) + : cell == Cell::ShortPre + ? bar(100.0, 115.0, 80.0, 95.0, 2'700'000) + : bar(100.0, 120.0, 85.0, 95.0, 2'700'000), + is_long + ? bar(105.0, 108.0, 85.0, 95.0, 3'600'000) + : bar(95.0, 115.0, 90.0, 100.0, 3'600'000), + }; + + probe.run(bars, 4); + + CHECK(probe.last_error().empty()); + CHECK(probe.fresh_parent_shape_seen); + CHECK(probe.parent_cancel_provenance_seen); + CHECK(probe.parent_cancel_token_exact); + CHECK(probe.parent_cancel_child_token_exact); + CHECK(probe.cancel_token_consumed); + CHECK(probe.parent_then_child_incarnations); + CHECK(probe.child_reissue_provenance_seen); + CHECK(probe.child_replacement_token_exact); + CHECK(probe.pending_book_size_on_reissue == 2); + const double signed_open_qty = is_long ? 1.0 : -1.0; + const bool parent_first_enabled = parent_first_factor != 0; + const double expected_visible_qty = + parent_first_enabled && !pre_entry_touch ? 0.0 : signed_open_qty; + CHECK(near(probe.position_seen_on_trigger_bar, expected_visible_qty)); + 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 == 2); + CHECK(trade.exit_bar_index == (pre_entry_touch ? 3 : 2)); +} + +static void check_ambiguous_book_is_inert(BookVariant variant, bool is_long) { + const Cell cell = is_long ? Cell::LongPost : Cell::ShortPost; + FreshParentProbe probe(cell, /*parent_first_factor=*/true, variant); + Bar bars[4] = { + bar(100.0, 101.0, 99.0, 100.0, 900'000), + bar(100.0, 105.0, 95.0, 100.0, 1'800'000), + is_long + ? bar(100.0, 115.0, 80.0, 105.0, 2'700'000) + : bar(100.0, 120.0, 85.0, 95.0, 2'700'000), + is_long + ? bar(105.0, 108.0, 85.0, 95.0, 3'600'000) + : bar(95.0, 115.0, 90.0, 100.0, 3'600'000), + }; + + probe.run(bars, 4); + + CHECK(probe.last_error().empty()); + CHECK(probe.fresh_parent_shape_seen); + if (variant == BookVariant::InterleavedThird) { + CHECK(probe.parent_cancel_provenance_seen); + CHECK(probe.parent_cancel_token_exact); + CHECK(probe.parent_cancel_child_token_exact); + } else { + CHECK(!probe.parent_cancel_provenance_seen); + CHECK(!probe.parent_cancel_token_exact); + CHECK(!probe.parent_cancel_child_token_exact); + } + CHECK(probe.cancel_token_consumed); + CHECK(probe.parent_then_child_incarnations); + CHECK(probe.child_reissue_provenance_seen); + CHECK(probe.child_replacement_token_exact + == (variant == BookVariant::InterleavedThird)); + CHECK(probe.pending_book_size_on_reissue == 3); + CHECK(near(probe.position_seen_on_trigger_bar, is_long ? 1.0 : -1.0)); + CHECK(probe.trade_count() == 1); +} + +static void check_missing_provenance_is_inert(BookVariant variant, + bool is_long) { + const Cell cell = is_long ? Cell::LongPost : Cell::ShortPost; + FreshParentProbe probe(cell, /*parent_first_factor=*/true, variant); + Bar bars[4] = { + bar(100.0, 101.0, 99.0, 100.0, 900'000), + bar(100.0, 105.0, 95.0, 100.0, 1'800'000), + is_long + ? bar(100.0, 115.0, 80.0, 105.0, 2'700'000) + : bar(100.0, 120.0, 85.0, 95.0, 2'700'000), + is_long + ? bar(105.0, 108.0, 85.0, 95.0, 3'600'000) + : bar(95.0, 115.0, 90.0, 100.0, 3'600'000), + }; + + probe.run(bars, 4); + + CHECK(probe.last_error().empty()); + CHECK(probe.fresh_parent_shape_seen); + CHECK(probe.pending_book_size_on_reissue == 2); + CHECK(!probe.parent_cancel_provenance_seen); + CHECK(!probe.parent_cancel_token_exact); + CHECK(!probe.parent_cancel_child_token_exact); + CHECK(!probe.child_replacement_token_exact); + if (variant == BookVariant::FreshChild) { + CHECK(!probe.child_reissue_provenance_seen); + } else { + CHECK(probe.child_reissue_provenance_seen); + } + CHECK(probe.cancel_token_consumed); + CHECK(probe.parent_then_child_incarnations + == (variant == BookVariant::PostCancelDoubleReissue)); + CHECK(near(probe.position_seen_on_trigger_bar, is_long ? 1.0 : -1.0)); + CHECK(probe.trade_count() == 1); +} + +static void check_sort_scope_guards() { + CHECK(retained_child_predicate_accepts( + SortMutation::ExactDefaultOn)); + + for (SortMutation mutation : { + SortMutation::FactorOff, + SortMutation::BrokerLive, + SortMutation::NonPooc, + SortMutation::CalcOnFills, + SortMutation::CoofScheduler, + SortMutation::Magnifier, + SortMutation::StreamWarmup, + SortMutation::StreamRealtime, + SortMutation::ParentReplacement, + SortMutation::MissingCancelToken, + SortMutation::MissingSurvivingChildToken, + SortMutation::MismatchedChildReplacementToken, + SortMutation::CancelTokenEqualsParent, + SortMutation::CancelTokenEqualsChild, + SortMutation::ParentCreatedLive, + SortMutation::ChildCreatedLive, + SortMutation::ParentAfterClose, + SortMutation::ChildAfterClose, + SortMutation::ParentStopLimitActivated, + SortMutation::ChildDifferentCreatedBar, + SortMutation::ParentMissingStop, + SortMutation::ParentHasLimit, + SortMutation::ExplicitParentQty, + SortMutation::ExplicitChildQty, + SortMutation::FreshChild, + SortMutation::ChildRequestedPartial, + SortMutation::ChildPercentPartial, + SortMutation::TrailingChild, + SortMutation::ChildOcaName, + SortMutation::ChildOcaType, + SortMutation::ProfitRelativeChild, + SortMutation::LossRelativeChild, + SortMutation::MismatchedFromEntry, + SortMutation::ChildZeroIncarnation, + SortMutation::ParentZeroIncarnation, + SortMutation::EqualIncarnations, + SortMutation::ChildReissuedBeforeParent, + SortMutation::InterveningIncarnation, + SortMutation::NormalSourceOrder, + }) { + CHECK(!retained_child_predicate_accepts(mutation)); + } +} + +static void check_cancel_token_scope() { + CancelTokenScopeProbe probe; + Bar bars[2] = { + bar(100.0, 101.0, 99.0, 100.0, 900'000), + bar(100.0, 101.0, 99.0, 100.0, 1'800'000), + }; + probe.run(bars, 2); + CHECK(probe.last_error().empty()); + CHECK(probe.cancelled_incarnation != 0); + CHECK(probe.surviving_child_incarnation != 0); + CHECK(probe.same_eval_token_seen); + CHECK(probe.token_cleared_before_next_eval); + CHECK(probe.later_parent_has_no_token); +} + int main() { std::printf("pre-armed from_entry bracket path cursor\n"); check_cell(Cell::LongPre, true, true); @@ -109,6 +756,37 @@ int main() { check_cell(Cell::ShortPre, false, true); check_cell(Cell::ShortPost, false, false); + std::printf("retained child / fresh parent broker order\n"); + for (bool factor : {false, true}) { + check_fresh_parent_cell(Cell::LongPre, true, true, factor); + check_fresh_parent_cell(Cell::LongPost, true, false, factor); + check_fresh_parent_cell(Cell::ShortPre, false, true, factor); + check_fresh_parent_cell(Cell::ShortPost, false, false, factor); + } + check_fresh_parent_cell(Cell::LongPost, true, false, + /*production default=*/-1); + check_fresh_parent_cell(Cell::ShortPost, false, false, + /*production default=*/-1); + + std::printf("ambiguous retained-child books stay inert\n"); + for (bool is_long : {true, false}) { + check_ambiguous_book_is_inert(BookVariant::InterleavedThird, is_long); + check_ambiguous_book_is_inert(BookVariant::MultipleChildren, is_long); + } + + std::printf("missing lifecycle provenance stays inert\n"); + for (bool is_long : {true, false}) { + check_missing_provenance_is_inert(BookVariant::FreshChild, is_long); + check_missing_provenance_is_inert( + BookVariant::PostCancelDoubleReissue, is_long); + check_missing_provenance_is_inert( + BookVariant::MissingParentCancel, is_long); + } + + std::printf("exact scope guards and default-on behavior\n"); + check_sort_scope_guards(); + check_cancel_token_scope(); + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; }