From 930b67e38000e4317b05f77e4ce9dc6c3e253be3 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Fri, 17 Jul 2026 01:18:14 +0800 Subject: [PATCH] fix: preserve flat all-in entry transaction sizing --- include/pineforge/engine.hpp | 30 +- src/engine_fills.cpp | 245 +++++++- src/engine_orders.cpp | 10 +- src/engine_run.cpp | 1 + src/engine_strategy_commands.cpp | 111 ++++ tests/CMakeLists.txt | 2 + ...st_default_flat_market_gross_admission.cpp | 538 ++++++++++++++++++ tests/test_stop_entry_placement_open_qty.cpp | 478 ++++++++++++++++ 8 files changed, 1403 insertions(+), 12 deletions(-) create mode 100644 tests/test_default_flat_market_gross_admission.cpp create mode 100644 tests/test_stop_entry_placement_open_qty.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index f8706b1..40211f5 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -328,6 +328,12 @@ struct PendingOrder { // NaN means ordinary strategy.entry reversal semantics. double paired_flat_market_transaction_qty = std::numeric_limits::quiet_NaN(); + // Candidate provenance for the narrow omitted-qty, PoE=100, true-flat + // MARKET/MARKET admission rule. Finalization waits for the complete source- + // bar book and considers exactly two fresh, consecutive, distinct-id, + // opposite entries. Without an over-equity gross transaction this remains + // metadata only and does not change dispatch. + bool default_flat_market_gross_candidate = false; // Snapshot of the position's quantity at the moment this order was // PLACED (0 if placed from flat). Used by execute_market_entry's // flat branch to apply TradingView's deferred-flip growth rule: @@ -371,6 +377,18 @@ struct PendingOrder { // flips every one of them. Keep ``qty`` NaN; read this field only where a // quantity is actually computed. double frozen_default_qty = std::numeric_limits::quiet_NaN(); + // Placement snapshot for one narrowly scoped default-sized pure STOP. A + // next-bar open-marketable fill uses this already-quantized quantity for + // both margin admission and dispatch. Intrabar touches and every other + // priced-entry shape remain fill-time-sized. Keeping it separate from + // frozen_default_qty prevents generic MARKET consumers from widening the + // rule. NaN means ordinary stop sizing/admission. + double stop_placement_open_qty = + std::numeric_limits::quiet_NaN(); + double stop_placement_open_equity = + std::numeric_limits::quiet_NaN(); + double stop_placement_signal_close = + std::numeric_limits::quiet_NaN(); // TV margin-admission snapshot for a FROZEN default-sized market order // (KI-54). Captured at the same placement point as frozen_default_qty: // sizing_equity = current_equity() + open_profit(close(S)) [account ccy] @@ -927,6 +945,11 @@ class BacktestEngine { // different calls and is outside both exact two-call oracles (KI-65 and // terminal-C POOC+COOF), so finalization must leave it ordinary. std::unordered_set pending_flat_market_pair_disqualified_bars_; + // A mutation or non-candidate entry-like call on a source bar prevents two + // surviving default MARKET objects from impersonating the original exact + // two-call book. + std::unordered_set + default_flat_market_gross_disqualified_bars_; // strategy.exit partial orders are one-shot per open position for a given id std::unordered_set consumed_partial_exit_ids_; @@ -2100,7 +2123,8 @@ class BacktestEngine { double tv_carry_qty = 0.0, int created_bar = -1, bool later_same_tick_entry = false, - bool paired_flat_market_transaction = false); + bool paired_flat_market_transaction = false, + bool explicit_qty_prequantized = false); void execute_market_exit(double fill_price); void execute_partial_exit_qty(double fill_price, double qty_to_close); void execute_partial_exit(double fill_price, double qty_percent); @@ -2128,6 +2152,8 @@ class BacktestEngine { void update_trail_best_for_bar_open(const Bar& bar); void sort_exit_siblings_by_path_fill(const Bar& bar); bool pending_flat_market_pair_scope_is_live() const; + bool default_flat_market_gross_scope_is_live() const; + void finalize_default_flat_market_gross_admission(); void apply_pooc_coof_explicit_flat_market_gross_admission(); void finalize_pending_flat_market_pairs(const Bar& bar); void sort_orders_by_fill_phase(const Bar& bar); @@ -2161,6 +2187,8 @@ class BacktestEngine { std::vector& filled_indices); bool stop_entry_margin_admission_declines( const PendingOrder& order, double fill_price, const Bar& bar) const; + bool use_stop_placement_open_qty( + const PendingOrder& order, double fill_price, const Bar& bar) const; // design-declined-reversal-close-leg: called at the KI-54 reversal-decline // site with the just-declined MARKET reversal entry. Flags every pending // FULL close that was co-queued after it on the same bar against the held diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index ed0fe9b..a7ff789 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -113,6 +113,7 @@ bool internal::dual_stop_margin_decline_can_continue_path( void BacktestEngine::process_pending_orders(const Bar& bar) { // Update risk state update_risk_state(); + finalize_default_flat_market_gross_admission(); finalize_pending_flat_market_pairs(bar); double trail_best_path_state = trail_best_price_; @@ -810,6 +811,165 @@ bool BacktestEngine::pending_flat_market_pair_scope_is_live() const { && !risk_halted_; } +bool BacktestEngine::default_flat_market_gross_scope_is_live() + const { + return !process_orders_on_close_ + && !calc_on_order_fills_ + && !bar_magnifier_enabled_ + && !coof_fill_recalc_active_ + && position_side_ == PositionSide::FLAT + // Pine's default pyramiding=0 is represented by one admitted entry. + // Keep this scope away from KI-65's independently pinned pyramiding=2 + // transaction model. + && pyramiding_ == 1 + && slippage_ == 0 + && commission_value_ == 0.0 + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && std::abs(margin_long_ - 100.0) < 1e-12 + && std::abs(margin_short_ - 100.0) < 1e-12 + && risk_direction_ == RiskDirection::BOTH + && risk_max_cons_loss_days_ == 0 + && risk_max_drawdown_ <= 0.0 + && risk_max_intraday_loss_ <= 0.0 + && risk_max_position_size_ <= 0.0 + && max_intraday_filled_orders_ <= 0 + && !risk_halted_; +} + +// Two omitted-qty MARKET strategy.entry calls from true flat each freeze one +// account-equity lot at the same signal close. The later opposite call is a +// gross reversal transaction (first frozen qty + later frozen qty); at 1x and +// PoE=100 that exceeds placement equity and is declined. Wait until the next +// ordinary broker boundary so the complete source-bar book is known. Only an +// exact two-object book of fresh, consecutive, distinct-id opposite entries +// reaches this arithmetic; the first order follows existing fill rules. +void BacktestEngine::finalize_default_flat_market_gross_admission() { + std::vector group; + group.reserve(2); + for (size_t i = 0; i < pending_orders_.size(); ++i) { + if (pending_orders_[i] + .default_flat_market_gross_candidate) { + group.push_back(i); + } + } + + if (group.empty()) { + for (auto it = + default_flat_market_gross_disqualified_bars_.begin(); + it != default_flat_market_gross_disqualified_bars_.end();) { + if (*it < bar_index_) { + it = default_flat_market_gross_disqualified_bars_ + .erase(it); + } else { + ++it; + } + } + return; + } + + std::unordered_set candidate_source_bars; + for (size_t index : group) { + candidate_source_bars.insert(pending_orders_[index].created_bar); + // One broker boundary owns one adjudication. An admitted/non-exact + // order must never be reconsidered on a later bar. + pending_orders_[index] + .default_flat_market_gross_candidate = false; + } + + auto consume_source_tombstones = [&]() { + for (int source_bar : candidate_source_bars) { + default_flat_market_gross_disqualified_bars_.erase( + source_bar); + } + }; + + if (!default_flat_market_gross_scope_is_live() + || pending_orders_.size() != 2 + || group.size() != 2 + || candidate_source_bars.size() != 1) { + consume_source_tombstones(); + return; + } + + PendingOrder* first = &pending_orders_[group[0]]; + PendingOrder* second = &pending_orders_[group[1]]; + if (second->incarnation < first->incarnation) std::swap(first, second); + const int source_bar = first->created_bar; + + auto eligible = [&](const PendingOrder& order) { + return order.type == OrderType::MARKET + && std::isnan(order.qty) + && std::isfinite(order.frozen_default_qty) + && order.frozen_default_qty > kQtyEpsilon + && order.opening_affordability_exemption_candidate + && !order.explicit_flat_admission_candidate + && !order.paired_flat_market_candidate + && order.paired_flat_market_peer_seq == 0 + && order.oca_name.empty() + && order.created_bar == source_bar + && order.incarnation > 0 + && order.created_seq > 0 + && !order.created_by_same_id_replacement + && order.created_position_side == PositionSide::FLAT + && !order.created_after_position_close_in_bar + && !order.created_during_coof_recalc + && !order.coof_born_at_close_recalc + && !order.coof_born_mid_bar + && std::isfinite(order.sizing_equity) + && order.sizing_equity > 0.0 + && std::isfinite(order.sizing_mark) + && order.sizing_mark > 0.0; + }; + + const bool source_bar_disqualified = + default_flat_market_gross_disqualified_bars_.erase( + source_bar) > 0; + if (source_bar_disqualified + || !eligible(*first) + || !eligible(*second) + || first->id == second->id + || first->is_long == second->is_long + || second->incarnation != first->incarnation + 1 + || second->created_seq != first->created_seq + 1) { + return; + } + + const double equity = second->sizing_equity; + const double signal_close = second->sizing_mark; + const double equity_guard = std::max( + 1e-9, std::abs(equity) * 1e-12); + const double price_guard = std::max( + 1e-12, std::abs(signal_close) * 1e-12); + if (std::abs(first->sizing_equity - equity) > equity_guard + || std::abs(first->sizing_mark - signal_close) > price_guard) { + return; + } + + const double first_qty = std::abs(first->frozen_default_qty); + const double second_qty = std::abs(second->frozen_default_qty); + if (calc_commission(signal_close, first_qty) != 0.0 + || calc_commission(signal_close, second_qty) != 0.0) { + return; + } + const double notional_k = syminfo_.pointvalue * account_currency_fx_; + if (!std::isfinite(notional_k) || !(notional_k > 0.0)) return; + + const double gross_required = + (first_qty + second_qty) * signal_close * notional_k; + if (!(gross_required > equity + equity_guard)) return; + + const uint64_t rejected_incarnation = second->incarnation; + invalidate_pending_flat_market_pair(second->created_seq); + pending_orders_.erase( + std::remove_if( + pending_orders_.begin(), pending_orders_.end(), + [&](const PendingOrder& order) { + return order.incarnation == rejected_incarnation; + }), + pending_orders_.end()); +} + // TradingView admission for the exact Fran-470 terminal-close shape. Two // distinct explicit-FIXED opposite MARKET strategy.entry calls are emitted // from true flat in one ordinary historical evaluation with both POOC and COOF @@ -1541,6 +1701,57 @@ void BacktestEngine::compact_filled_pending_orders( } +bool BacktestEngine::use_stop_placement_open_qty( + const PendingOrder& order, double fill_price, const Bar& bar) const { + if (order.type != OrderType::ENTRY + || std::isnan(order.stop_price) + || !std::isnan(order.limit_price) + || !std::isfinite(bar.open) + || bar.open <= 0.0) { + return false; + } + const double margin_pct = order.is_long ? margin_long_ : margin_short_; + // A candidate snapshot is usable only on the immediately following bar + // when the pure STOP is already marketable at O and therefore fills exactly + // at O. Re-prove the ordinary scheduler and unchanged true-flat equity at + // consumption time; any intervening fill, delayed resting order, intrabar + // touch, OCA link, commission/slippage, or non-default sizing falls back to + // established fill-time sizing. This covers favorable and adverse gaps: + // both carry the placement qty, while only an adverse notional overshoot is + // declined by the caller's margin comparison. + const bool gap_open_marketable = + order.is_long ? bar.open >= order.stop_price + : bar.open <= order.stop_price; + const double placement_equity = + order.stop_placement_open_equity; + const double equity_guard = std::isfinite(placement_equity) + ? std::max(1e-9, std::abs(placement_equity) * 1e-12) + : 0.0; + return std::isfinite(order.stop_placement_open_qty) + && order.stop_placement_open_qty > 0.0 + && std::isfinite(placement_equity) && placement_equity > 0.0 + && std::isfinite(order.stop_placement_signal_close) + && order.created_position_side == PositionSide::FLAT + && !order.created_after_position_close_in_bar + && position_side_ == PositionSide::FLAT + && order.created_bar == bar_index_ - 1 + && !process_orders_on_close_ + && !calc_on_order_fills_ + && !bar_magnifier_enabled_ + && !order.created_during_coof_recalc + && order.oca_name.empty() && order.oca_type == 0 + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && std::abs(margin_pct - 100.0) < 1e-12 + && slippage_ == 0 + && gap_open_marketable + && fill_price == bar.open + && std::abs(current_equity() - placement_equity) <= equity_guard + && calc_commission( + fill_price, order.stop_placement_open_qty) == 0.0; +} + + bool BacktestEngine::stop_entry_margin_admission_declines( const PendingOrder& order, double fill_price, const Bar& bar) const { if (order.type != OrderType::ENTRY @@ -1552,11 +1763,16 @@ bool BacktestEngine::stop_entry_margin_admission_declines( if (!(margin_pct > 0.0) || !std::isfinite(bar.open) || bar.open <= 0.0) { return false; } - const double fill_qty = - std::abs(calc_qty_for_type(fill_price, order.qty, order.qty_type)); + const bool use_placement_open_qty = + use_stop_placement_open_qty(order, fill_price, bar); + const double fill_qty = use_placement_open_qty + ? std::abs(order.stop_placement_open_qty) + : std::abs(calc_qty_for_type(fill_price, order.qty, order.qty_type)); const double required = fill_qty * bar.open * syminfo_.pointvalue * account_currency_fx_ * (margin_pct / 100.0); - const double available = current_equity(); + const double available = use_placement_open_qty + ? order.stop_placement_open_equity + : current_equity(); const double eps = std::max(1e-9, std::abs(available) * 1e-12); return fill_qty > 0.0 && required > available + eps; } @@ -1638,9 +1854,11 @@ void BacktestEngine::apply_filled_order_to_state( // realized-only (current_equity()) — the engine's validated stop-entry // sizing basis (3,160/3,160). Does NOT touch the :443 created_bar // eligibility, the signal-time MARKET-only gate, or any margin=0 path (all - // byte-identical when margin_pct==0). The qty is exactly the fill kernel's - // (calc_qty_for_type at the fill price) so the gate cannot diverge from the - // fill it guards. + // byte-identical when margin_pct==0). The qty is exactly the fill kernel's: + // normally calc_qty_for_type at the fill price; for the narrowly scoped + // next-open STOP rule, the same placement snapshot that dispatch consumes + // below. Admission therefore never approves one quantity and executes + // another. if (stop_entry_margin_admission_declines(order, fill_price, bar)) { decline_and_cancel(); return; @@ -2749,11 +2967,22 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri && 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, + const bool use_placement_open_qty = + use_stop_placement_open_qty(order, fill_price, bar); + const double dispatch_qty = use_placement_open_qty + ? order.stop_placement_open_qty + : order.qty; + const int dispatch_qty_type = use_placement_open_qty ? -1 : order.qty_type; + execute_market_entry(order.id, order.is_long, fill_price, + dispatch_qty, dispatch_qty_type, order.created_position_side, close_only_opposite, /*is_priced_entry=*/true, order.tv_carry_qty, - order.created_bar); + order.created_bar, + /*later_same_tick_entry=*/false, + /*paired_flat_market_transaction=*/false, + /*explicit_qty_prequantized=*/ + use_placement_open_qty); bool did_execute = (position_side_ != side_before) diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index f028faa..115b367 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -69,7 +69,8 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d double tv_carry_qty, int created_bar, bool later_same_tick_entry, - bool paired_flat_market_transaction) { + bool paired_flat_market_transaction, + bool explicit_qty_prequantized) { // Degenerate-bar guard: never open a position at a non-finite fill price // (e.g. a NaN/Inf print). Dropping the fill keeps trade output finite and // a single bad tick from poisoning the backtest. Clean feeds never hit this. @@ -100,7 +101,8 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d created_position_side, is_priced_entry, tv_carry_qty, created_bar, /*explicit_qty_prequantized=*/ - paired_flat_market_transaction); + (explicit_qty_prequantized + || paired_flat_market_transaction)); return; } @@ -114,7 +116,9 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d close_opposite_then_enter( id, is_long, fill_price, explicit_qty, explicit_qty_type, /*purge_pending_exits=*/!paired_flat_market_transaction, - /*explicit_qty_prequantized=*/paired_flat_market_transaction); + /*explicit_qty_prequantized=*/ + (explicit_qty_prequantized + || paired_flat_market_transaction)); return; } diff --git a/src/engine_run.cpp b/src/engine_run.cpp index b333758..1175fb4 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -510,6 +510,7 @@ void BacktestEngine::reset_run_state() { // pyramid_entries_, trail, partial ids pending_orders_.clear(); pending_flat_market_pair_disqualified_bars_.clear(); + default_flat_market_gross_disqualified_bars_.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 01b2bde..d03a7ad 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -76,6 +76,7 @@ inline bool trading_is_active(int64_t current_ms, int64_t start_ms, : 0; return current_ms >= start_ms - buffer_ms; } + } void BacktestEngine::strategy_entry(const std::string& id, bool is_long, @@ -135,6 +136,40 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, explicit_fixed_market_call && pooc_coof_ordinary_flat_market_call; + // Preserve an exact omitted-qty MARKET pair's call provenance until the + // next broker boundary, where the complete source-bar book is known. + const bool default_flat_market_gross_call = + default_flat_market_gross_scope_is_live() + && std::isnan(qty) + && std::isnan(limit_price) + && std::isnan(stop_price) + && oca_name.empty(); + if (default_flat_market_gross_scope_is_live()) { + int current_bar_entry_like = 0; + bool has_non_candidate_entry_like = false; + for (const PendingOrder& pending : pending_orders_) { + if (pending.created_bar != bar_index_) continue; + const bool entry_like = + pending.type == OrderType::MARKET + || pending.type == OrderType::ENTRY + || pending.type == OrderType::RAW_ORDER; + if (!entry_like) continue; + ++current_bar_entry_like; + if (!pending.default_flat_market_gross_candidate) { + has_non_candidate_entry_like = true; + } + } + // An ineligible call, an admitted non-candidate sibling, or a third + // candidate call makes this source bar permanently non-exact even if a + // later rejection/cancel/replacement reduces the live book back to 2. + if (!default_flat_market_gross_call + || has_non_candidate_entry_like + || current_bar_entry_like >= 2) { + default_flat_market_gross_disqualified_bars_.insert( + bar_index_); + } + } + // TradingView broker rule: market-entry orders are admitted only // when ``qty * * margin_pct/100 <= equity``. // The check happens HERE (at signal time, with current_bar_.close @@ -222,11 +257,19 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, if (pooc_coof_explicit_flat_market_candidate) { pending_flat_market_pair_disqualified_bars_.insert(bar_index_); } + if (default_flat_market_gross_call && entry_like) { + default_flat_market_gross_disqualified_bars_.insert( + bar_index_); + } if (entry_like && pending.created_position_side == PositionSide::FLAT) { pending_flat_market_pair_disqualified_bars_.insert( pending.created_bar); } + if (pending.default_flat_market_gross_candidate) { + default_flat_market_gross_disqualified_bars_.insert( + pending.created_bar); + } invalidate_pending_flat_market_pair(pending.created_seq); } } @@ -383,6 +426,15 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, && std::isfinite(order.sizing_equity) && std::isfinite(order.sizing_price) && std::isfinite(order.sizing_mark); + order.default_flat_market_gross_candidate = + default_flat_market_gross_call + && order.opening_affordability_exemption_candidate + && std::isfinite(order.frozen_default_qty) + && order.frozen_default_qty > kQtyEpsilon + && std::isfinite(order.sizing_equity) + && order.sizing_equity > 0.0 + && std::isfinite(order.sizing_mark) + && order.sizing_mark > 0.0; } // design-explicit-qty-fill-admission: capture the fill-time admission // snapshot for an EXPLICIT-qty (the caller passed a finite qty) MARKET @@ -420,6 +472,42 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.type = OrderType::ENTRY; order.limit_price = limit_price; order.stop_price = stop_price; + + // Snapshot one narrowly scoped pure STOP at placement. A next-bar + // open-marketable fill carries the already lot-quantized signal-close + // quantity into both admission and dispatch; fill-time logic re-proves + // every scope condition before consuming it. + const double stop_margin_pct = + is_long ? margin_long_ : margin_short_; + if (std::isnan(qty) + && std::isfinite(stop_price) + && std::isnan(limit_price) + && oca_name.empty() && oca_type == 0 + && order.created_position_side == PositionSide::FLAT + && !order.created_after_position_close_in_bar + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && std::isfinite(stop_margin_pct) + && std::abs(stop_margin_pct - 100.0) < 1e-12 + && !process_orders_on_close_ + && !calc_on_order_fills_ + && !bar_magnifier_enabled_ + && !order.created_during_coof_recalc + && slippage_ == 0 + && std::isfinite(current_bar_.close) + && current_bar_.close > 0.0) { + const double placement_equity = current_equity(); + const double placement_qty = calc_qty(current_bar_.close); + if (std::isfinite(placement_equity) && placement_equity > 0.0 + && std::isfinite(placement_qty) + && placement_qty > kQtyEpsilon + && calc_commission(current_bar_.close, placement_qty) == 0.0) { + order.stop_placement_open_qty = placement_qty; + order.stop_placement_open_equity = placement_equity; + order.stop_placement_signal_close = + current_bar_.close; + } + } // KI-65 placement-time pending-market awareness (probe pf-probe-ki65- // dual-entry-precedence): a priced entry placed from FLAT that will // reverse a position an EARLIER same-on_bar OPPOSITE-direction MARKET @@ -1279,6 +1367,14 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro } void BacktestEngine::strategy_cancel(const std::string& id) { + // Any cancel issued while a candidate book is live makes the original + // source body non-exact, even if the surviving book later has size 2. + for (const PendingOrder& order : pending_orders_) { + if (order.default_flat_market_gross_candidate) { + default_flat_market_gross_disqualified_bars_.insert( + order.created_bar); + } + } for (const PendingOrder& order : pending_orders_) { if (order.id == id) { const bool entry_like = @@ -1307,6 +1403,10 @@ void BacktestEngine::strategy_cancel(const std::string& id) { void BacktestEngine::strategy_cancel_all() { for (const PendingOrder& order : pending_orders_) { + if (order.default_flat_market_gross_candidate) { + default_flat_market_gross_disqualified_bars_.insert( + order.created_bar); + } const bool entry_like = order.type == OrderType::MARKET || order.type == OrderType::ENTRY @@ -1329,6 +1429,13 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double double limit_price, double stop_price, const std::string& oca_name, int oca_type) { if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return; + if (default_flat_market_gross_scope_is_live()) { + // strategy.order is outside the high-level two-entry oracle. Record + // the call before any same-id replacement or signal-time rejection can + // make it disappear from the final broker book. + default_flat_market_gross_disqualified_bars_.insert( + bar_index_); + } int64_t preserved_seq = 0; for (const auto& o : pending_orders_) { if (o.id == id) { @@ -1345,6 +1452,10 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double || pending.type == OrderType::ENTRY || pending.type == OrderType::RAW_ORDER; if (entry_like) { + if (pending.default_flat_market_gross_candidate) { + default_flat_market_gross_disqualified_bars_.insert( + pending.created_bar); + } if (pending.created_position_side == PositionSide::FLAT) { pending_flat_market_pair_disqualified_bars_.insert( pending.created_bar); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fcc7274..8658cf7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -72,6 +72,7 @@ set(TEST_SOURCES test_margin_admission_gate test_short_reversal_emission test_margin_stop_admission + test_stop_entry_placement_open_qty test_stop_decline_continue_path test_frozen_flat_gap_reject test_explicit_qty_fill_admission @@ -86,6 +87,7 @@ set(TEST_SOURCES test_same_bar_add_exit_coverage test_declined_reversal_close_leg test_dual_entry_placement_sizing + test_default_flat_market_gross_admission test_lower_tf_parse_extra test_ta_ma_warmup_extra test_ta_osc_edge diff --git a/tests/test_default_flat_market_gross_admission.cpp b/tests/test_default_flat_market_gross_admission.cpp new file mode 100644 index 0000000..2b2c240 --- /dev/null +++ b/tests/test_default_flat_market_gross_admission.cpp @@ -0,0 +1,538 @@ +/* + * Production default MARKET/MARKET gross admission. + * + * At the canonical range start, both stop expressions are na, so Long then + * Short arrive as omitted-qty MARKET strategy.entry calls. With default + * percent_of_equity=100, each freezes one account-equity lot. TradingView + * keeps Long because the later Short's gross transaction is ~200% of equity. + * + * These tests pin the production behavior and its deliberate non-target scope. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static constexpr double kNaN = + std::numeric_limits::quiet_NaN(); + +static bool near(double a, double b, double tolerance = 1e-9) { + return std::abs(a - b) <= tolerance; +} + + +static Bar flat_bar(double price, int64_t timestamp) { + Bar bar; + bar.open = price; + bar.high = price; + bar.low = price; + bar.close = price; + bar.volume = 1000.0; + bar.timestamp = timestamp; + return bar; +} + +struct Snapshot { + double signed_position = 0.0; + int trades = 0; + std::string pending_book; + std::string trade_book; +}; + +static void check_snapshot(const Snapshot& actual, double signed_position, + int trades, const char* pending_book, + const char* trade_book) { + if (!near(actual.signed_position, signed_position)) { + std::printf(" snapshot position actual=%.17g expected=%.17g\n", + actual.signed_position, signed_position); + } + CHECK(near(actual.signed_position, signed_position)); + CHECK(actual.trades == trades); + CHECK(actual.pending_book == pending_book); + CHECK(actual.trade_book == trade_book); +} + +enum class Shape { + OPPOSITE, + SAME_ID, + SAME_DIRECTION, + THREE_CALLS, + REPLACEMENT, + OCA, + PRICED_THIRD, + RAW_THIRD, + CANCELED_THIRD, +}; + +struct Probe : public BacktestEngine { + explicit Probe(Shape shape = Shape::OPPOSITE) : shape_(shape) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + pyramiding_ = 1; + margin_long_ = 100.0; + margin_short_ = 100.0; + commission_value_ = 0.0; + slippage_ = 0; + qty_step_ = 0.0; + set_margin_call_enabled(false); + } + + Shape shape_; + bool placed_ = false; + size_t queued_after_signal = 0; + int candidates_after_signal = 0; + int replacements_after_signal = 0; + Snapshot after_signal; + Snapshot after_fill; + + static const char* order_type_name(OrderType type) { + switch (type) { + case OrderType::MARKET: return "M"; + case OrderType::ENTRY: return "E"; + case OrderType::EXIT: return "X"; + case OrderType::RAW_ORDER: return "R"; + } + return "?"; + } + + static std::string number(double value) { + if (std::isnan(value)) return "na"; + std::ostringstream out; + out << std::fixed << std::setprecision(4) << value; + return out.str(); + } + + Snapshot snapshot() const { + Snapshot result; + result.signed_position = signed_position_size(); + result.trades = trade_count(); + std::ostringstream orders; + orders << "["; + for (size_t i = 0; i < pending_orders_.size(); ++i) { + if (i != 0) orders << ","; + const PendingOrder& order = pending_orders_[i]; + orders << order.id << ":" << order_type_name(order.type) + << ":" << (order.is_long ? "L" : "S") + << ":q=" << number(order.qty) + << ":l=" << number(order.limit_price) + << ":s=" << number(order.stop_price) + << ":o=" << (order.oca_name.empty() ? "-" : order.oca_name) + << "/" << order.oca_type + << ":c=" << order.default_flat_market_gross_candidate + << ":r=" << order.created_by_same_id_replacement; + } + orders << "]"; + result.pending_book = orders.str(); + + std::ostringstream trades; + trades << "["; + for (size_t i = 0; i < trades_.size(); ++i) { + if (i != 0) trades << ","; + const Trade& trade = trades_[i]; + trades << (trade.is_long ? "L" : "S") + << ":" << trade.entry_id << ">" << trade.exit_id + << ":" << number(trade.entry_price) + << ">" << number(trade.exit_price) + << ":q=" << number(trade.qty) + << ":p=" << number(trade.pnl); + } + trades << "]"; + result.trade_book = trades.str(); + return result; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0 && !placed_) { + placed_ = true; + switch (shape_) { + case Shape::OPPOSITE: + strategy_entry("Long", true); + strategy_entry("Short", false); + break; + case Shape::SAME_ID: + strategy_entry("Same", true); + strategy_entry("Same", false); + break; + case Shape::SAME_DIRECTION: + strategy_entry("Long-1", true); + strategy_entry("Long-2", true); + break; + case Shape::THREE_CALLS: + strategy_entry("Long-1", true); + strategy_entry("Short-2", false); + strategy_entry("Long-3", true); + break; + case Shape::REPLACEMENT: + strategy_entry("Long", true); + strategy_entry("Short", false); + strategy_entry("Short", false); + break; + case Shape::OCA: + strategy_entry("Long", true, kNaN, kNaN, kNaN, + "", "G", 1); + strategy_entry("Short", false, kNaN, kNaN, kNaN, + "", "G", 1); + break; + case Shape::PRICED_THIRD: + strategy_entry("Long", true); + strategy_entry("Short", false); + strategy_entry("Priced", true, kNaN, 200.0, 1.0); + break; + case Shape::RAW_THIRD: + strategy_entry("Long", true); + strategy_entry("Short", false); + strategy_order("Raw", true, 1.0); + break; + case Shape::CANCELED_THIRD: + strategy_entry("Long", true); + strategy_entry("Short", false); + strategy_entry("Third", true); + strategy_cancel("Third"); + break; + } + queued_after_signal = pending_orders_.size(); + for (const PendingOrder& order : pending_orders_) { + if (order.default_flat_market_gross_candidate) { + ++candidates_after_signal; + } + if (order.created_by_same_id_replacement) { + ++replacements_after_signal; + } + } + after_signal = snapshot(); + } + if (bar_index_ == 1) { + after_fill = snapshot(); + } + } +}; + +static Snapshot run_probe(Probe& probe) { + const Bar bars[] = { + flat_bar(100.0, 600'000), + flat_bar(100.0, 1'200'000), + }; + probe.run(bars, 2); + return probe.after_fill; +} + +static void test_default_exact_pair_admission() { + std::printf("-- production default exact-pair gross admission --\n"); + + Probe probe; + const Snapshot result = run_probe(probe); + CHECK(probe.queued_after_signal == 2); + CHECK(probe.candidates_after_signal == 2); + check_snapshot( + probe.after_signal, 0.0, 0, + "[Long:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Short:M:S:q=na:l=na:s=na:o=-/0:c=1:r=0]", + "[]"); + // Gross frozen cost = (10 + 10) * 100 = 2000 > 1000 equity. Only the + // later Short is canceled; Long fills its unchanged frozen quantity. + check_snapshot(result, 10.0, 0, "[]", "[]"); +} + +static void test_exact_book_controls_remain_ordinary() { + std::printf("-- id/direction/third/replacement/OCA controls --\n"); + struct Expected { + Shape shape; + const char* signal_book; + double fill_position; + int fill_trades; + const char* fill_book; + const char* trade_book; + }; + const Expected expected[] = { + {Shape::SAME_ID, + "[Same:M:S:q=na:l=na:s=na:o=-/0:c=1:r=1]", + -10.0, 0, "[]", "[]"}, + {Shape::SAME_DIRECTION, + "[Long-1:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Long-2:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0]", + 10.0, 0, "[]", "[]"}, + {Shape::THREE_CALLS, + "[Long-1:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Short-2:M:S:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Long-3:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0]", + 10.0, 2, "[]", + "[L:Long-1>Short-2:100.0000>100.0000:q=10.0000:p=0.0000," + "S:Short-2>Long-3:100.0000>100.0000:q=10.0000:p=0.0000]"}, + {Shape::REPLACEMENT, + "[Long:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Short:M:S:q=na:l=na:s=na:o=-/0:c=1:r=1]", + -10.0, 1, "[]", + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + {Shape::OCA, + "[Long:M:L:q=na:l=na:s=na:o=G/1:c=0:r=0," + "Short:M:S:q=na:l=na:s=na:o=G/1:c=0:r=0]", + 10.0, 0, "[]", "[]"}, + {Shape::PRICED_THIRD, + "[Long:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Short:M:S:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Priced:E:L:q=1.0000:l=na:s=200.0000:o=-/0:c=0:r=0]", + -10.0, 1, + "[Priced:E:L:q=1.0000:l=na:s=200.0000:o=-/0:c=0:r=0]", + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + {Shape::RAW_THIRD, + "[Long:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Short:M:S:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Raw:R:L:q=1.0000:l=na:s=na:o=-/0:c=0:r=0]", + -10.0, 1, + "[Raw:R:L:q=1.0000:l=na:s=na:o=-/0:c=0:r=0]", + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + {Shape::CANCELED_THIRD, + "[Long:M:L:q=na:l=na:s=na:o=-/0:c=1:r=0," + "Short:M:S:q=na:l=na:s=na:o=-/0:c=1:r=0]", + -10.0, 1, "[]", + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + }; + for (const Expected& value : expected) { + Probe probe(value.shape); + const Snapshot result = run_probe(probe); + check_snapshot(probe.after_signal, 0.0, 0, value.signal_book, "[]"); + check_snapshot(result, value.fill_position, value.fill_trades, + value.fill_book, value.trade_book); + } + + Probe same_id(Shape::SAME_ID); + run_probe(same_id); + CHECK(same_id.queued_after_signal == 1); + CHECK(same_id.replacements_after_signal == 1); + + Probe three(Shape::THREE_CALLS); + run_probe(three); + CHECK(three.queued_after_signal == 3); + CHECK(three.candidates_after_signal == 3); + + Probe replacement(Shape::REPLACEMENT); + run_probe(replacement); + CHECK(replacement.queued_after_signal == 2); + CHECK(replacement.replacements_after_signal == 1); + + Probe oca(Shape::OCA); + run_probe(oca); + CHECK(oca.candidates_after_signal == 0); + + Probe canceled(Shape::CANCELED_THIRD); + run_probe(canceled); + CHECK(canceled.queued_after_signal == 2); +} + +enum class ConfigControl { + DEFAULT_FIXED_5_5, + EXPLICIT_KI65_5_5, + MARGIN_50, + COMMISSION, + SLIPPAGE, + RISK_LONG_ONLY, + POOC, + COOF, + MAGNIFIER, +}; + +struct ConfigProbe : public BacktestEngine { + explicit ConfigProbe(ConfigControl control) : control_(control) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + pyramiding_ = 1; + margin_long_ = 100.0; + margin_short_ = 100.0; + commission_value_ = 0.0; + slippage_ = 0; + qty_step_ = 0.0; + set_margin_call_enabled(false); + switch (control_) { + case ConfigControl::DEFAULT_FIXED_5_5: + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 5.5; + break; + case ConfigControl::EXPLICIT_KI65_5_5: + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 2; + break; + case ConfigControl::MARGIN_50: + margin_long_ = 50.0; + margin_short_ = 50.0; + break; + case ConfigControl::COMMISSION: + commission_value_ = 0.1; + break; + case ConfigControl::SLIPPAGE: + slippage_ = 1; + syminfo_mintick_ = 0.01; + break; + case ConfigControl::RISK_LONG_ONLY: + risk_direction_ = RiskDirection::LONG_ONLY; + break; + case ConfigControl::POOC: + process_orders_on_close_ = true; + break; + case ConfigControl::COOF: + calc_on_order_fills_ = true; + break; + case ConfigControl::MAGNIFIER: + bar_magnifier_enabled_ = true; + break; + } + } + + ConfigControl control_; + bool placed_ = false; + int candidates_after_signal = 0; + Snapshot after_signal; + Snapshot after_fill; + + Snapshot snapshot() const { + Snapshot result; + result.signed_position = signed_position_size(); + result.trades = trade_count(); + std::ostringstream orders; + orders << "["; + for (size_t i = 0; i < pending_orders_.size(); ++i) { + if (i != 0) orders << ","; + const PendingOrder& order = pending_orders_[i]; + orders << order.id << ":" << Probe::order_type_name(order.type) + << ":" << (order.is_long ? "L" : "S") + << ":q=" << Probe::number(order.qty) + << ":l=" << Probe::number(order.limit_price) + << ":s=" << Probe::number(order.stop_price) + << ":o=" << (order.oca_name.empty() ? "-" : order.oca_name) + << "/" << order.oca_type + << ":c=" << order.default_flat_market_gross_candidate + << ":r=" << order.created_by_same_id_replacement; + } + orders << "]"; + result.pending_book = orders.str(); + std::ostringstream trades; + trades << "["; + for (size_t i = 0; i < trades_.size(); ++i) { + if (i != 0) trades << ","; + const Trade& trade = trades_[i]; + trades << (trade.is_long ? "L" : "S") + << ":" << trade.entry_id << ">" << trade.exit_id + << ":" << Probe::number(trade.entry_price) + << ">" << Probe::number(trade.exit_price) + << ":q=" << Probe::number(trade.qty) + << ":p=" << Probe::number(trade.pnl); + } + trades << "]"; + result.trade_book = trades.str(); + return result; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0 && !placed_) { + placed_ = true; + if (control_ == ConfigControl::EXPLICIT_KI65_5_5) { + strategy_entry("Long", true, kNaN, kNaN, 5.5); + strategy_entry("Short", false, kNaN, kNaN, 5.5); + } else { + strategy_entry("Long", true); + strategy_entry("Short", false); + } + for (const PendingOrder& order : pending_orders_) { + if (order.default_flat_market_gross_candidate) { + ++candidates_after_signal; + } + } + after_signal = snapshot(); + } + if (bar_index_ == 1) { + after_fill = snapshot(); + } + } +}; + +static Snapshot run_config(ConfigProbe& probe) { + const Bar bars[] = { + flat_bar(100.0, 600'000), + flat_bar(100.0, 1'200'000), + }; + if (probe.control_ == ConfigControl::MAGNIFIER) { + probe.run(bars, 2, "1", "1", /*bar_magnifier=*/true, 4, + MagnifierDistribution::ENDPOINTS); + } else { + probe.run(bars, 2); + } + return probe.after_fill; +} + +static void test_configuration_controls_and_explicit_ki65_stay_inert() { + std::printf("-- fixed/explicit/risk/scheduler configuration controls --\n"); + struct Expected { + ConfigControl control; + const char* signal_book; + double fill_position; + int fill_trades; + const char* trade_book; + }; + const char* default_signal = + "[Long:M:L:q=na:l=na:s=na:o=-/0:c=0:r=0," + "Short:M:S:q=na:l=na:s=na:o=-/0:c=0:r=0]"; + const Expected expected[] = { + {ConfigControl::DEFAULT_FIXED_5_5, default_signal, -5.5, 1, + "[L:Long>Short:100.0000>100.0000:q=5.5000:p=0.0000]"}, + {ConfigControl::EXPLICIT_KI65_5_5, + "[Long:M:L:q=5.5000:l=na:s=na:o=-/0:c=0:r=0," + "Short:M:S:q=5.5000:l=na:s=na:o=-/0:c=0:r=0]", + 5.5, 0, "[]"}, + {ConfigControl::MARGIN_50, default_signal, -10.0, 1, + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + {ConfigControl::COMMISSION, default_signal, -9.990009990009991, 1, + "[L:Long>Short:100.0000>100.0000:q=9.9900:p=-1.9980]"}, + {ConfigControl::SLIPPAGE, default_signal, -10.001000100010002, 1, + "[L:Long>Short:100.0100>99.9900:q=9.9990:p=-0.2000]"}, + {ConfigControl::RISK_LONG_ONLY, default_signal, 0.0, 1, + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + {ConfigControl::POOC, default_signal, -10.0, 1, + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + {ConfigControl::COOF, default_signal, -10.0, 1, + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + {ConfigControl::MAGNIFIER, default_signal, -10.0, 1, + "[L:Long>Short:100.0000>100.0000:q=10.0000:p=0.0000]"}, + }; + for (const Expected& value : expected) { + ConfigProbe probe(value.control); + const Snapshot result = run_config(probe); + CHECK(probe.candidates_after_signal == 0); + check_snapshot(probe.after_signal, 0.0, 0, value.signal_book, "[]"); + check_snapshot(result, value.fill_position, value.fill_trades, + "[]", value.trade_book); + } +} + +int main() { + std::printf("--- production default flat MARKET gross admission ---\n"); + test_default_exact_pair_admission(); + test_exact_book_controls_remain_ordinary(); + test_configuration_controls_and_explicit_ki65_stay_inert(); + + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_stop_entry_placement_open_qty.cpp b/tests/test_stop_entry_placement_open_qty.cpp new file mode 100644 index 0000000..a30069a --- /dev/null +++ b/tests/test_stop_entry_placement_open_qty.cpp @@ -0,0 +1,478 @@ +/* + * Production placement-frozen STOP admission semantics. + * + * One tightly scoped true-flat all-in pure STOP snapshots its quantity at + * placement. A next-bar open-marketable fill uses that quantity for admission + * and dispatch; intrabar touches and all non-target controls keep ordinary + * fill-time sizing. + */ + +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +#define CHECK_NEAR(a, b, tol) \ + do { \ + const double _a = (a), _b = (b); \ + if (!(std::fabs(_a - _b) <= (tol))) { \ + std::printf(" FAIL %s:%d %s == %.12f, expected %.12f\n", \ + __FILE__, __LINE__, #a, _a, _b); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar bar(int64_t ts, double o, double h, double l, double c) { + Bar b; + b.open = o; + b.high = h; + b.low = l; + b.close = c; + b.volume = 1.0; + b.timestamp = ts; + return b; +} + +namespace { + +enum class Shape { STOP, LIMIT, STOP_LIMIT }; +enum class PostPlacementMutation { + NONE, + REALIZED_EQUITY_GAIN, + COMMISSION, + SLIPPAGE, + MARGIN, + DEFAULT_SIZING, +}; + +class Probe : public BacktestEngine { +public: + Probe(QtyType qty_type = QtyType::PERCENT_OF_EQUITY, + double qty_value = 100.0, double margin = 100.0, + double capital = 10000.0) { + initial_capital_ = capital; + default_qty_type_ = qty_type; + default_qty_value_ = qty_value; + commission_value_ = 0.0; + margin_long_ = margin; + margin_short_ = margin; + qty_step_ = 0.0001; + pyramiding_ = 1; + set_margin_call_enabled(false); + } + + Shape shape = Shape::STOP; + bool is_long = false; + bool explicit_qty = false; + int reissue_bar = -1; + PostPlacementMutation post_placement_mutation = + PostPlacementMutation::NONE; + double stop = 120.0; + double limit = 80.0; + double placement_snapshot_qty = kNaN; + double reissue_snapshot_qty = kNaN; + + void on_bar(const Bar&) override { + if (bar_index_ != 0 && bar_index_ != reissue_bar) return; + const double qty = explicit_qty ? 7.0 : kNaN; + switch (shape) { + case Shape::STOP: + strategy_entry("E", is_long, kNaN, stop, qty); + break; + case Shape::LIMIT: + strategy_entry("E", is_long, limit, kNaN, qty); + break; + case Shape::STOP_LIMIT: + strategy_entry("E", is_long, limit, stop, qty); + break; + } + const PendingOrder* order = pending(); + if (order != nullptr) { + if (bar_index_ == 0) { + placement_snapshot_qty = + order->stop_placement_open_qty; + } else if (bar_index_ == reissue_bar) { + reissue_snapshot_qty = order->stop_placement_open_qty; + } + } + if (bar_index_ != 0) return; + // Mutate only after the placement snapshot has been captured. Each + // case therefore proves that consumption re-validates current broker + // state and falls back to the ordinary fill-time path. + switch (post_placement_mutation) { + case PostPlacementMutation::NONE: + break; + case PostPlacementMutation::REALIZED_EQUITY_GAIN: + // Model an independent intervening round trip that realizes a + // gain and returns broker state to flat before this STOP's + // next-open adjudication. Flatness alone must not authorize a + // stale placement-equity snapshot. + net_profit_sum_ = 1000.0; + break; + case PostPlacementMutation::COMMISSION: + commission_value_ = 0.1; + break; + case PostPlacementMutation::SLIPPAGE: + slippage_ = 1; + syminfo_mintick_ = 0.01; + break; + case PostPlacementMutation::MARGIN: + margin_short_ = 50.0; + margin_long_ = 50.0; + break; + case PostPlacementMutation::DEFAULT_SIZING: + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 7.0; + break; + } + } + + const PendingOrder* pending() const { + for (const PendingOrder& order : pending_orders_) { + if (order.id == "E") return ℴ + } + return nullptr; + } + + PositionSide side() const { return position_side_; } + double position_qty() const { return position_qty_; } + double entry_price() const { return position_entry_price_; } + double ledger_qty(const std::string& id = "E") const { + const auto it = id_unclosed_qty_.find(id); + return it == id_unclosed_qty_.end() ? 0.0 : it->second; + } +}; + +static void run(Probe& probe, const std::vector& bars) { + probe.run(bars.data(), static_cast(bars.size())); +} + +void test_default_placement_snapshot() { + std::printf("-- production default captures the narrow STOP snapshot --\n"); + + Probe probe; + run(probe, {bar(1000, 100, 100, 100, 100)}); + CHECK(probe.pending() != nullptr); + if (probe.pending() != nullptr) { + CHECK(std::isnan(probe.pending()->qty)); + CHECK(std::isnan(probe.pending()->frozen_default_qty)); + CHECK_NEAR(probe.pending()->stop_placement_open_qty, + 100.0, 1e-12); + CHECK_NEAR(probe.pending()->stop_placement_open_equity, + 10000.0, 1e-12); + CHECK_NEAR(probe.pending()->stop_placement_signal_close, + 100.0, 1e-12); + } +} + +void test_positive_gap_declines_both_directions() { + std::printf("-- higher-notional gap-open declines long and short --\n"); + + Probe short_probe; + short_probe.is_long = false; + short_probe.stop = 120.0; // short STOP is already marketable at open 110 + run(short_probe, { + bar(1000, 100, 100, 100, 100), + bar(2000, 110, 111, 109, 110), + }); + CHECK(short_probe.side() == PositionSide::FLAT); + CHECK(short_probe.trade_count() == 0); + + Probe long_probe; + long_probe.is_long = true; + long_probe.stop = 105.0; // long STOP is marketable at open 110 + run(long_probe, { + bar(1000, 100, 100, 100, 100), + bar(2000, 110, 111, 109, 110), + }); + CHECK(long_probe.side() == PositionSide::FLAT); + CHECK(long_probe.trade_count() == 0); +} + +void test_accepted_gap_dispatches_placement_quantity() { + std::printf("-- admitted gap dispatches placement-frozen qty --\n"); + + Probe short_probe; + short_probe.is_long = false; + short_probe.stop = 95.0; // open 90 is through the short STOP + run(short_probe, { + bar(1000, 100, 100, 100, 100), + bar(2000, 90, 91, 89, 90), + }); + CHECK(short_probe.side() == PositionSide::SHORT); + CHECK_NEAR(short_probe.entry_price(), 90.0, 1e-12); + CHECK_NEAR(short_probe.position_qty(), 100.0, 1e-9); + + Probe long_probe; + long_probe.is_long = true; + long_probe.stop = 85.0; // deliberately wrong-side stop, open-marketable + run(long_probe, { + bar(1000, 100, 100, 100, 100), + bar(2000, 90, 91, 89, 90), + }); + CHECK(long_probe.side() == PositionSide::LONG); + CHECK_NEAR(long_probe.entry_price(), 90.0, 1e-12); + CHECK_NEAR(long_probe.position_qty(), 100.0, 1e-9); +} + +void test_one_step_favorable_gap_keeps_signal_close_lot() { + std::printf("-- one-step favorable gap keeps signal-close lot --\n"); + + // Placement equity 13118.817086 and signal close 3988.94 floor to 3.2887 + // contracts, while + // re-dividing at the next open 3988.93 produces 3.2888. TV exports 3.2887. + Probe probe(QtyType::PERCENT_OF_EQUITY, 100.0, 100.0, + 13118.817086); + probe.is_long = false; + probe.stop = 4000.0; + run(probe, { + bar(1000, 3988.94, 3988.94, 3988.94, 3988.94), + bar(2000, 3988.93, 3989.00, 3988.00, 3988.50), + }); + CHECK(probe.side() == PositionSide::SHORT); + CHECK_NEAR(probe.entry_price(), 3988.93, 1e-12); + CHECK_NEAR(probe.position_qty(), 3.2887, 1e-12); +} + +void test_prequantized_dispatch_preserves_exact_binary_lot() { + std::printf("-- placement lot is not quantized a second time --\n"); + + // floor((10000 / close) / 0.0001) * 0.0001 is the binary double + // represented by literal 0.3. Applying the same floor a second time is + // 0.2999 on this boundary, so exact position and id-ledger equality pin + // the independent prequantized dispatch provenance. + Probe probe(QtyType::PERCENT_OF_EQUITY, 100.0, 100.0, 10000.0); + probe.is_long = false; + probe.stop = 40000.0; + run(probe, { + bar(1000, 33327.77870354941, 33327.77870354941, + 33327.77870354941, 33327.77870354941), + bar(2000, 33327.77, 33328.0, 33327.0, 33327.5), + }); + CHECK(probe.side() == PositionSide::SHORT); + CHECK(probe.placement_snapshot_qty == 0.3); + CHECK(probe.position_qty() == 0.3); + CHECK(probe.ledger_qty() == 0.3); +} + +void test_zero_open_falls_back_without_frozen_qty() { + std::printf("-- zero open stays on the baseline dispatch path --\n"); + + Probe baseline; + baseline.is_long = false; + baseline.stop = 40000.0; + run(baseline, { + bar(1000, 100, 100, 100, 100), + bar(2000, 0, 1, 0, 1), + }); + + Probe enabled; + enabled.is_long = false; + enabled.stop = 40000.0; + run(enabled, { + bar(1000, 100, 100, 100, 100), + bar(2000, 0, 1, 0, 1), + }); + + CHECK(enabled.side() == baseline.side()); + CHECK(enabled.placement_snapshot_qty == 100.0); + CHECK(enabled.position_qty() == baseline.position_qty()); + CHECK(enabled.ledger_qty() == baseline.ledger_qty()); + CHECK(enabled.position_qty() == 0.0); + CHECK(enabled.ledger_qty() == 0.0); +} + +void test_replacement_reissues_the_snapshot() { + std::printf("-- same-id reissue replaces, rather than reuses, snapshot --\n"); + + Probe probe; + probe.is_long = false; + probe.stop = 50.0; + probe.reissue_bar = 1; + run(probe, { + bar(1000, 100, 100, 100, 100), + bar(2000, 80, 81, 79, 80), + bar(3000, 40, 41, 39, 40), + }); + CHECK_NEAR(probe.placement_snapshot_qty, 100.0, 1e-12); + CHECK_NEAR(probe.reissue_snapshot_qty, 125.0, 1e-12); + CHECK(probe.side() == PositionSide::SHORT); + CHECK_NEAR(probe.entry_price(), 40.0, 1e-12); + CHECK_NEAR(probe.position_qty(), 125.0, 1e-12); + CHECK_NEAR(probe.ledger_qty(), 125.0, 1e-12); + CHECK(probe.pending() == nullptr); +} + +void test_intervening_equity_change_while_flat_falls_back() { + std::printf("-- intervening realized-equity change invalidates snapshot --\n"); + + Probe probe; + probe.is_long = false; + probe.stop = 95.0; + probe.post_placement_mutation = + PostPlacementMutation::REALIZED_EQUITY_GAIN; + run(probe, { + bar(1000, 100, 100, 100, 100), + bar(2000, 90, 91, 89, 90), + }); + CHECK_NEAR(probe.placement_snapshot_qty, 100.0, 1e-12); + CHECK(probe.side() == PositionSide::SHORT); + CHECK_NEAR(probe.entry_price(), 90.0, 1e-12); + // Current realized equity is 11000, so ordinary fill-time sizing is + // floor((11000 / 90) / 0.0001) * 0.0001 = 122.2222, not stale 100. + CHECK_NEAR(probe.position_qty(), 122.2222, 1e-9); + CHECK_NEAR(probe.ledger_qty(), 122.2222, 1e-9); +} + +void test_placement_to_fill_config_changes_fall_back() { + std::printf("-- placement-to-fill config changes use ordinary fallback --\n"); + + struct Expected { + PostPlacementMutation mutation; + PositionSide side; + double qty; + double price; + }; + const Expected expected[] = { + {PostPlacementMutation::COMMISSION, + PositionSide::SHORT, 111.0001, 90.0}, + // Slippage moves the short fill to 89.99 and ordinary fill-time sizing + // becomes 111.1234; consuming the placement lot would instead open 100. + {PostPlacementMutation::SLIPPAGE, + PositionSide::SHORT, 111.1234, 89.99}, + {PostPlacementMutation::MARGIN, + PositionSide::SHORT, 111.1111, 90.0}, + {PostPlacementMutation::DEFAULT_SIZING, + PositionSide::SHORT, 7.0, 90.0}, + }; + for (const Expected& value : expected) { + Probe probe; + probe.is_long = false; + probe.stop = 95.0; + probe.post_placement_mutation = value.mutation; + run(probe, { + bar(1000, 100, 100, 100, 100), + bar(2000, 90, 91, 89, 90), + }); + CHECK_NEAR(probe.placement_snapshot_qty, 100.0, 1e-12); + CHECK(probe.side() == value.side); + CHECK_NEAR(probe.position_qty(), value.qty, 1e-9); + CHECK_NEAR(probe.ledger_qty(), value.qty, 1e-9); + if (value.side != PositionSide::FLAT) { + CHECK_NEAR(probe.entry_price(), value.price, 1e-12); + } + CHECK(probe.pending() == nullptr); + } +} + +void test_scope_controls_remain_ordinary() { + std::printf("-- intrabar, delayed, fractional, explicit and shape controls --\n"); + + // Intrabar touch is not an open-marketable gap. Existing admission uses + // fill-time qty at stop=120, costed at open=110, so it admits. + Probe intrabar; + intrabar.is_long = true; + intrabar.stop = 120.0; + run(intrabar, { + bar(1000, 100, 100, 100, 100), + bar(2000, 110, 121, 109, 120), + }); + CHECK(intrabar.side() == PositionSide::LONG); + CHECK_NEAR(intrabar.entry_price(), 120.0, 1e-12); + CHECK_NEAR(intrabar.position_qty(), 83.3333, 1e-9); + + // A STOP first becoming marketable two bars after placement is outside the + // exact next-bar replay cell and remains fill-time-sized. + Probe delayed; + delayed.is_long = false; + delayed.stop = 80.0; + run(delayed, { + bar(1000, 100, 100, 100, 100), + bar(2000, 100, 110, 90, 100), + bar(3000, 70, 71, 69, 70), + }); + CHECK(delayed.side() == PositionSide::SHORT); + CHECK_NEAR(delayed.position_qty(), 142.8571, 1e-9); + + Probe fractional(QtyType::PERCENT_OF_EQUITY, 50.0, 100.0); + fractional.is_long = false; + fractional.stop = 120.0; + run(fractional, { + bar(1000, 100, 100, 100, 100), + bar(2000, 110, 111, 109, 110), + }); + CHECK(fractional.side() == PositionSide::SHORT); + CHECK_NEAR(fractional.position_qty(), 45.4545, 1e-9); + + Probe explicit_stop; + explicit_stop.is_long = false; + explicit_stop.stop = 120.0; + explicit_stop.explicit_qty = true; + run(explicit_stop, { + bar(1000, 100, 100, 100, 100), + bar(2000, 110, 111, 109, 110), + }); + CHECK(explicit_stop.side() == PositionSide::SHORT); + CHECK_NEAR(explicit_stop.position_qty(), 7.0, 1e-12); + + Probe limit_only; + limit_only.shape = Shape::LIMIT; + run(limit_only, {bar(1000, 100, 100, 100, 100)}); + CHECK(limit_only.pending() != nullptr); + if (limit_only.pending() != nullptr) { + CHECK(std::isnan( + limit_only.pending()->stop_placement_open_qty)); + } + + Probe stop_limit; + stop_limit.shape = Shape::STOP_LIMIT; + run(stop_limit, {bar(1000, 100, 100, 100, 100)}); + CHECK(stop_limit.pending() != nullptr); + if (stop_limit.pending() != nullptr) { + CHECK(std::isnan( + stop_limit.pending()->stop_placement_open_qty)); + } +} + +} // namespace + +int main() { + std::printf("--- production STOP placement open qty ---\n"); + test_default_placement_snapshot(); + test_positive_gap_declines_both_directions(); + test_accepted_gap_dispatches_placement_quantity(); + test_one_step_favorable_gap_keeps_signal_close_lot(); + test_prequantized_dispatch_preserves_exact_binary_lot(); + test_zero_open_falls_back_without_frozen_qty(); + test_replacement_reissues_the_snapshot(); + test_intervening_equity_change_while_flat_falls_back(); + test_placement_to_fill_config_changes_fall_back(); + test_scope_controls_remain_ordinary(); + + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}