diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 1d41e51..3a5340c 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -648,12 +648,11 @@ class BacktestEngine { // 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; - // A close-then-open SHORT whose omitted 100%-of-equity MARKET order was - // placed while LONG after a full strategy.close in the same Pine - // evaluation, and therefore reaches the fill from FLAT with zero - // deferred-flip carry. The one-shot bit queues the fill-price affordability - // pass and then one ordinary adverse-price pass on that same bar, even when - // the opening check itself is a no-op. + // An eligible omitted 100%-of-equity MARKET short opening. This covers + // both the close-then-open shape that reaches the fill from FLAT and the + // direct LONG-to-SHORT auto-reversal shape. The one-shot bit queues the + // fill-price affordability pass and then one ordinary adverse-price pass + // on that same bar, even when the opening check itself is a no-op. bool close_then_short_opening_requires_adverse_retry_ = false; // Position-lifecycle provenance for a commissioned, omitted-qty, // percent-of-equity=100 MARKET short that fills from flat at 100% short @@ -661,6 +660,15 @@ class BacktestEngine { // live across partial margin trims and clears when the position lifecycle // ends or a later add changes its shape. bool commissioned_all_in_market_short_lifecycle_ = false; + // Position-lifecycle provenance for an omitted-qty, + // percent-of-equity=100 MARKET short opened by a direct LONG-to-SHORT + // auto-reversal at 100% short margin. It carries no commission or + // flat-admission requirement. Broker margin-call reductions preserve the + // lifecycle; a script-driven reduction, add, full close, or fresh position + // clears it. At a later finite-price floor-zero margin call, this lifecycle + // selects the one-contract fallback only when the configured full-residual + // interpretation is off. + bool default_market_direct_short_reversal_lifecycle_ = false; double opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); int64_t position_entry_time_ = 0; diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 13c668c..80da437 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -931,7 +931,8 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if ((fee_created_floor_zero_candidate || opening_event_default_long_reversal || (opening_event_default_short_reversal - && commissioned_all_in_market_short_lifecycle_)) + && (commissioned_all_in_market_short_lifecycle_ + || default_market_direct_short_reversal_lifecycle_))) && qty_step_ > 0.0 && qty_step_ <= 1.0 && raw_q_min > kQtyEpsilon @@ -968,7 +969,10 @@ void BacktestEngine::process_margin_call(const Bar& bar) { return; } bool used_one_contract_fallback = false; - if (commissioned_all_in_market_short_lifecycle_ + const bool one_contract_short_lifecycle = + commissioned_all_in_market_short_lifecycle_ + || default_market_direct_short_reversal_lifecycle_; + if (one_contract_short_lifecycle && !margin_zero_cover_full_liquidation_ && position_side_ == PositionSide::SHORT && qty_step_ <= 1.0 @@ -3344,6 +3348,43 @@ void BacktestEngine::apply_filled_order_to_state( default_market_short_shape_after_fill && order.created_position_side == PositionSide::FLAT && !order.created_after_position_close_in_bar; + // A direct, default-sized strategy.entry auto-reversal has the same + // broker opening checkpoints as the already-pinned close-then-short + // shape. Re-prove the generic order/runtime topology at the fill. + const bool default_market_direct_short_reversal_after_fill = + successful_fresh_open + && position_side_before_fill == PositionSide::LONG + && position_side_ == PositionSide::SHORT + && order.type == OrderType::MARKET + && !order.is_long + && std::isnan(order.qty) + && order.created_position_side == PositionSide::LONG + && !order.created_after_position_close_in_bar + && order.tv_carry_qty > kQtyEpsilon + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && std::isfinite(margin_short_) + && std::abs(margin_short_ / 100.0 - 1.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_mark) + && order.sizing_mark > 0.0 + && std::isfinite(order.sizing_fx) + && order.sizing_fx > 0.0 + && slippage_ == 0 + && !process_orders_on_close_ + && !calc_on_order_fills_ + && !bar_magnifier_enabled_ + && !stream_warmup_mode_ + && stream_phase_ == StreamPhase::IDLE + && !order.created_during_coof_recalc + && order.created_bar < bar_index_ + && order.oca_name.empty() + && order.oca_type == 0; const bool default_market_long_close_then_open_after_fill = successful_fresh_open && position_side_before_fill == PositionSide::FLAT @@ -3388,11 +3429,14 @@ void BacktestEngine::apply_filled_order_to_state( commissioned_all_in_market_short_lifecycle_ = default_market_short_close_then_open_after_fill || default_market_flat_short_after_fill; + default_market_direct_short_reversal_lifecycle_ = + default_market_direct_short_reversal_after_fill; } else if (accepted_additional_entry) { // A later add changes the exact position lifecycle whose TV // zero-cover behavior is pinned by the source tape. Fail closed // rather than lending the original provenance to the new shape. commissioned_all_in_market_short_lifecycle_ = false; + default_market_direct_short_reversal_lifecycle_ = false; } const bool positive_raw_base = std::isfinite(fill_price) && fill_price > 0.0; @@ -3401,7 +3445,8 @@ void BacktestEngine::apply_filled_order_to_state( && (successful_fresh_open || accepted_additional_entry); const bool scoped_short_opening_fill = (explicit_market_short_full_margin_after_fill - || default_market_short_close_then_open_after_fill) + || default_market_short_close_then_open_after_fill + || default_market_direct_short_reversal_after_fill) && positive_raw_base; if (successful_short_open_or_add && !scoped_short_opening_fill) { opening_affordability_pending_ = false; @@ -3414,7 +3459,8 @@ void BacktestEngine::apply_filled_order_to_state( } if ((long_full_margin_after_fill || explicit_market_short_full_margin_after_fill - || default_market_short_close_then_open_after_fill) + || default_market_short_close_then_open_after_fill + || default_market_direct_short_reversal_after_fill) && positive_raw_base && (successful_fresh_open || accepted_additional_entry)) { // The only exemption requires every item of provenance to agree: @@ -3482,7 +3528,8 @@ void BacktestEngine::apply_filled_order_to_state( && std::isfinite(new_opening_commission) && new_opening_commission == 0.0; close_then_short_opening_requires_adverse_retry_ = - default_market_short_close_then_open_after_fill; + default_market_short_close_then_open_after_fill + || default_market_direct_short_reversal_after_fill; opening_affordability_raw_fill_base_ = fill_price; } } @@ -4093,6 +4140,7 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price opening_affordability_default_long_reversal_ = false; close_then_short_opening_requires_adverse_retry_ = false; commissioned_all_in_market_short_lifecycle_ = false; + default_market_direct_short_reversal_lifecycle_ = 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 a0b029b..578a3f1 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -594,6 +594,7 @@ void BacktestEngine::reset_position_state_to_flat() { opening_affordability_default_long_reversal_ = false; close_then_short_opening_requires_adverse_retry_ = false; commissioned_all_in_market_short_lifecycle_ = false; + default_market_direct_short_reversal_lifecycle_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); position_entry_time_ = 0; @@ -636,6 +637,7 @@ void BacktestEngine::settle_position_after_partial_exit( && position_side_ == PositionSide::SHORT && position_qty_ + kQtyEpsilon < qty_before) { commissioned_all_in_market_short_lifecycle_ = false; + default_market_direct_short_reversal_lifecycle_ = false; } } } @@ -660,6 +662,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri opening_affordability_default_long_reversal_ = false; close_then_short_opening_requires_adverse_retry_ = false; commissioned_all_in_market_short_lifecycle_ = false; + default_market_direct_short_reversal_lifecycle_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); position_entry_time_ = current_bar_.timestamp; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0f334ad..28fb955 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,6 +74,7 @@ set(TEST_SOURCES test_fills_edge test_default_qty_signal_freeze test_margin_admission_gate + test_direct_short_reversal_affordability test_short_reversal_emission test_margin_stop_admission test_stop_entry_placement_open_qty diff --git a/tests/test_direct_short_reversal_affordability.cpp b/tests/test_direct_short_reversal_affordability.cpp new file mode 100644 index 0000000..56923af --- /dev/null +++ b/tests/test_direct_short_reversal_affordability.cpp @@ -0,0 +1,697 @@ +/* + * Focused coverage for direct default-sized short-reversal affordability. + * + * An omitted-qty, percent-of-equity=100 MARKET LONG-to-SHORT reversal at + * margin_short=100 receives a fill-price affordability pass followed by one + * bounded adverse-high retry. Its position-lifecycle bit also participates in + * the established one-contract finite-price floor-zero fallback, while the + * optional full-residual interpretation keeps precedence. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +namespace { + +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +int tests_passed = 0; +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(actual, expected, tolerance) \ + do { \ + const double _actual = (actual); \ + const double _expected = (expected); \ + if (!(std::fabs(_actual - _expected) <= (tolerance))) { \ + std::printf(" FAIL %s:%d %s == %.12f, expected %.12f\n", \ + __FILE__, __LINE__, #actual, _actual, _expected); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +Bar bar(int64_t timestamp, double open, double high, double low, double close) { + Bar out; + out.timestamp = timestamp; + out.open = open; + out.high = high; + out.low = low; + out.close = close; + out.volume = 1.0; + return out; +} + +class DirectShortReversalProbe : public BacktestEngine { +public: + double position_size() const { return signed_position_size(); } + bool direct_lifecycle_active() const { + return default_market_direct_short_reversal_lifecycle_; + } + bool opening_event_cleared() const { + return !opening_affordability_pending_ + && !opening_affordability_eligible_ + && !close_then_short_opening_requires_adverse_retry_ + && std::isnan(opening_affordability_raw_fill_base_); + } + + std::vector margin_quantities() const { + std::vector out; + for (int index = 0; index < trade_count(); ++index) { + if (closed_trade_exit_comment(index) == "Margin call") { + out.push_back(closed_trade_size(index)); + } + } + return out; + } + + std::vector margin_prices() const { + std::vector out; + for (int index = 0; index < trade_count(); ++index) { + if (closed_trade_exit_comment(index) == "Margin call") { + out.push_back(closed_trade_exit_price(index)); + } + } + return out; + } + +protected: + void seed_position(PositionSide side, + double entry, + double qty, + const std::string& entry_key, + double realized_net_profit) { + position_side_ = side; + position_cycle_seq_ = next_position_cycle_seq_++; + position_entry_price_ = entry; + position_entry_time_ = current_bar_.timestamp; + position_qty_ = qty; + position_entry_count_ = 1; + position_open_bar_ = bar_index_; + trail_best_price_ = entry; + net_profit_sum_ = realized_net_profit; + pyramid_entries_.clear(); + id_unclosed_qty_.clear(); + pyramid_entries_.push_back( + {entry, position_entry_time_, qty, entry_key, bar_index_}); + pyramid_entries_.back().entry_incarnation = 1; + snapshot_entry_commission(pyramid_entries_.back()); + id_unclosed_qty_[entry_key] = qty; + } +}; + +class OpeningRetryProbe final : public DirectShortReversalProbe { +public: + OpeningRetryProbe() { + initial_capital_ = 99764.603236; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.03; + margin_long_ = 100.0; + margin_short_ = 100.0; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + seed_position( + PositionSide::LONG, 3167.25, 31.4892, "L", + /*realized_net_profit=*/0.0); + strategy_entry("S", false, kNaN, kNaN, kNaN); + } +}; + +void test_direct_reversal_runs_opening_check_and_one_adverse_retry() { + std::printf( + "test_direct_reversal_runs_opening_check_and_one_adverse_retry\n"); + const std::vector bars = { + bar(1000, 3145.00, 3145.00, 3145.00, 3145.00), + bar(2000, 3145.01, 3154.20, 3144.00, 3150.00), + }; + OpeningRetryProbe probe; + probe.run(bars.data(), static_cast(bars.size())); + const std::vector qty = probe.margin_quantities(); + const std::vector price = probe.margin_prices(); + + CHECK(qty.size() == 2U); + CHECK(price.size() == 2U); + if (qty.size() == 2U && price.size() == 2U) { + CHECK_NEAR(qty[0], 0.0376, 1e-9); + CHECK_NEAR(price[0], 3145.01, 1e-9); + CHECK_NEAR(qty[1], 0.6204, 1e-9); + CHECK_NEAR(price[1], 3154.20, 1e-9); + } + CHECK_NEAR(probe.position_size(), -30.8219, 1e-9); + CHECK(probe.direct_lifecycle_active()); + CHECK(probe.opening_event_cleared()); +} + +class FloorZeroPrecedenceProbe final : public DirectShortReversalProbe { +public: + explicit FloorZeroPrecedenceProbe(bool full_residual) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + set_syminfo_metadata( + "margin_zero_cover_full_liquidation", + full_residual ? 1.0 : 0.0); + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + seed_position( + PositionSide::LONG, 4629.63, 2.7738, "L", + /*realized_net_profit=*/2841.8043809999995); + strategy_entry("S", false, kNaN, kNaN, kNaN); + strategy_close("L"); + } +}; + +void test_direct_lifecycle_floor_zero_and_full_residual_precedence() { + std::printf( + "test_direct_lifecycle_floor_zero_and_full_residual_precedence\n"); + const std::vector bars = { + bar(1000, 4506.71, 4506.71, 4506.71, 4506.71), + bar(2000, 4506.70, 4514.70, 4500.00, 4506.70), + bar(3000, 4514.70, 4539.00, 4500.00, 4530.00), + }; + + FloorZeroPrecedenceProbe one_contract(/*full_residual=*/false); + one_contract.run(bars.data(), static_cast(bars.size())); + const std::vector one_contract_qty = + one_contract.margin_quantities(); + const std::vector one_contract_price = + one_contract.margin_prices(); + CHECK(one_contract_qty.size() == 2U); + CHECK(one_contract_price.size() == 2U); + if (one_contract_qty.size() == 2U + && one_contract_price.size() == 2U) { + CHECK_NEAR(one_contract_qty[0], 0.0392, 1e-9); + CHECK_NEAR(one_contract_price[0], 4514.70, 1e-9); + CHECK_NEAR(one_contract_qty[1], 1.0, 1e-9); + CHECK_NEAR(one_contract_price[1], 4539.00, 1e-9); + } + CHECK_NEAR(one_contract.position_size(), -1.7346, 1e-9); + CHECK(one_contract.direct_lifecycle_active()); + + FloorZeroPrecedenceProbe full_residual(/*full_residual=*/true); + full_residual.run(bars.data(), static_cast(bars.size())); + const std::vector full_residual_qty = + full_residual.margin_quantities(); + const std::vector full_residual_price = + full_residual.margin_prices(); + CHECK(full_residual_qty.size() == 2U); + CHECK(full_residual_price.size() == 2U); + if (full_residual_qty.size() == 2U + && full_residual_price.size() == 2U) { + CHECK_NEAR(full_residual_qty[0], 0.0392, 1e-9); + CHECK_NEAR(full_residual_price[0], 4514.70, 1e-9); + CHECK_NEAR(full_residual_qty[1], 2.7346, 1e-9); + CHECK_NEAR(full_residual_price[1], 4539.00, 1e-9); + } + CHECK_NEAR(full_residual.position_size(), 0.0, 1e-9); + CHECK(!full_residual.direct_lifecycle_active()); +} + +class TrueFlatFullResidualControlProbe final + : public DirectShortReversalProbe { +public: + TrueFlatFullResidualControlProbe() { + initial_capital_ = 10000.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.05; + margin_short_ = 100.0; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + set_syminfo_metadata( + "margin_zero_cover_full_liquidation", 1.0); + + constexpr double qty = 3.6930; + constexpr double entry = 1799.94; + constexpr double adverse = 1801.26; + constexpr double raw_q_min = 0.00005; + current_bar_ = bar(1000, entry, entry, entry, entry); + bar_index_ = 0; + seed_position( + PositionSide::SHORT, entry, qty, "S", + /*realized_net_profit=*/0.0); + const double open_fee = surviving_open_percent_commission_account(); + net_profit_sum_ = + (qty - raw_q_min) * adverse - initial_capital_ + open_fee + + (adverse - entry) * qty; + commissioned_all_in_market_short_lifecycle_ = true; + } + + void on_bar(const Bar&) override {} + + void trigger() { + current_bar_ = + bar(2000, 1800.00, 1801.26, 1799.50, 1800.50); + bar_index_ = 1; + process_margin_call(current_bar_); + } +}; + +void test_true_flat_full_residual_control_is_unchanged() { + std::printf("test_true_flat_full_residual_control_is_unchanged\n"); + TrueFlatFullResidualControlProbe probe; + probe.trigger(); + const std::vector qty = probe.margin_quantities(); + CHECK(qty.size() == 1U); + if (qty.size() == 1U) { + CHECK_NEAR(qty[0], 3.6930, 1e-9); + } + CHECK_NEAR(probe.position_size(), 0.0, 1e-9); + CHECK(!probe.direct_lifecycle_active()); +} + +class DirectPathExclusionProbe final : public DirectShortReversalProbe { +public: + enum class Mechanism { + ExplicitQuantity, + FixedSizing, + PartialPercentSizing, + LeveragedShortMargin, + PricedOrder, + Slippage, + ProcessOnClose, + CalcOnFill, + BarMagnifier, + StreamWarmup, + StreamRealtime, + OcaMembership, + }; + + explicit DirectPathExclusionProbe(Mechanism mechanism) + : mechanism_(mechanism) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + margin_call_enabled_ = false; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + + switch (mechanism_) { + case Mechanism::FixedSizing: + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 100.0; + break; + case Mechanism::PartialPercentSizing: + default_qty_value_ = 50.0; + break; + case Mechanism::LeveragedShortMargin: + margin_short_ = 50.0; + break; + case Mechanism::Slippage: + slippage_ = 1; + break; + case Mechanism::ProcessOnClose: + process_orders_on_close_ = true; + break; + case Mechanism::CalcOnFill: + calc_on_order_fills_ = true; + break; + case Mechanism::BarMagnifier: + bar_magnifier_enabled_ = true; + break; + case Mechanism::StreamWarmup: + stream_warmup_mode_ = true; + break; + case Mechanism::StreamRealtime: + stream_phase_ = StreamPhase::REALTIME; + break; + default: + break; + } + } + + void on_bar(const Bar&) override {} + + void exercise() { + current_bar_ = bar(1000, 100.0, 100.0, 100.0, 100.0); + bar_index_ = 0; + seed_position( + PositionSide::LONG, 100.0, 100.0, "L", + /*realized_net_profit=*/0.0); + + if (mechanism_ == Mechanism::ExplicitQuantity) { + strategy_entry("S", false, kNaN, kNaN, 100.0); + } else if (mechanism_ == Mechanism::PricedOrder) { + strategy_entry("S", false, /*limit_price=*/100.0); + } else if (mechanism_ == Mechanism::OcaMembership) { + strategy_entry( + "S", false, kNaN, kNaN, kNaN, "", + /*oca_name=*/"G", /*oca_type=*/1); + } else { + strategy_entry("S", false, kNaN, kNaN, kNaN); + } + + current_bar_ = bar(2000, 100.0, 100.0, 100.0, 100.0); + bar_index_ = 1; + process_pending_orders(current_bar_); + + reversal_filled = + position_side_ == PositionSide::SHORT + && position_qty_ > 1e-9; + direct_opening_path_armed = + opening_affordability_pending_ + && opening_affordability_eligible_ + && close_then_short_opening_requires_adverse_retry_ + && std::isfinite(opening_affordability_raw_fill_base_); + } + + bool reversal_filled = false; + bool direct_opening_path_armed = false; + +private: + Mechanism mechanism_; +}; + +void test_direct_path_exclusion_matrix() { + std::printf("test_direct_path_exclusion_matrix\n"); + using Mechanism = DirectPathExclusionProbe::Mechanism; + struct Case { + const char* label; + Mechanism mechanism; + }; + const std::array cases = {{ + {"explicit quantity", Mechanism::ExplicitQuantity}, + {"fixed sizing", Mechanism::FixedSizing}, + {"partial percent sizing", Mechanism::PartialPercentSizing}, + {"leveraged short margin", Mechanism::LeveragedShortMargin}, + {"priced order", Mechanism::PricedOrder}, + {"slippage", Mechanism::Slippage}, + {"process on close", Mechanism::ProcessOnClose}, + {"calc on fill", Mechanism::CalcOnFill}, + {"bar magnifier", Mechanism::BarMagnifier}, + {"stream warmup", Mechanism::StreamWarmup}, + {"stream realtime", Mechanism::StreamRealtime}, + {"OCA membership", Mechanism::OcaMembership}, + }}; + + for (const Case& test_case : cases) { + std::printf(" %s\n", test_case.label); + DirectPathExclusionProbe probe(test_case.mechanism); + probe.exercise(); + CHECK(probe.reversal_filled); + CHECK(!probe.direct_lifecycle_active()); + CHECK(!probe.direct_opening_path_armed); + } +} + +class NoEffectAddProbe final : public DirectShortReversalProbe { +public: + enum class Attempt { + RejectedByPyramiding, + QuantizedToZero, + }; + + explicit NoEffectAddProbe(Attempt attempt) : attempt_(attempt) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + margin_call_enabled_ = false; + pyramiding_ = + attempt_ == Attempt::RejectedByPyramiding ? 1 : 2; + qty_step_ = + attempt_ == Attempt::QuantizedToZero ? 1.0 : 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override {} + + void exercise() { + current_bar_ = bar(1000, 100.0, 100.0, 100.0, 100.0); + bar_index_ = 0; + seed_position( + PositionSide::LONG, 100.0, 100.0, "L", + /*realized_net_profit=*/0.0); + strategy_entry("S", false, kNaN, kNaN, kNaN); + + current_bar_ = bar(2000, 100.0, 100.0, 100.0, 100.0); + bar_index_ = 1; + process_pending_orders(current_bar_); + captured_before_attempt = direct_lifecycle_active(); + process_margin_call(current_bar_); + qty_before_attempt = position_qty_; + + const double requested_qty = + attempt_ == Attempt::QuantizedToZero ? 0.5 : 1.0; + strategy_entry("A", false, kNaN, kNaN, requested_qty); + current_bar_ = bar(3000, 100.0, 100.0, 100.0, 100.0); + bar_index_ = 2; + process_pending_orders(current_bar_); + + quantity_unchanged = + std::abs(position_qty_ - qty_before_attempt) <= 1e-9; + provenance_preserved = direct_lifecycle_active(); + } + + bool captured_before_attempt = false; + bool quantity_unchanged = false; + bool provenance_preserved = false; + double qty_before_attempt = 0.0; + +private: + Attempt attempt_; +}; + +void test_no_effect_same_side_add_preserves_direct_provenance() { + std::printf( + "test_no_effect_same_side_add_preserves_direct_provenance\n"); + for (const NoEffectAddProbe::Attempt attempt : { + NoEffectAddProbe::Attempt::RejectedByPyramiding, + NoEffectAddProbe::Attempt::QuantizedToZero, + }) { + NoEffectAddProbe probe(attempt); + probe.exercise(); + CHECK(probe.captured_before_attempt); + CHECK(probe.quantity_unchanged); + CHECK(probe.provenance_preserved); + } +} + +class FreshOpeningResetProbe final : public BacktestEngine { +public: + enum class Opening { + HighLevelEntry, + RawOrder, + }; + + explicit FreshOpeningResetProbe(Opening opening) : opening_(opening) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + margin_long_ = 100.0; + margin_call_enabled_ = false; + } + + void on_bar(const Bar&) override {} + + void exercise() { + current_bar_ = bar(1000, 100.0, 100.0, 100.0, 100.0); + bar_index_ = 0; + default_market_direct_short_reversal_lifecycle_ = true; + if (opening_ == Opening::HighLevelEntry) { + strategy_entry("N", true, kNaN, kNaN, 1.0); + } else { + strategy_order("N", true, 1.0); + } + + current_bar_ = bar(2000, 100.0, 100.0, 100.0, 100.0); + bar_index_ = 1; + process_pending_orders(current_bar_); + fresh_open_filled = + position_side_ == PositionSide::LONG + && position_qty_ > 1e-9; + provenance_cleared = + !default_market_direct_short_reversal_lifecycle_; + } + + bool fresh_open_filled = false; + bool provenance_cleared = false; + +private: + Opening opening_; +}; + +void test_fresh_entry_and_raw_order_clear_direct_provenance() { + std::printf( + "test_fresh_entry_and_raw_order_clear_direct_provenance\n"); + for (const FreshOpeningResetProbe::Opening opening : { + FreshOpeningResetProbe::Opening::HighLevelEntry, + FreshOpeningResetProbe::Opening::RawOrder, + }) { + FreshOpeningResetProbe probe(opening); + probe.exercise(); + CHECK(probe.fresh_open_filled); + CHECK(probe.provenance_cleared); + } +} + +class LifecycleMutationProbe final : public DirectShortReversalProbe { +public: + enum class Mutation { + AcceptedAdd, + ScriptPartialReduction, + FullClose, + }; + + explicit LifecycleMutationProbe(Mutation mutation) + : mutation_(mutation) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + margin_call_enabled_ = false; + pyramiding_ = 2; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + seed_position( + PositionSide::LONG, 100.0, 100.0, "L", + /*realized_net_profit=*/0.0); + strategy_entry("S", false, kNaN, kNaN, kNaN); + } else if (bar_index_ == 1) { + captured_after_reversal = direct_lifecycle_active(); + if (mutation_ == Mutation::AcceptedAdd) { + strategy_entry("A", false, kNaN, kNaN, 1.0); + } else if (mutation_ == Mutation::ScriptPartialReduction) { + strategy_close( + "S", "", 1.0, kNaN, /*immediately=*/true); + mutation_applied = + position_side_ == PositionSide::SHORT + && position_qty_ > 1.0; + cleared_after_mutation = !direct_lifecycle_active(); + } else { + strategy_close( + "S", "", kNaN, kNaN, /*immediately=*/true); + mutation_applied = + position_side_ == PositionSide::FLAT; + cleared_after_mutation = !direct_lifecycle_active(); + } + } else if (bar_index_ == 2 + && mutation_ == Mutation::AcceptedAdd) { + mutation_applied = + position_side_ == PositionSide::SHORT + && position_entry_count_ == 2; + cleared_after_mutation = !direct_lifecycle_active(); + } + } + + bool captured_after_reversal = false; + bool mutation_applied = false; + bool cleared_after_mutation = false; + +private: + Mutation mutation_; +}; + +void test_direct_lifecycle_clears_on_script_mutations() { + std::printf("test_direct_lifecycle_clears_on_script_mutations\n"); + const std::vector bars = { + bar(1000, 100.0, 100.0, 100.0, 100.0), + bar(2000, 100.0, 100.0, 100.0, 100.0), + bar(3000, 90.0, 90.0, 90.0, 90.0), + bar(4000, 90.0, 90.0, 90.0, 90.0), + }; + + for (const LifecycleMutationProbe::Mutation mutation : { + LifecycleMutationProbe::Mutation::AcceptedAdd, + LifecycleMutationProbe::Mutation::ScriptPartialReduction, + LifecycleMutationProbe::Mutation::FullClose, + }) { + LifecycleMutationProbe probe(mutation); + probe.run(bars.data(), static_cast(bars.size())); + CHECK(probe.captured_after_reversal); + CHECK(probe.mutation_applied); + CHECK(probe.cleared_after_mutation); + } +} + +class RunResetControlProbe final : public BacktestEngine { +public: + RunResetControlProbe() { margin_call_enabled_ = false; } + + void on_bar(const Bar&) override {} + + void prime_direct_lifecycle() { + default_market_direct_short_reversal_lifecycle_ = true; + } + + bool direct_lifecycle_active() const { + return default_market_direct_short_reversal_lifecycle_; + } +}; + +void test_run_reset_clears_direct_lifecycle() { + std::printf("test_run_reset_clears_direct_lifecycle\n"); + const std::vector bars = { + bar(1000, 100.0, 100.0, 100.0, 100.0), + }; + RunResetControlProbe probe; + probe.prime_direct_lifecycle(); + CHECK(probe.direct_lifecycle_active()); + probe.run(bars.data(), static_cast(bars.size())); + CHECK(!probe.direct_lifecycle_active()); +} + +} // namespace + +int main() { + std::printf("--- direct short reversal affordability ---\n"); + test_direct_reversal_runs_opening_check_and_one_adverse_retry(); + test_direct_lifecycle_floor_zero_and_full_residual_precedence(); + test_true_flat_full_residual_control_is_unchanged(); + test_direct_path_exclusion_matrix(); + test_no_effect_same_side_add_preserves_direct_provenance(); + test_fresh_entry_and_raw_order_clear_direct_provenance(); + test_direct_lifecycle_clears_on_script_mutations(); + test_run_reset_clears_direct_lifecycle(); + std::printf( + "=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}