diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 723cfc1..d74a8cf 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -641,6 +641,11 @@ class BacktestEngine { // fee-created, floor-zero one-contract fallback. Reversals, adds, // explicit/priced/RAW orders and zero/CASH commission stay outside it. bool opening_affordability_commissioned_default_flat_long_ = false; + // The queued event came from a successful omitted-qty, 100%-of-equity + // MARKET reversal from SHORT to LONG at 100% long margin with zero opening + // commission. TV applies a one-contract post-fill affordability trim when + // this exact reversal has a positive but sub-lot restore amount. + bool opening_affordability_default_long_reversal_ = false; double opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); int64_t position_entry_time_ = 0; @@ -2528,6 +2533,7 @@ class BacktestEngine { void flip_market_position_to(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, + bool explicit_qty_prequantized, bool close_only, uint64_t entry_incarnation); void sequential_same_tick_reversal_fill(const std::string& id, bool is_long, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index f17a7d9..ed3aeb2 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -667,11 +667,14 @@ void BacktestEngine::process_margin_call(const Bar& bar) { const bool opening_event_eligible = opening_affordability_eligible_; const bool opening_event_commissioned_default_flat_long = opening_affordability_commissioned_default_flat_long_; + const bool opening_event_default_long_reversal = + opening_affordability_default_long_reversal_; const double opening_event_raw_fill_base = opening_affordability_raw_fill_base_; opening_affordability_pending_ = false; opening_affordability_eligible_ = false; opening_affordability_commissioned_default_flat_long_ = false; + opening_affordability_default_long_reversal_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); @@ -875,7 +878,8 @@ void BacktestEngine::process_margin_call(const Bar& bar) { double opening_floor_zero_fallback = std::numeric_limits::quiet_NaN(); if (opening_affordability && q_min <= kQtyEpsilon) { - if (fee_created_floor_zero_candidate + if ((fee_created_floor_zero_candidate + || opening_event_default_long_reversal) && qty_step_ > 0.0 && qty_step_ <= 1.0 && raw_q_min > kQtyEpsilon @@ -3213,6 +3217,7 @@ void BacktestEngine::apply_filled_order_to_state( opening_affordability_pending_ = false; opening_affordability_eligible_ = false; opening_affordability_commissioned_default_flat_long_ = false; + opening_affordability_default_long_reversal_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); } @@ -3265,6 +3270,26 @@ void BacktestEngine::apply_filled_order_to_state( && commission_value_ > 0.0 && std::isfinite(new_opening_commission) && new_opening_commission > 0.0; + opening_affordability_default_long_reversal_ = + successful_fresh_open + && position_side_before_fill == PositionSide::SHORT + && order.created_position_side == PositionSide::SHORT + && !order.created_after_position_close_in_bar + && order.type == OrderType::MARKET + && order.is_long + && std::isnan(order.qty) + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && 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_price) + && order.sizing_price > 0.0 + && std::isfinite(order.sizing_fx) + && order.sizing_fx > 0.0 + && std::isfinite(new_opening_commission) + && new_opening_commission == 0.0; opening_affordability_raw_fill_base_ = fill_price; } } @@ -3872,6 +3897,7 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price opening_affordability_pending_ = false; opening_affordability_eligible_ = false; opening_affordability_commissioned_default_flat_long_ = false; + opening_affordability_default_long_reversal_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); position_entry_time_ = current_bar_.timestamp; diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 1c21050..621485d 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -142,6 +142,7 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d // transaction whose whole broker movement is consumed by the close. Both // close the live opposite position without opening their own leg. flip_market_position_to(id, is_long, fill_price, explicit_qty, explicit_qty_type, + explicit_qty_prequantized, /*close_only=*/close_only_opposite, entry_incarnation); } @@ -580,6 +581,7 @@ void BacktestEngine::reset_position_state_to_flat() { opening_affordability_pending_ = false; opening_affordability_eligible_ = false; opening_affordability_commissioned_default_flat_long_ = false; + opening_affordability_default_long_reversal_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); position_entry_time_ = 0; @@ -633,6 +635,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri opening_affordability_pending_ = false; opening_affordability_eligible_ = false; opening_affordability_commissioned_default_flat_long_ = false; + opening_affordability_default_long_reversal_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); position_entry_time_ = current_bar_.timestamp; @@ -881,6 +884,7 @@ void BacktestEngine::close_opposite_then_enter(const std::string& id, bool is_lo void BacktestEngine::flip_market_position_to(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, + bool explicit_qty_prequantized, bool close_only, uint64_t entry_incarnation) { // For the close we need exit slippage based on closing direction. @@ -918,7 +922,14 @@ void BacktestEngine::flip_market_position_to(const std::string& id, bool is_long return; } - double new_qty = calc_qty_for_type(fill_price, explicit_qty, explicit_qty_type); + // Default-sized MARKET quantities are frozen and exchange-quantized at + // signal time. Every sibling dispatch path preserves that provenance; + // ordinary flips must not feed the frozen contracts through qty_step a + // second time (binary64 can turn 1.3410 into 1.3409 on the second floor). + // Explicit/FIXED quantities keep their normal single fill-side floor. + double new_qty = explicit_qty_prequantized + ? explicit_qty + : calc_qty_for_type(fill_price, explicit_qty, explicit_qty_type); PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT; open_fresh_position(requested, fill_price, new_qty, id, entry_incarnation); } diff --git a/tests/test_default_qty_signal_freeze.cpp b/tests/test_default_qty_signal_freeze.cpp index 536612c..4fd241a 100644 --- a/tests/test_default_qty_signal_freeze.cpp +++ b/tests/test_default_qty_signal_freeze.cpp @@ -453,6 +453,89 @@ void test_explicit_true_flat_market_keeps_single_floor() { CHECK_NEAR(eng.id_ledger_qty("BOUNDARY"), 6.2790, 1e-12); } +// J/K. The same binary boundary must survive an ordinary in-position MARKET +// reversal. The default-sized long is quantized when it is placed; the later +// short->long flip must consume that frozen contracts value directly. The +// adjacent explicit-qty reversal remains un-frozen and therefore still gets +// exactly one ordinary fill-side floor. +class FrozenReversalBoundaryProbe : public BacktestEngine { +public: + explicit FrozenReversalBoundaryProbe(bool explicit_reversal) + : explicit_reversal_(explicit_reversal) { + initial_capital_ = 6279.0000001; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + qty_step_ = 0.0001; + margin_call_enabled_ = false; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("SEED", false, kNaN, kNaN, 1.0); + } else if (bar_index_ == 1) { + if (explicit_reversal_) { + strategy_entry("BOUNDARY", true, kNaN, kNaN, + 6.2790000001); + } else { + strategy_entry("BOUNDARY", true); + } + } + } + + double one_floor_qty() const { return apply_qty_step(6.2790000001); } + double two_floor_qty() const { return apply_qty_step(one_floor_qty()); } + PositionSide position_side() const { return position_side_; } + double position_qty() const { return position_qty_; } + int live_lot_count() const { return static_cast(pyramid_entries_.size()); } + double live_lot_qty() const { + return pyramid_entries_.empty() ? 0.0 : pyramid_entries_.front().qty; + } + double id_ledger_qty(const std::string& id) const { + const auto it = id_unclosed_qty_.find(id); + return it == id_unclosed_qty_.end() ? 0.0 : it->second; + } + +private: + bool explicit_reversal_; +}; + +static std::vector frozen_reversal_boundary_bars() { + return { + mk_bar(1000, 1000.0, 1000.0, 1000.0, 1000.0), + mk_bar(2000, 1000.0, 1000.0, 1000.0, 1000.0), + mk_bar(3000, 1000.0, 1000.0, 1000.0, 1000.0), + }; +} + +void test_frozen_market_reversal_is_not_refloored() { + std::printf("-- J: frozen MARKET reversal is not re-floored --\n"); + FrozenReversalBoundaryProbe eng(/*explicit_reversal=*/false); + auto bars = frozen_reversal_boundary_bars(); + eng.run(bars.data(), static_cast(bars.size())); + + CHECK_NEAR(eng.one_floor_qty(), 6.2790, 1e-12); + CHECK_NEAR(eng.two_floor_qty(), 6.2789, 1e-12); + CHECK(eng.position_side() == PositionSide::LONG); + CHECK_NEAR(eng.position_qty(), 6.2790, 1e-12); + CHECK(eng.live_lot_count() == 1); + CHECK_NEAR(eng.live_lot_qty(), 6.2790, 1e-12); + CHECK_NEAR(eng.id_ledger_qty("BOUNDARY"), 6.2790, 1e-12); +} + +void test_explicit_market_reversal_keeps_single_floor() { + std::printf("-- K: explicit MARKET reversal keeps one qty floor --\n"); + FrozenReversalBoundaryProbe eng(/*explicit_reversal=*/true); + auto bars = frozen_reversal_boundary_bars(); + eng.run(bars.data(), static_cast(bars.size())); + + CHECK(eng.position_side() == PositionSide::LONG); + CHECK_NEAR(eng.position_qty(), 6.2790, 1e-12); + CHECK(eng.live_lot_count() == 1); + CHECK_NEAR(eng.live_lot_qty(), 6.2790, 1e-12); + CHECK_NEAR(eng.id_ledger_qty("BOUNDARY"), 6.2790, 1e-12); +} + } // namespace int main() { @@ -466,6 +549,8 @@ int main() { test_reversal_bracket_binding_survives_freeze(); test_frozen_true_flat_market_dispatch_is_not_refloored(); test_explicit_true_flat_market_keeps_single_floor(); + test_frozen_market_reversal_is_not_refloored(); + test_explicit_market_reversal_keeps_single_floor(); std::printf("\n=== Results: %d passed, %d failed ===\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index cf9247b..bf94e0e 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -1789,6 +1789,98 @@ static void test_reversal_captures_fresh_opening_state() { CHECK(std::isnan(eng.opening_raw_base())); } +// A frozen 100%-equity MARKET reversal can pass the signal-time admission +// check yet become microscopically underfunded after the carried short is +// realized at the next-open fill. TV restores this positive sub-step deficit +// by closing exactly one whole contract, not by treating it as dust. The +// numbers pin a source-faithful omitted-quantity reversal event and also +// exercise the frozen-quantity no-refloor path: 5.2798 must survive placement +// and flip. +class DefaultLongReversalFloorZeroProbe : public MCEngine { +public: + explicit DefaultLongReversalFloorZeroProbe(bool explicit_reversal) + : explicit_reversal_(explicit_reversal) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + pyramiding_ = 1; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + + // Seed the already-partially-liquidated short immediately before the + // TV-pinned reversal signal. Its open mark at 1841.70 freezes the new + // long at 5.2798; filling at 1841.71 realizes the remaining short loss + // and leaves a positive restore amount below the 0.0001 lot step. + position_side_ = PositionSide::SHORT; + position_cycle_seq_ = next_position_cycle_seq_++; + position_entry_price_ = 1821.96; + position_entry_time_ = current_bar_.timestamp - 1000; + position_qty_ = 5.2524; + position_entry_count_ = 1; + position_open_bar_ = -1; + trail_best_price_ = position_entry_price_; + net_profit_sum_ = -172.449012; + pyramid_entries_.clear(); + id_unclosed_qty_.clear(); + pyramid_entries_.push_back( + {position_entry_price_, position_entry_time_, position_qty_, + "SEED", -1}); + pyramid_entries_.back().entry_incarnation = 1; + snapshot_entry_commission(pyramid_entries_.back()); + id_unclosed_qty_["SEED"] = position_qty_; + + if (explicit_reversal_) { + strategy_entry("L", true, kNaN, kNaN, 5.2798000001); + } else { + strategy_entry("L", true); + } + } + +private: + bool explicit_reversal_; +}; + +static std::vector default_long_reversal_floor_zero_bars() { + return { + mk_bar(1000, 1841.70, 1841.70, 1841.70, 1841.70, 1.0), + mk_bar(2000, 1841.71, 1841.71, 1841.71, 1841.71, 1.0), + }; +} + +static void test_default_long_reversal_floor_zero_closes_one_contract() { + std::printf("test_default_long_reversal_floor_zero_closes_one_contract\n"); + DefaultLongReversalFloorZeroProbe eng(/*explicit_reversal=*/false); + auto bars = default_long_reversal_floor_zero_bars(); + eng.run(bars.data(), static_cast(bars.size())); + + CHECK(eng.trade_count() == 2); // seed close + same-fill long MC trim + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.exit_comment(1) == std::string("Margin call")); + CHECK(near(eng.entry_price(1), 1841.71)); + CHECK(near(eng.exit_price(1), 1841.71)); + CHECK(near(eng.trade_size(1), 1.0)); + CHECK(near(eng.position_size(), 4.2798)); +} + +static void test_explicit_long_reversal_floor_zero_remains_dust() { + std::printf("test_explicit_long_reversal_floor_zero_remains_dust\n"); + DefaultLongReversalFloorZeroProbe eng(/*explicit_reversal=*/true); + auto bars = default_long_reversal_floor_zero_bars(); + eng.run(bars.data(), static_cast(bars.size())); + + CHECK(eng.trade_count() == 1); // seed close only; no broker trim + CHECK(margin_call_rows(eng) == 0); + CHECK(near(eng.position_size(), 5.2798)); +} + // A reused engine handle must clear the per-position state before on_bar of // the next run. Run 1 deliberately ends with an open position whose one-shot // event was consumed; run 2 observes a clean state before opening a new RAW @@ -1938,6 +2030,8 @@ int main() { test_zero_qty_add_does_not_duplicate_cash_per_order_fee(); test_flat_clears_and_raw_fresh_reuses_state(); test_reversal_captures_fresh_opening_state(); + test_default_long_reversal_floor_zero_closes_one_contract(); + test_explicit_long_reversal_floor_zero_remains_dust(); test_run_reuse_clears_opening_state(); test_long_leveraged_margin_call();