diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index d74a8cf..1d41e51 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -435,7 +435,8 @@ struct PendingOrder { 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] + // sizing_equity = current_equity() + open_profit(close(S)) + // - paid commission on surviving open lots [account ccy] // sizing_price = close(S) + slippage*mintick*(+1 buy/-1 sell) // At fill time the broker re-checks (see the gate in // apply_filled_order_to_state for the full evidence trail) @@ -506,7 +507,7 @@ struct PendingOrder { // 99.94%); mdfe3757 306/306. bool explicit_flat_admission_candidate = false; // Placement-time equity snapshot (account ccy) for the explicit-qty gate: - // current_equity() + open_profit(close(S)) == realized equity when flat + // percent_commission_live_equity(close(S)) == realized equity when flat // Captured at the explicit-qty MARKET placement point. NaN = no snapshot. double explicit_placement_equity = std::numeric_limits::quiet_NaN(); // Slipped signal close at placement (frozen_sizing_price convention: @@ -635,17 +636,31 @@ class BacktestEngine { bool opening_affordability_pending_ = false; bool opening_affordability_eligible_ = false; // The queued event came from a successful, commissioned, omitted-qty - // percent_of_equity=100 high-level MARKET long that was created and filled - // from true flat. process_margin_call combines this one-shot provenance - // with the actual margin/commission arithmetic before applying TV's - // fee-created, floor-zero one-contract fallback. Reversals, adds, + // percent_of_equity=100 high-level MARKET long that filled from flat. + // It covers the proven true-flat form and the separately proven + // close-then-open form. process_margin_call combines this one-shot + // provenance with the actual margin/commission arithmetic before applying + // TV's fee-created, floor-zero one-contract fallback. Adds, // explicit/priced/RAW orders and zero/CASH commission stay outside it. - bool opening_affordability_commissioned_default_flat_long_ = false; + bool commissioned_all_in_market_long_opening_affordability_ = 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; + // 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. + 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 + // margin. Unlike the one-shot close-then-short opening event, this remains + // 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; double opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); int64_t position_entry_time_ = 0; @@ -1394,6 +1409,16 @@ class BacktestEngine { pe.entry_commission_account = calc_commission(pe.price, pe.qty); } + // Sum the already-paid percent entry commission attributable to the + // still-open pyramid slices. FIFO partial exits scale each surviving + // snapshot. Cash commission types remain outside this TV-pinned rule. + double surviving_open_percent_commission_account() const; + + // TradingView debits percent entry commission at fill, while PineForge + // realizes both commission legs in net_profit_sum_ when the lot closes. + // Use this fee-net ledger for percent-of-equity sizing and broker margin. + double percent_commission_live_equity(double mark_price) const; + // --- Position sizing helper --- // PERCENT_OF_EQUITY / CASH size a budget that is denominated in ACCOUNT // currency (equity, and a strategy.cash default_qty_value are both @@ -1461,23 +1486,14 @@ class BacktestEngine { case QtyType::FIXED: return apply_qty_step(default_qty_value_); case QtyType::PERCENT_OF_EQUITY: { - // TradingView's percent-of-equity default sizing uses - // strategy.equity, i.e. initial capital + closed PnL + - // mark-to-market open PnL. Keep current_equity() as the - // closed-equity accessor used elsewhere, but size new default - // percent orders from the live equity snapshot so pyramid adds - // and same-bar/re-entry sizing see unrealized PnL. - // - // KNOWN RESIDUAL (unresolved, do not "fix" without a probe): - // for an entry sized while a position is open AND commission_ - // type=percent, TV appears to also deduct the entry fee already - // charged on the open lot (it debits at fill; we book both legs - // at close). Three independent reconstructions of TV's equity - // chain disagreed on whether that term belongs here — adding it - // made one strategy's predicted quantities exact and two others - // markedly worse, and it cut end-to-end coverage. Settle it - // with a clean-room TV probe (the KI-52 method), not algebra. - double equity = current_equity() + open_profit(current_bar_.close); + // KI-56's clean-room v6 flat/holding pair proves that an + // omitted percent-of-equity add sizes from mark-to-market + // equity AFTER the paid percent commission on surviving lots. + // This is a ledger rule, independent of pct=100, margin, or + // how the already-open position was sized. + const double equity = + percent_commission_live_equity(current_bar_.close); + if (!std::isfinite(equity)) return 0.0; double cash = reserve_percent_commission(equity * (default_qty_value_ / 100.0)) / active_account_currency_fx(); // Reject (qty 0) on a non-finite / non-positive fill price — a // degenerate $0/NaN print must NOT size as the raw % number. @@ -1561,7 +1577,7 @@ class BacktestEngine { o.frozen_default_qty = calc_qty(o.sizing_price); if (!std::isnan(o.sizing_equity)) { o.sizing_equity = - current_equity() + open_profit(current_bar_.close); + percent_commission_live_equity(current_bar_.close); } o.sizing_fx = active_account_currency_fx(); } @@ -2281,6 +2297,11 @@ class BacktestEngine { double calc_qty_for_type(double fill_price, double qty_value, int qty_type) const; private: + enum class PositionReductionCause { + SCRIPT_ORDER, + MARGIN_CALL, + }; + void execute_market_entry(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, @@ -2294,7 +2315,9 @@ class BacktestEngine { bool explicit_qty_prequantized, uint64_t entry_incarnation); void execute_market_exit(double fill_price); - void execute_partial_exit_qty(double fill_price, double qty_to_close); + void execute_partial_exit_qty( + double fill_price, double qty_to_close, + PositionReductionCause cause = PositionReductionCause::SCRIPT_ORDER); void execute_partial_exit(double fill_price, double qty_percent); void execute_partial_exit_by_entry(double fill_price, const std::string& from_entry); void execute_partial_exit_by_entry_qty(double fill_price, @@ -2509,7 +2532,8 @@ class BacktestEngine { void reset_run_state(); double account_currency_fx_at(int64_t timestamp_ms) const; double active_account_currency_fx() const; - void settle_position_after_partial_exit(); + void settle_position_after_partial_exit( + double qty_before, PositionReductionCause cause); void enter_market_from_flat(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index ed3aeb2..13c668c 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -102,6 +102,36 @@ bool is_true_flat_unlinked_stop_pair( } // namespace +double BacktestEngine::surviving_open_percent_commission_account() const { + if (commission_type_ != CommissionType::PERCENT + || !(commission_value_ > 0.0) + || position_side_ == PositionSide::FLAT) { + return 0.0; + } + + double debit = 0.0; + for (const auto& pe : pyramid_entries_) { + if (pe.qty <= kQtyEpsilon) continue; + const double fee = open_entry_commission(pe); + if (!std::isfinite(fee)) { + return std::numeric_limits::quiet_NaN(); + } + debit += fee; + } + return debit; +} + +double BacktestEngine::percent_commission_live_equity( + double mark_price) const { + const double paid_open_commission = + surviving_open_percent_commission_account(); + if (!std::isfinite(paid_open_commission)) { + return std::numeric_limits::quiet_NaN(); + } + return current_equity() + open_profit(mark_price) - paid_open_commission; +} + + bool internal::dual_stop_margin_decline_can_continue_path( const std::vector& orders, DualEntryStopPathWinner winner, @@ -627,7 +657,8 @@ bool BacktestEngine::process_carried_long_full_margin_fx_rollover( if (qty_liq >= qty - kQtyEpsilon) { execute_market_exit(bar.open); } else { - execute_partial_exit_qty(bar.open, qty_liq); + execute_partial_exit_qty( + bar.open, qty_liq, PositionReductionCause::MARGIN_CALL); } if (trades_.size() == trades_before) return false; @@ -665,16 +696,19 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // state that a later bar may reconstruct or reuse. const bool opening_event_pending = opening_affordability_pending_; 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_commissioned_default_long = + commissioned_all_in_market_long_opening_affordability_; const bool opening_event_default_long_reversal = opening_affordability_default_long_reversal_; + const bool opening_event_default_short_reversal = + close_then_short_opening_requires_adverse_retry_; 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; + commissioned_all_in_market_long_opening_affordability_ = false; opening_affordability_default_long_reversal_ = false; + close_then_short_opening_requires_adverse_retry_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); @@ -708,6 +742,13 @@ void BacktestEngine::process_margin_call(const Bar& bar) { short_full_margin && event_is_actionable; const bool opening_affordability = long_opening_affordability || short_opening_affordability; + const auto run_default_short_adverse_retry = [&]() { + if (short_opening_affordability + && opening_event_default_short_reversal + && position_side_ == PositionSide::SHORT) { + process_margin_call(bar); + } + }; // A carried 1x long has no adverse-price liquidation. A just-filled 1x // long with no event is likewise ineligible, while a pending-but-exempt @@ -793,7 +834,10 @@ void BacktestEngine::process_margin_call(const Bar& bar) { return; } const double required_margin = qty * margin_per_unit; - if (opening_equity >= required_margin) return; + if (opening_equity >= required_margin) { + run_default_short_adverse_retry(); + return; + } q_min = qty - opening_equity / margin_per_unit; // A source-faithful TV tape pins a discontinuous broker edge for the // exact one-shot provenance above: margin remains affordable before a @@ -817,7 +861,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { const double pre_fee_headroom = pre_fee_equity - required_margin; const double post_fee_deficit = required_margin - opening_equity; fee_created_floor_zero_candidate = - opening_event_commissioned_default_flat_long + opening_event_commissioned_default_long && commission_type_ == CommissionType::PERCENT && commission_value_ > 0.0 && entry_commission > 0.0 @@ -835,8 +879,11 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (!std::isfinite(adverse) || !(adverse > 0.0)) return; const double fx = active_account_currency_fx(); if (!std::isfinite(fx) || !(fx > 0.0)) return; - const double equity_adv = initial_capital_ + net_profit_sum_ - + direction * (adverse - position_entry_price_) * qty * pv * fx; + // KI-56's adverse-margin v6 discriminator leaves gross equity safely + // above required margin but fee-net equity below it; TV emits the + // margin call. Use the same fee-net ledger as percent sizing. + const double equity_adv = percent_commission_live_equity(adverse); + if (!std::isfinite(equity_adv)) return; const double margin_per_unit_adv = adverse * pv * fx * m; const double req_margin_adv = qty * margin_per_unit_adv; if (equity_adv >= req_margin_adv) return; @@ -844,7 +891,10 @@ void BacktestEngine::process_margin_call(const Bar& bar) { raw_exit_fill_base = adverse; } - if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return; + if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) { + run_default_short_adverse_retry(); + return; + } // Per-instrument lot quantization. TradingView floors the minimum-restore // qty to the instrument's quantity step BEFORE applying the 4x over- // liquidation — not after. Flooring the 4x PRODUCT instead injects a @@ -879,7 +929,9 @@ void BacktestEngine::process_margin_call(const Bar& bar) { std::numeric_limits::quiet_NaN(); if (opening_affordability && q_min <= kQtyEpsilon) { if ((fee_created_floor_zero_candidate - || opening_event_default_long_reversal) + || opening_event_default_long_reversal + || (opening_event_default_short_reversal + && commissioned_all_in_market_short_lifecycle_)) && qty_step_ > 0.0 && qty_step_ <= 1.0 && raw_q_min > kQtyEpsilon @@ -894,7 +946,10 @@ void BacktestEngine::process_margin_call(const Bar& bar) { opening_floor_zero_fallback = candidate; } } - if (!std::isfinite(opening_floor_zero_fallback)) return; + if (!std::isfinite(opening_floor_zero_fallback)) { + run_default_short_adverse_retry(); + return; + } } double qty_liq = std::isfinite(opening_floor_zero_fallback) ? opening_floor_zero_fallback @@ -908,16 +963,42 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // with it alpha-wizard-channel cascade-1 matches TV 19/19 bit-exact. double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_; if (floored <= kQtyEpsilon) { - if (opening_affordability) return; + if (opening_affordability) { + run_default_short_adverse_retry(); + return; + } + bool used_one_contract_fallback = false; + if (commissioned_all_in_market_short_lifecycle_ + && !margin_zero_cover_full_liquidation_ + && position_side_ == PositionSide::SHORT + && qty_step_ <= 1.0 + && raw_q_min > kQtyEpsilon + && raw_q_min < 1.0) { + const double candidate = std::min(1.0, qty); + const bool full_position_cap = + candidate >= qty - kQtyEpsilon; + const double gridded = apply_exit_qty_step(candidate); + const double grid_guard = std::max( + 1e-12, std::abs(candidate) * 1e-12); + if (full_position_cap + || std::abs(gridded - candidate) <= grid_guard) { + floored = candidate; + used_one_contract_fallback = true; + } + } // A finite-price liquidation IS required, but the documented // minimum-restore quantity truncates to zero at the instrument lot // precision. Exports disagree on this edge: some close the whole - // residual while others continue with a bounded nibble. Preserve - // the established one-step default, and expose the full-residual - // interpretation only through an explicit verifier candidate. - floored = margin_zero_cover_full_liquidation_ - ? qty - : std::min(qty_step_, qty); + // residual, the commissioned all-in short lifecycle closes one + // whole contract, and others continue with a bounded nibble. + // Preserve the existing full-residual candidate's precedence, + // then the lifecycle interpretation, then the established + // one-step default. + if (!used_one_contract_fallback) { + floored = margin_zero_cover_full_liquidation_ + ? qty + : std::min(qty_step_, qty); + } } qty_liq = floored; } @@ -935,7 +1016,9 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (qty_liq >= qty - kQtyEpsilon) { execute_market_exit(raw_exit_fill_base); } else { - execute_partial_exit_qty(raw_exit_fill_base, qty_liq); + execute_partial_exit_qty( + raw_exit_fill_base, qty_liq, + PositionReductionCause::MARGIN_CALL); } if (trades_.size() != trades_before) { ++broker_fill_event_seq_; @@ -945,6 +1028,11 @@ void BacktestEngine::process_margin_call(const Bar& bar) { trades_[ti].exit_comment = "Margin call"; trades_[ti].exit_id = "__margin_call__"; } + // A commissioned all-in close-then-short has two broker checkpoints on its + // fill bar: fill-price opening affordability (which may be a no-op), then + // the ordinary adverse-high check over the surviving short. The one-shot + // provenance bit was consumed above, so recursion is bounded to one retry. + run_default_short_adverse_retry(); } // ──────────────────────────────────────────────────────────────────── @@ -3189,6 +3277,11 @@ void BacktestEngine::apply_filled_order_to_state( && pyramid_entries_.size() > pyramid_lots_before_fill && pyramid_entries_.back().qty > kQtyEpsilon && position_qty_ > position_qty_before_fill + kQtyEpsilon; + const double new_opening_commission = + (successful_fresh_open || accepted_additional_entry) + && !pyramid_entries_.empty() + ? open_entry_commission(pyramid_entries_.back()) + : std::numeric_limits::quiet_NaN(); const bool long_full_margin_after_fill = position_side_ == PositionSide::LONG && std::isfinite(margin_long_) @@ -3205,24 +3298,123 @@ void BacktestEngine::apply_filled_order_to_state( && order.type == OrderType::MARKET && !order.is_long && std::isfinite(order.qty); + const bool default_market_short_shape_after_fill = + successful_fresh_open + && position_side_before_fill == PositionSide::FLAT + && position_side_ == PositionSide::SHORT + && order.type == OrderType::MARKET + && !order.is_long + && std::isnan(order.qty) + && std::abs(order.tv_carry_qty) <= kQtyEpsilon + && admitted_flat_on_frozen_sizing_price + && 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 + && commission_type_ == CommissionType::PERCENT + && std::isfinite(commission_value_) + && commission_value_ > 0.0 + && std::isfinite(new_opening_commission) + && new_opening_commission > 0.0 + && 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_short_close_then_open_after_fill = + default_market_short_shape_after_fill + && order.created_position_side == PositionSide::LONG + && order.created_after_position_close_in_bar; + const bool default_market_flat_short_after_fill = + default_market_short_shape_after_fill + && order.created_position_side == PositionSide::FLAT + && !order.created_after_position_close_in_bar; + const bool default_market_long_close_then_open_after_fill = + successful_fresh_open + && position_side_before_fill == PositionSide::FLAT + && position_side_ == PositionSide::LONG + && order.type == OrderType::MARKET + && order.is_long + && std::isnan(order.qty) + && order.created_position_side == PositionSide::SHORT + && order.created_after_position_close_in_bar + && std::abs(order.tv_carry_qty) <= kQtyEpsilon + && admitted_flat_on_frozen_sizing_price + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && std::isfinite(margin_long_) + && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12 + && commission_type_ == CommissionType::PERCENT + && std::isfinite(commission_value_) + && commission_value_ > 0.0 + && std::isfinite(new_opening_commission) + && new_opening_commission > 0.0 + && 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; + if (successful_fresh_open) { + commissioned_all_in_market_short_lifecycle_ = + default_market_short_close_then_open_after_fill + || default_market_flat_short_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; + } const bool positive_raw_base = std::isfinite(fill_price) && fill_price > 0.0; const bool successful_short_open_or_add = requested_side == PositionSide::SHORT && (successful_fresh_open || accepted_additional_entry); const bool scoped_short_opening_fill = - explicit_market_short_full_margin_after_fill + (explicit_market_short_full_margin_after_fill + || default_market_short_close_then_open_after_fill) && positive_raw_base; if (successful_short_open_or_add && !scoped_short_opening_fill) { opening_affordability_pending_ = false; opening_affordability_eligible_ = false; - opening_affordability_commissioned_default_flat_long_ = false; + commissioned_all_in_market_long_opening_affordability_ = false; opening_affordability_default_long_reversal_ = false; + close_then_short_opening_requires_adverse_retry_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); } if ((long_full_margin_after_fill - || explicit_market_short_full_margin_after_fill) + || explicit_market_short_full_margin_after_fill + || default_market_short_close_then_open_after_fill) && positive_raw_base && (successful_fresh_open || accepted_additional_entry)) { // The only exemption requires every item of provenance to agree: @@ -3231,10 +3423,6 @@ void BacktestEngine::apply_filled_order_to_state( // sizing_price; and an actually zero opening fee. Checking the // just-created pyramid lot avoids inferring a reversal/paired // reentry from trade count or discarding zero-PnL closes. - double new_opening_commission = - pyramid_entries_.empty() - ? std::numeric_limits::quiet_NaN() - : open_entry_commission(pyramid_entries_.back()); const bool frozen_all_in_true_flat_exemption = successful_fresh_open && order.opening_affordability_exemption_candidate @@ -3256,20 +3444,23 @@ void BacktestEngine::apply_filled_order_to_state( opening_affordability_eligible_ = accepted_additional_entry || !frozen_all_in_true_flat_exemption; - opening_affordability_commissioned_default_flat_long_ = - successful_fresh_open - && order.opening_affordability_exemption_candidate - && order.type == OrderType::MARKET - && order.is_long - && std::isnan(order.qty) - && order.created_position_side == PositionSide::FLAT - && !order.created_after_position_close_in_bar - && position_side_before_fill == PositionSide::FLAT - && admitted_flat_on_frozen_sizing_price - && commission_type_ == CommissionType::PERCENT - && commission_value_ > 0.0 - && std::isfinite(new_opening_commission) - && new_opening_commission > 0.0; + commissioned_all_in_market_long_opening_affordability_ = + (successful_fresh_open + && order.opening_affordability_exemption_candidate + && order.type == OrderType::MARKET + && order.is_long + && std::isnan(order.qty) + && order.created_position_side == PositionSide::FLAT + && !order.created_after_position_close_in_bar + && position_side_before_fill == PositionSide::FLAT + && admitted_flat_on_frozen_sizing_price + && commission_type_ == CommissionType::PERCENT + && commission_value_ > 0.0 + && std::isfinite(new_opening_commission) + && new_opening_commission > 0.0) + || (default_market_long_close_then_open_after_fill + && std::isfinite(new_opening_commission) + && new_opening_commission > 0.0); opening_affordability_default_long_reversal_ = successful_fresh_open && position_side_before_fill == PositionSide::SHORT @@ -3290,6 +3481,8 @@ void BacktestEngine::apply_filled_order_to_state( && order.sizing_fx > 0.0 && 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; opening_affordability_raw_fill_base_ = fill_price; } } @@ -3896,8 +4089,10 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price // open_fresh_position. opening_affordability_pending_ = false; opening_affordability_eligible_ = false; - opening_affordability_commissioned_default_flat_long_ = false; + commissioned_all_in_market_long_opening_affordability_ = false; opening_affordability_default_long_reversal_ = false; + close_then_short_opening_requires_adverse_retry_ = false; + commissioned_all_in_market_short_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 621485d..a0b029b 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -30,7 +30,9 @@ double BacktestEngine::calc_qty_for_type(double fill_price, double qty_value, in return apply_qty_step(qty_value); } if (qty_type == static_cast(QtyType::PERCENT_OF_EQUITY)) { - double equity = current_equity() + open_profit(current_bar_.close); + const double equity = + percent_commission_live_equity(current_bar_.close); + if (!std::isfinite(equity)) return 0.0; double cash = reserve_percent_commission(equity * (qty_value / 100.0)); // Reject (qty 0) on a non-finite / non-positive fill price — a degenerate // $0/NaN print must NOT size as the raw % number (silent wrong-qty bug). @@ -218,8 +220,10 @@ double BacktestEngine::fifo_drain(const std::string* from_entry, double qty_limi // Internal helper: execute a partial exit (reduce position by qty, create trade records) // TradingView creates individual trade records for each partial exit. -void BacktestEngine::execute_partial_exit_qty(double fill_price, double qty_to_close) { +void BacktestEngine::execute_partial_exit_qty( + double fill_price, double qty_to_close, PositionReductionCause cause) { if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; + const double qty_before = position_qty_; qty_to_close = std::clamp(qty_to_close, 0.0, position_qty_); if (qty_to_close <= kQtyEpsilon) return; @@ -229,7 +233,7 @@ void BacktestEngine::execute_partial_exit_qty(double fill_price, double qty_to_c // Close FIFO across all pyramid entries, creating trade records. fifo_drain(/*from_entry=*/nullptr, qty_to_close, fill_price, was_long); - settle_position_after_partial_exit(); + settle_position_after_partial_exit(qty_before, cause); } @@ -253,6 +257,7 @@ void BacktestEngine::execute_partial_exit(double fill_price, double qty_percent) // Internal helper: close only entries matching from_entry (close_entries_rule="ANY") void BacktestEngine::execute_partial_exit_by_entry(double fill_price, const std::string& from_entry) { if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; + const double qty_before = position_qty_; bool is_buy = (position_side_ == PositionSide::SHORT); fill_price = apply_fill_slippage(fill_price, is_buy); @@ -269,7 +274,8 @@ void BacktestEngine::execute_partial_exit_by_entry(double fill_price, const std: } pyramid_entries_ = std::move(remaining); - settle_position_after_partial_exit(); + settle_position_after_partial_exit( + qty_before, PositionReductionCause::SCRIPT_ORDER); } @@ -281,13 +287,15 @@ void BacktestEngine::execute_partial_exit_by_entry_qty( double fill_price, const std::string& from_entry, double qty_to_close) { if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; if (!std::isfinite(qty_to_close) || qty_to_close <= kQtyEpsilon) return; + const double qty_before = position_qty_; bool is_buy = (position_side_ == PositionSide::SHORT); fill_price = apply_fill_slippage(fill_price, is_buy); bool was_long = (position_side_ == PositionSide::LONG); fifo_drain(&from_entry, qty_to_close, fill_price, was_long); - settle_position_after_partial_exit(); + settle_position_after_partial_exit( + qty_before, PositionReductionCause::SCRIPT_ORDER); } @@ -335,6 +343,7 @@ double BacktestEngine::cover_samebar_market_adds_on_exit(const PendingOrder& ord || !std::isnan(order.trail_price); if (!priced_bracket) return 0.0; + const double qty_before = position_qty_; bool is_buy = (position_side_ == PositionSide::SHORT); double slipped = apply_fill_slippage(fill_price, is_buy); bool was_long = (position_side_ == PositionSide::LONG); @@ -355,7 +364,8 @@ double BacktestEngine::cover_samebar_market_adds_on_exit(const PendingOrder& ord if (closed <= kQtyEpsilon) return 0.0; // nothing covered pyramid_entries_ = std::move(remaining); position_qty_ -= closed; - settle_position_after_partial_exit(); + settle_position_after_partial_exit( + qty_before, PositionReductionCause::SCRIPT_ORDER); return closed; } @@ -580,8 +590,10 @@ void BacktestEngine::reset_position_state_to_flat() { position_entry_price_ = 0.0; opening_affordability_pending_ = false; opening_affordability_eligible_ = false; - opening_affordability_commissioned_default_flat_long_ = false; + commissioned_all_in_market_long_opening_affordability_ = false; opening_affordability_default_long_reversal_ = false; + close_then_short_opening_requires_adverse_retry_ = false; + commissioned_all_in_market_short_lifecycle_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); position_entry_time_ = 0; @@ -604,7 +616,8 @@ void BacktestEngine::reset_position_state_to_flat() { // recompute volume-weighted average entry price across surviving entries. // Body was previously inlined identically at the end of every partial-exit // path. -void BacktestEngine::settle_position_after_partial_exit() { +void BacktestEngine::settle_position_after_partial_exit( + double qty_before, PositionReductionCause cause) { if (position_qty_ <= kQtyEpsilon || pyramid_entries_.empty()) { reset_position_state_to_flat(); } else { @@ -615,6 +628,15 @@ void BacktestEngine::settle_position_after_partial_exit() { } position_entry_price_ = weighted_sum / total_qty; position_entry_count_ = (int)pyramid_entries_.size(); + // The one-contract floor-zero rule belongs to an otherwise unmodified + // commissioned all-in short lifecycle. Any script-driven surviving + // reduction changes that shape. Broker margin-call reductions are the + // sole exception: TV preserves the lifecycle across its own cascade. + if (cause != PositionReductionCause::MARGIN_CALL + && position_side_ == PositionSide::SHORT + && position_qty_ + kQtyEpsilon < qty_before) { + commissioned_all_in_market_short_lifecycle_ = false; + } } } @@ -634,8 +656,10 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri // transiently. opening_affordability_pending_ = false; opening_affordability_eligible_ = false; - opening_affordability_commissioned_default_flat_long_ = false; + commissioned_all_in_market_long_opening_affordability_ = false; opening_affordability_default_long_reversal_ = false; + close_then_short_opening_requires_adverse_retry_ = false; + commissioned_all_in_market_short_lifecycle_ = false; opening_affordability_raw_fill_base_ = std::numeric_limits::quiet_NaN(); position_entry_time_ = current_bar_.timestamp; @@ -912,13 +936,18 @@ void BacktestEngine::flip_market_position_to(const std::string& id, bool is_long emit_close_trade(pe, pe.qty, exit_fill, was_long); } + // The old lots are fully realized above. Clear their live position/PnL + // and entry-fee snapshots before sizing the incoming leg; otherwise a + // percent-typed reversal adds stale open PnL and debits the already- + // realized entry commission a second time. + reset_position_state_to_flat(); + if (close_only) { // Priced-entry reduce-only cases: either this order was armed during a // prior cycle and flips a later opposite position, or its same-cycle // frozen transaction exactly equals the grown live opposite position. // In both cases close the whole position and stay flat; do NOT open. // See apply_entry_order_fill's close_only_opposite predicates. - reset_position_state_to_flat(); return; } diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index cc33144..c15ecce 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -423,7 +423,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, // engine.hpp and the gate in apply_filled_order_to_state). order.sizing_price = frozen_sizing_price(/*is_buy=*/is_long); order.sizing_equity = - current_equity() + open_profit(current_bar_.close); + percent_commission_live_equity(current_bar_.close); order.sizing_mark = current_bar_.close; order.sizing_fx = active_account_currency_fx(); // Direction-neutral: two fill-time consumers read this flag. @@ -1998,7 +1998,7 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double // apply_filled_order_to_state. order.sizing_price = frozen_sizing_price(/*is_buy=*/is_long); order.sizing_equity = - current_equity() + open_profit(current_bar_.close); + percent_commission_live_equity(current_bar_.close); order.sizing_mark = current_bar_.close; order.sizing_fx = active_account_currency_fx(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8986c0d..0f334ad 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -102,6 +102,7 @@ set(TEST_SOURCES test_metrics test_drawing test_margin_call + test_percent_equity_open_entry_fee test_streaming test_calc_on_order_fills test_coof_cascade_eligibility diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index bf94e0e..99f625e 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -79,6 +79,9 @@ class MCEngine : public BacktestEngine { double liq_price() const { return margin_liquidation_price(); } bool opening_pending() const { return opening_affordability_pending_; } bool opening_eligible() const { return opening_affordability_eligible_; } + bool opening_default_short_reversal() const { + return close_then_short_opening_requires_adverse_retry_; + } double opening_raw_base() const { return opening_affordability_raw_fill_base_; } @@ -1362,6 +1365,654 @@ static void test_short_opening_event_scope_is_explicit_market_margin100_only() { } } +// Literal first reversal from a source-bound TV tape. The script closes Long +// and then emits an omitted-qty Short in the same evaluation. Paid entry fees +// reduce broker equity, and the close-then-short fill receives both the +// fill-price affordability checkpoint and one bounded adverse-high retry. +class CommissionedDefaultShortCheckpointProbe : public MCEngine { +public: + CommissionedDefaultShortCheckpointProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.05; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + calc_on_order_fills_ = false; + bar_magnifier_enabled_ = false; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Long", true, kNaN, kNaN, kNaN); + } else if (bar_index_ == 1) { + strategy_close("Long"); + strategy_entry("Short", false, kNaN, kNaN, kNaN); + } else if (bar_index_ == 2) { + captured_short_event = opening_affordability_pending_ + && opening_affordability_eligible_ + && close_then_short_opening_requires_adverse_retry_ + && commissioned_all_in_market_short_lifecycle_ + && near(opening_affordability_raw_fill_base_, 1798.09); + } + } + + bool captured_short_event = false; +}; + +struct DefaultShortCheckpointResult { + int margin_rows = 0; + int trade_rows = 0; + double position = 0.0; + bool captured = false; + std::vector margin_qty; + std::vector margin_exit; +}; + +static DefaultShortCheckpointResult run_commissioned_default_short_checkpoint() { + std::vector bars = { + mk_bar(1000, 1801.48, 1801.48, 1801.48, 1801.48, 1.0), + mk_bar(2000, 1801.48, 1801.48, 1798.09, 1798.09, 1.0), + mk_bar(3000, 1798.09, 1806.33, 1798.09, 1804.62, 1.0), + }; + CommissionedDefaultShortCheckpointProbe eng; + eng.run(bars.data(), static_cast(bars.size())); + + DefaultShortCheckpointResult result; + result.margin_rows = margin_call_rows(eng); + result.trade_rows = eng.trade_count(); + result.position = eng.position_size(); + result.captured = eng.captured_short_event; + for (int i = 0; i < eng.trade_count(); ++i) { + if (eng.exit_comment(i) != std::string("Margin call")) continue; + result.margin_qty.push_back(eng.trade_size(i)); + result.margin_exit.push_back(eng.exit_price(i)); + } + return result; +} + +static void test_commissioned_close_then_short_exact_checkpoints() { + std::printf( + "test_commissioned_close_then_short_exact_checkpoints\n"); + const DefaultShortCheckpointResult result = + run_commissioned_default_short_checkpoint(); + + CHECK(result.captured); + CHECK(result.margin_rows == 2); + CHECK(result.margin_qty.size() == 2); + CHECK(near(result.margin_qty[0], 0.0108, 1e-9)); + CHECK(near(result.margin_exit[0], 1798.09, 1e-9)); + CHECK(near(result.margin_qty[1], 0.1696, 1e-9)); + CHECK(near(result.margin_exit[1], 1806.33, 1e-9)); + CHECK(near(result.position, -5.3650, 1e-9)); +} + +// Mirror a separate close-then-entry order pair on the LONG side. A +// commissioned omitted-qty LONG created while SHORT after the close command +// retains the opening-affordability provenance needed by the broker trim. +class CommissionedCloseThenLongProbe : public MCEngine { +public: + explicit CommissionedCloseThenLongProbe(bool explicit_qty) + : explicit_qty_(explicit_qty) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.05; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Short", false, kNaN, kNaN, kNaN); + } else if (bar_index_ == 1) { + strategy_close("Short"); + const double qty = explicit_qty_ + ? frozen_default_market_qty(/*is_buy=*/true) : kNaN; + strategy_entry("Long", true, kNaN, kNaN, qty); + } else if (bar_index_ == 2) { + captured = opening_affordability_pending_ + && opening_affordability_eligible_ + && commissioned_all_in_market_long_opening_affordability_ + && position_side_ == PositionSide::LONG; + } + } + + bool captured = false; + +private: + bool explicit_qty_; +}; + +static void test_commissioned_close_then_long_floor_zero_scope() { + std::printf("test_commissioned_close_then_long_floor_zero_scope\n"); + std::vector bars = { + mk_bar(1000, 2968.50, 2968.50, 2968.50, 2968.50, 1.0), + mk_bar(2000, 2968.50, 2968.50, 2968.50, 2968.50, 1.0), + mk_bar(3000, 2967.80, 2967.80, 2967.80, 2967.80, 1.0), + }; + CommissionedCloseThenLongProbe omitted(/*explicit_qty=*/false); + omitted.run(bars.data(), static_cast(bars.size())); + CommissionedCloseThenLongProbe explicit_control(/*explicit_qty=*/true); + explicit_control.run(bars.data(), static_cast(bars.size())); + + CHECK(omitted.captured); + CHECK(!explicit_control.captured); + CHECK(omitted.trade_count() == 2); // short close + long margin trim + CHECK(margin_call_rows(omitted) == 1); + CHECK(omitted.exit_comment(1) == std::string("Margin call")); + CHECK(near(omitted.entry_price(1), 2967.80)); + CHECK(near(omitted.exit_price(1), 2967.80)); + CHECK(near(omitted.trade_size(1), 1.0, 1e-9)); + CHECK(explicit_control.trade_count() == 1); // short close only + CHECK(margin_call_rows(explicit_control) == 0); +} + +// After the close-then-short fill-price trim, its bounded ordinary adverse +// retry can +// require a positive restore quantity smaller than one configured lot. TV's +// source-bound tape closes one whole contract at that exact discontinuity; the +// established generic finite-price behavior closes one qty_step instead. +class DefaultShortLaterFloorZeroProbe : public MCEngine { +public: + explicit DefaultShortLaterFloorZeroProbe(bool full_residual = false) { + initial_capital_ = 10000.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.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); + + constexpr double qty = 3.6930; + constexpr double entry = 1799.94; + constexpr double adverse = 1801.26; + constexpr double raw_q_min = 0.00005; + position_side_ = PositionSide::SHORT; + position_cycle_seq_ = next_position_cycle_seq_++; + position_entry_price_ = entry; + position_entry_time_ = 1000; + position_qty_ = qty; + position_entry_count_ = 1; + position_open_bar_ = 0; + trail_best_price_ = entry; + net_profit_sum_ = + (qty - raw_q_min) * adverse - initial_capital_ + + (adverse - entry) * qty; + pyramid_entries_.push_back( + {entry, position_entry_time_, qty, "S", 0}); + pyramid_entries_.back().entry_incarnation = 1; + snapshot_entry_commission(pyramid_entries_.back()); + id_unclosed_qty_["S"] = qty; + } + + void on_bar(const Bar&) override {} + + void trigger(bool carry_s_provenance) { + current_bar_ = mk_bar( + 2000, 1800.00, 1801.26, 1799.50, 1800.50, 1.0); + bar_index_ = 1; + commissioned_all_in_market_short_lifecycle_ = carry_s_provenance; + process_margin_call(current_bar_); + } + + bool lifecycle_active() const { + return commissioned_all_in_market_short_lifecycle_; + } +}; + +static void test_default_short_lifecycle_floor_zero_one_contract() { + std::printf("test_default_short_lifecycle_floor_zero_one_contract\n"); + DefaultShortLaterFloorZeroProbe top_level; + top_level.trigger(/*carry_s_provenance=*/false); + DefaultShortLaterFloorZeroProbe one_contract; + one_contract.trigger(/*carry_s_provenance=*/true); + DefaultShortLaterFloorZeroProbe full_residual(/*full_residual=*/true); + full_residual.trigger(/*carry_s_provenance=*/true); + + // A normal short without default-opening lifecycle provenance retains the + // established one-step progress fallback. + CHECK(top_level.trade_count() == 1); + CHECK(near(top_level.trade_size(0), 0.0001, 1e-9)); + CHECK(near(top_level.position_size(), -3.6929, 1e-9)); + + CHECK(one_contract.trade_count() == 1); + CHECK(one_contract.exit_comment(0) == std::string("Margin call")); + CHECK(near(one_contract.exit_price(0), 1801.26)); + CHECK(near(one_contract.trade_size(0), 1.0, 1e-9)); + CHECK(near(one_contract.position_size(), -2.6930, 1e-9)); + CHECK(one_contract.lifecycle_active()); + + // The existing opt-in whole-residual interpretation retains precedence + // when a verifier deliberately combines both candidates. + CHECK(full_residual.trade_count() == 1); + CHECK(near(full_residual.trade_size(0), 3.6930, 1e-9)); + CHECK(near(full_residual.position_size(), 0.0, 1e-9)); +} + +// The commissioned lifecycle covers the opening checkpoint too. The positive +// restore quantity below is half one lot: without lifecycle provenance the +// event is a dust no-op; with it TV closes one contract at the raw fill base. +class DefaultShortOpeningFloorZeroProbe : public MCEngine { +public: + explicit DefaultShortOpeningFloorZeroProbe(bool carry_lifecycle) { + initial_capital_ = 1000.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.05; + margin_short_ = 100.0; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + + constexpr double qty = 10.0; + constexpr double entry = 100.0; + position_side_ = PositionSide::SHORT; + position_cycle_seq_ = next_position_cycle_seq_++; + position_entry_price_ = entry; + position_entry_time_ = 1000; + position_qty_ = qty; + position_entry_count_ = 1; + position_open_bar_ = 0; + net_profit_sum_ = 0.495; // entry fee 0.5 => q_min = 0.00005 + pyramid_entries_.push_back( + {entry, position_entry_time_, qty, "S", 0}); + pyramid_entries_.back().entry_incarnation = 1; + snapshot_entry_commission(pyramid_entries_.back()); + id_unclosed_qty_["S"] = qty; + opening_affordability_pending_ = true; + opening_affordability_eligible_ = true; + close_then_short_opening_requires_adverse_retry_ = true; + opening_affordability_raw_fill_base_ = entry; + commissioned_all_in_market_short_lifecycle_ = carry_lifecycle; + } + + void on_bar(const Bar&) override {} + + void trigger() { + current_bar_ = mk_bar(2000, 100.0, 100.0, 100.0, 100.0, 1.0); + bar_index_ = 1; + process_margin_call(current_bar_); + } +}; + +static void test_default_short_opening_floor_zero_one_contract() { + std::printf("test_default_short_opening_floor_zero_one_contract\n"); + DefaultShortOpeningFloorZeroProbe baseline(/*carry_lifecycle=*/false); + baseline.trigger(); + DefaultShortOpeningFloorZeroProbe enabled(/*carry_lifecycle=*/true); + enabled.trigger(); + + // Without the scoped one-contract lifecycle, the opening checkpoint + // dust-noops and its adverse retry follows the generic finite-price rule: + // fee-net equity is 1000 + .495 - .5 = 999.995, so the positive 0.00005 + // restore amount floors below one 0.0001 lot and closes one qty_step. + CHECK(baseline.trade_count() == 1); + CHECK(baseline.exit_comment(0) == std::string("Margin call")); + CHECK(near(baseline.trade_size(0), 0.0001, 1e-9)); + CHECK(near(baseline.position_size(), -9.9999, 1e-9)); + CHECK(enabled.trade_count() == 1); + CHECK(enabled.exit_comment(0) == std::string("Margin call")); + CHECK(near(enabled.entry_price(0), 100.0)); + CHECK(near(enabled.exit_price(0), 100.0)); + CHECK(near(enabled.trade_size(0), 1.0, 1e-9)); + CHECK(near(enabled.position_size(), -9.0, 1e-9)); +} + +// A close-then-short fill-price opening check can be affordable while the same +// bar's high is +// already adverse enough to require an ordinary margin call. The opening event +// must schedule that second checkpoint even though it emitted no trade, and it +// must be consumed before recursion so the retry occurs exactly once. +class DefaultShortAffordableOpeningAdverseProbe : public MCEngine { +public: + DefaultShortAffordableOpeningAdverseProbe() { + initial_capital_ = 1000.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.05; + margin_short_ = 100.0; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + + constexpr double qty = 9.99; + constexpr double entry = 100.0; + position_side_ = PositionSide::SHORT; + position_cycle_seq_ = next_position_cycle_seq_++; + position_entry_price_ = entry; + position_entry_time_ = 1000; + position_qty_ = qty; + position_entry_count_ = 1; + position_open_bar_ = 0; + trail_best_price_ = entry; + pyramid_entries_.push_back( + {entry, position_entry_time_, qty, "S", 0}); + pyramid_entries_.back().entry_incarnation = 1; + snapshot_entry_commission(pyramid_entries_.back()); + id_unclosed_qty_["S"] = qty; + opening_affordability_pending_ = true; + opening_affordability_eligible_ = true; + close_then_short_opening_requires_adverse_retry_ = true; + opening_affordability_raw_fill_base_ = entry; + commissioned_all_in_market_short_lifecycle_ = true; + } + + void on_bar(const Bar&) override {} + + void trigger() { + current_bar_ = mk_bar(2000, 100.0, 105.0, 99.0, 100.0, 1.0); + bar_index_ = 1; + process_margin_call(current_bar_); + event_cleared = !opening_affordability_pending_ + && !opening_affordability_eligible_ + && !close_then_short_opening_requires_adverse_retry_ + && std::isnan(opening_affordability_raw_fill_base_); + } + + bool event_cleared = false; +}; + +static void test_default_short_affordable_opening_retries_adverse_once() { + std::printf( + "test_default_short_affordable_opening_retries_adverse_once\n"); + DefaultShortAffordableOpeningAdverseProbe probe; + probe.trigger(); + + CHECK(probe.trade_count() == 1); + CHECK(probe.exit_comment(0) == std::string("Margin call")); + CHECK(near(probe.entry_price(0), 100.0)); + CHECK(near(probe.exit_price(0), 105.0)); + // TV's adverse-margin ledger debits the surviving opening commission: + // equity = 1000 + (100 - 105) * 9.99 - .4995 = 949.5505; + // q_min = 9.99 - 949.5505 / 105 = 0.9466619..., which floors to + // 0.9466 before TV's 4x liquidation multiplier. + CHECK(near(probe.trade_size(0), 3.7864, 1e-9)); + CHECK(near(probe.position_size(), -6.2036, 1e-9)); + CHECK(probe.event_cleared); +} + +// A default-sized commissioned short opened from true flat has its own +// lifecycle provenance. It must not inherit the prior close-then-short token, +// and an ordinary add or fresh position will clear it through the shared +// lifecycle reset sites. +class CommissionedDefaultFlatShortLifecycleProbe : public MCEngine { +public: + explicit CommissionedDefaultFlatShortLifecycleProbe(bool commissioned) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = commissioned ? 0.05 : 0.0; + margin_short_ = 100.0; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Short", false, kNaN, kNaN, kNaN); + } else if (bar_index_ == 1) { + captured = position_side_ == PositionSide::SHORT + && commissioned_all_in_market_short_lifecycle_; + } + } + + bool captured = false; +}; + +static void test_commissioned_default_flat_short_lifecycle_tag() { + std::printf("test_commissioned_default_flat_short_lifecycle_tag\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(2000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(3000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + CommissionedDefaultFlatShortLifecycleProbe uncommissioned( + /*commissioned=*/false); + uncommissioned.run(bars.data(), static_cast(bars.size())); + CommissionedDefaultFlatShortLifecycleProbe commissioned( + /*commissioned=*/true); + commissioned.run(bars.data(), static_cast(bars.size())); + + CHECK(!uncommissioned.captured); + CHECK(commissioned.captured); + CHECK(uncommissioned.trade_count() == 0); + CHECK(commissioned.trade_count() == 0); + CHECK(uncommissioned.position_size() < -1e-9); + CHECK(commissioned.position_size() < -1e-9); +} + +// After a prior LONG has already been fully liquidated, the next default SHORT +// is a true-flat open rather than a close-then-open reversal. Its adverse +// restore amount is positive but below one lot; TV caps the one-contract +// fallback to the entire 0.3383-contract residual. +class DefaultFlatShortFloorZeroProbe : public MCEngine { +public: + explicit DefaultFlatShortFloorZeroProbe(bool carry_flat_lifecycle) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.05; + margin_short_ = 100.0; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + + constexpr double qty = 0.3383; + constexpr double entry = 3734.88; + position_side_ = PositionSide::SHORT; + position_cycle_seq_ = next_position_cycle_seq_++; + position_entry_price_ = entry; + position_entry_time_ = 1000; + position_qty_ = qty; + position_entry_count_ = 1; + position_open_bar_ = 0; + trail_best_price_ = entry; + net_profit_sum_ = -8735.542085; + pyramid_entries_.push_back( + {entry, position_entry_time_, qty, "Short", 0}); + pyramid_entries_.back().entry_incarnation = 1; + snapshot_entry_commission(pyramid_entries_.back()); + id_unclosed_qty_["Short"] = qty; + commissioned_all_in_market_short_lifecycle_ = carry_flat_lifecycle; + } + + void on_bar(const Bar&) override {} + + void trigger() { + current_bar_ = mk_bar( + 2000, 3734.88, 3735.52, 3734.00, 3735.00, 1.0); + bar_index_ = 1; + process_margin_call(current_bar_); + } +}; + +static void test_default_flat_short_floor_zero_caps_to_residual() { + std::printf("test_default_flat_short_floor_zero_caps_to_residual\n"); + DefaultFlatShortFloorZeroProbe baseline( + /*carry_flat_lifecycle=*/false); + baseline.trigger(); + DefaultFlatShortFloorZeroProbe enabled( + /*carry_flat_lifecycle=*/true); + enabled.trigger(); + + CHECK(baseline.trade_count() == 1); + CHECK(near(baseline.exit_price(0), 3735.52)); + CHECK(near(baseline.trade_size(0), 0.0001, 1e-9)); + CHECK(near(baseline.position_size(), -0.3382, 1e-9)); + + CHECK(enabled.trade_count() == 1); + CHECK(enabled.exit_comment(0) == std::string("Margin call")); + CHECK(near(enabled.entry_price(0), 3734.88)); + CHECK(near(enabled.exit_price(0), 3735.52)); + CHECK(near(enabled.trade_size(0), 0.3383, 1e-9)); + CHECK(near(enabled.position_size(), 0.0, 1e-9)); +} + +// A commissioned omitted-qty 100%-equity SHORT acquires lifecycle provenance +// from its real entry shape. A user-requested partial exit changes that shape +// and must invalidate the one-contract floor-zero provenance. Broker margin +// reductions are covered separately above and intentionally preserve it. +class CommissionedDefaultShortPartialLifecycleProbe : public MCEngine { +public: + CommissionedDefaultShortPartialLifecycleProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.05; + margin_short_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Short", false, kNaN, kNaN, kNaN); + } else if (bar_index_ == 1) { + lifecycle_after_open = + commissioned_all_in_market_short_lifecycle_; + strategy_close( + "Short", "partial lifecycle close", /*qty=*/1.0, + /*qty_percent=*/kNaN, /*immediately=*/true); + qty_after_partial = position_qty_; + lifecycle_after_partial = + position_side_ == PositionSide::SHORT + && position_qty_ > 1.0 + && commissioned_all_in_market_short_lifecycle_; + } + } + + void trigger_later_floor_zero() { + constexpr double adverse = 105.0; + constexpr double raw_q_min = 0.00005; + const double open_fee = surviving_open_percent_commission_account(); + net_profit_sum_ = + (position_qty_ - raw_q_min) * adverse - initial_capital_ + + open_fee + + (adverse - position_entry_price_) * position_qty_; + current_bar_ = mk_bar( + 3000, 100.0, adverse, 99.0, 100.0, 1.0); + bar_index_ = 2; + process_margin_call(current_bar_); + lifecycle_after_margin_partial = + commissioned_all_in_market_short_lifecycle_; + } + + bool lifecycle_after_open = false; + bool lifecycle_after_partial = false; + bool lifecycle_after_margin_partial = false; + double qty_after_partial = 0.0; +}; + +static void test_user_partial_invalidates_commissioned_short_lifecycle() { + std::printf( + "test_user_partial_invalidates_commissioned_short_lifecycle\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(2000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + CommissionedDefaultShortPartialLifecycleProbe probe; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.lifecycle_after_open); + CHECK(!probe.lifecycle_after_partial); + CHECK(probe.trade_count() == 1); + CHECK(near(probe.trade_size(0), 1.0, 1e-9)); + const double qty_before_margin = probe.qty_after_partial; + + probe.trigger_later_floor_zero(); + CHECK(probe.trade_count() == 2); + CHECK(probe.exit_comment(1) == std::string("Margin call")); + CHECK(near(probe.exit_price(1), 105.0)); + CHECK(near(probe.trade_size(1), 0.0001, 1e-9)); + CHECK(near(probe.position_size(), -(qty_before_margin - 0.0001), 1e-9)); + CHECK(!probe.lifecycle_after_margin_partial); +} + +// The scoped lifecycle belongs to one unmodified position cycle. A genuine +// accepted add invalidates it, while a full close clears it through the shared +// flat-position reset path. +class CommissionedDefaultShortLifecycleMutationProbe : public MCEngine { +public: + enum class Mutation { AcceptedAdd, FullClose }; + + explicit CommissionedDefaultShortLifecycleMutationProbe(Mutation mutation) + : mutation_(mutation) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.05; + margin_short_ = 100.0; + process_orders_on_close_ = false; + pyramiding_ = 2; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Short", false, kNaN, kNaN, kNaN); + } else if (bar_index_ == 1) { + lifecycle_after_open = + commissioned_all_in_market_short_lifecycle_; + if (mutation_ == Mutation::AcceptedAdd) { + strategy_entry("Add", false, kNaN, kNaN, /*qty=*/1.0); + } else { + strategy_close( + "Short", "full lifecycle close", /*qty=*/kNaN, + /*qty_percent=*/kNaN, /*immediately=*/true); + full_close_cleared = position_side_ == PositionSide::FLAT + && !commissioned_all_in_market_short_lifecycle_; + } + } else if (bar_index_ == 2 + && mutation_ == Mutation::AcceptedAdd) { + add_filled = position_side_ == PositionSide::SHORT + && position_entry_count_ == 2; + accepted_add_cleared = add_filled + && !commissioned_all_in_market_short_lifecycle_; + } + } + + bool lifecycle_after_open = false; + bool add_filled = false; + bool accepted_add_cleared = false; + bool full_close_cleared = false; + +private: + Mutation mutation_; +}; + +static void test_commissioned_default_short_lifecycle_mutations() { + std::printf("test_commissioned_default_short_lifecycle_mutations\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(2000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(3000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + CommissionedDefaultShortLifecycleMutationProbe add( + CommissionedDefaultShortLifecycleMutationProbe::Mutation::AcceptedAdd); + add.run(bars.data(), static_cast(bars.size())); + CommissionedDefaultShortLifecycleMutationProbe close( + CommissionedDefaultShortLifecycleMutationProbe::Mutation::FullClose); + close.run(bars.data(), static_cast(bars.size())); + + CHECK(add.lifecycle_after_open); + CHECK(add.add_filled); + CHECK(add.accepted_add_cleared); + CHECK(close.lifecycle_after_open); + CHECK(close.full_close_cleared); +} + // A scoped explicit MARKET short event is only provenance for that exact // fill. If a later successful same-direction short fill in the same dispatch // cycle has a non-scoped shape, the earlier event must not survive to the @@ -2022,6 +2673,15 @@ int main() { test_short_add_opening_margin_marks_latest_raw_fill(); test_thula_next_open_short_pair_exact_margin_rows(); test_short_opening_event_scope_is_explicit_market_margin100_only(); + test_commissioned_close_then_short_exact_checkpoints(); + test_commissioned_close_then_long_floor_zero_scope(); + test_default_short_lifecycle_floor_zero_one_contract(); + test_default_short_opening_floor_zero_one_contract(); + test_default_short_affordable_opening_retries_adverse_once(); + test_commissioned_default_flat_short_lifecycle_tag(); + test_default_flat_short_floor_zero_caps_to_residual(); + test_user_partial_invalidates_commissioned_short_lifecycle(); + test_commissioned_default_short_lifecycle_mutations(); test_priced_short_add_invalidates_scoped_opening_event(); test_raw_short_add_invalidates_scoped_opening_event(); test_accepted_add_fifo_keeps_add_affordability_event(); diff --git a/tests/test_percent_equity_open_entry_fee.cpp b/tests/test_percent_equity_open_entry_fee.cpp new file mode 100644 index 0000000..a6558cf --- /dev/null +++ b/tests/test_percent_equity_open_entry_fee.cpp @@ -0,0 +1,366 @@ +/* + * Paid percent entry fees debit the live broker ledger used by subsequent + * percent-of-equity sizing and finite adverse-margin checks. + */ + +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +namespace { + +int passed = 0; +int failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++failed; \ + } else { \ + ++passed; \ + } \ + } while (0) + +bool near(double lhs, double rhs, double tolerance = 1e-9) { + return std::abs(lhs - rhs) <= tolerance; +} + +Bar bar(double price) { + Bar result; + result.open = price; + result.high = price; + result.low = price; + result.close = price; + result.volume = 1.0; + result.timestamp = 2000; + return result; +} + +class FeeEquityProbe : public BacktestEngine { +public: + FeeEquityProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 1.0; + margin_short_ = 100.0; + syminfo_.pointvalue = 1.0; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override {} + + void set_commission_type(CommissionType type) { commission_type_ = type; } + void set_default_percent(double value) { default_qty_value_ = value; } + void set_default_type(QtyType type) { default_qty_type_ = type; } + + double flat_qty(double price) { + position_side_ = PositionSide::FLAT; + position_qty_ = 0.0; + pyramid_entries_.clear(); + current_bar_ = bar(price); + return calc_qty(price); + } + + double seeded_qty(double price) { + seed_short(); + current_bar_ = bar(price); + return calc_qty(price); + } + + double seeded_typed_percent_qty(double price, double percent) { + seed_short(); + current_bar_ = bar(price); + return calc_qty_for_type( + price, percent, + static_cast(QtyType::PERCENT_OF_EQUITY)); + } + + void process_seeded_adverse(double price) { + seed_short(); + current_bar_ = bar(price); + bar_index_ = 1; + process_margin_call(current_bar_); + } + + double paid_open_fee() const { return surviving_open_percent_commission_account(); } + + double live_position_qty() const { return position_qty_; } + int closed_count() const { return static_cast(trades_.size()); } + double first_closed_qty() const { + return trades_.empty() ? std::numeric_limits::quiet_NaN() + : trades_.front().qty; + } + std::string first_exit_comment() const { + return trades_.empty() ? std::string() : trades_.front().exit_comment; + } + +private: + void seed_short() { + trades_.clear(); + net_profit_sum_ = 0.0; + position_side_ = PositionSide::SHORT; + position_qty_ = 10.0; + position_entry_price_ = 100.0; + position_entry_time_ = 1000; + position_open_bar_ = 0; + position_entry_count_ = 1; + position_cycle_seq_ = 1; + pyramid_entries_.clear(); + id_unclosed_qty_.clear(); + pyramid_entries_.push_back({100.0, 1000, 10.0, "S", 0}); + pyramid_entries_.back().entry_commission_account = 10.0; + pyramid_entries_.back().entry_incarnation = 1; + id_unclosed_qty_["S"] = 10.0; + } +}; + +class PartialFeeSnapshotProbe : public BacktestEngine { +public: + PartialFeeSnapshotProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 1.0; + margin_short_ = 100.0; + process_orders_on_close_ = true; + syminfo_.pointvalue = 1.0; + syminfo_mintick_ = 0.01; + set_margin_call_enabled(false); + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("S", false, kNaN, kNaN, /*qty=*/10.0); + } else if (bar_index_ == 1) { + strategy_close( + "S", "", kNaN, /*qty_percent=*/40.0, false); + } else if (bar_index_ == 2) { + surviving_paid_fee = surviving_open_percent_commission_account(); + next_default_qty = calc_qty(current_bar_.close); + remaining_qty = position_qty_; + } + } + + double surviving_paid_fee = kNaN; + double next_default_qty = kNaN; + double remaining_qty = kNaN; + +private: + static constexpr double kNaN = + std::numeric_limits::quiet_NaN(); +}; + +// A percent-typed reversal sizes only after the old position has been fully +// realized. Its closed entry/exit commissions are already in net profit; the +// old lot's open-PnL and entry-fee snapshot must not be counted a second time. +class PercentTypedReversalProbe : public BacktestEngine { +public: + PercentTypedReversalProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 10.0; + process_orders_on_close_ = true; + qty_step_ = 0.0001; + syminfo_.pointvalue = 1.0; + syminfo_mintick_ = 0.01; + set_margin_call_enabled(false); + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, /*qty=*/1.0); + } else if (bar_index_ == 1) { + strategy_entry( + "S", false, kNaN, kNaN, /*qty=*/50.0, + /*comment=*/"", /*oca_name=*/"", /*oca_type=*/0, + static_cast(QtyType::PERCENT_OF_EQUITY)); + } + } + + double signed_position() const { + if (position_side_ == PositionSide::LONG) return position_qty_; + if (position_side_ == PositionSide::SHORT) return -position_qty_; + return 0.0; + } + +private: + static constexpr double kNaN = + std::numeric_limits::quiet_NaN(); +}; + +// Exact quantities exported by the clean-room Pine v6 KI-56 holding and +// adverse-margin discriminators on ETHUSDT.P, 15m, 2025-04-02. +class Ki56TvOracleProbe : public BacktestEngine { +public: + Ki56TvOracleProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 50.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 10.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + qty_step_ = 0.0001; + syminfo_.pointvalue = 1.0; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override {} + + double holding_probe_qty() { + seed_position(PositionSide::LONG, 1900.21, 1.0); + current_bar_ = bar(1896.99); + return calc_qty(1896.99); + } + + void run_adverse_margin_probe() { + seed_position(PositionSide::SHORT, 1900.21, 4.7745); + current_bar_ = bar(1900.21); + current_bar_.high = 1904.46; + bar_index_ = 1; + process_margin_call(current_bar_); + } + + int closed_count() const { return static_cast(trades_.size()); } + double first_closed_qty() const { + return trades_.empty() ? kNaN : trades_.front().qty; + } + double remaining_qty() const { return position_qty_; } + std::string first_exit_comment() const { + return trades_.empty() ? std::string() : trades_.front().exit_comment; + } + +private: + static constexpr double kNaN = + std::numeric_limits::quiet_NaN(); + + void seed_position(PositionSide side, double price, double qty) { + trades_.clear(); + net_profit_sum_ = 0.0; + position_side_ = side; + position_qty_ = qty; + position_entry_price_ = price; + position_entry_time_ = 1000; + position_open_bar_ = 0; + position_entry_count_ = 1; + position_cycle_seq_ = 1; + pyramid_entries_.clear(); + id_unclosed_qty_.clear(); + pyramid_entries_.push_back({price, 1000, qty, "seed", 0}); + pyramid_entries_.back().entry_incarnation = 1; + snapshot_entry_commission(pyramid_entries_.back()); + id_unclosed_qty_["seed"] = qty; + } +}; + +void test_sizing_debits_surviving_snapshot() { + FeeEquityProbe probe; + probe.set_default_percent(50.0); + CHECK(near(probe.seeded_qty(100.0), 990.0 * 0.50 / 1.01 / 100.0)); + + // Explicit qty_type=PERCENT uses the same fee-net equity even when the + // configured default is FIXED. This prevents the two sizing APIs from + // drifting apart. + FeeEquityProbe typed; + typed.set_default_type(QtyType::FIXED); + CHECK(near( + typed.seeded_typed_percent_qty(100.0, 50.0), + 990.0 * 0.50 / 1.01 / 100.0)); +} + +void test_flat_sizing_has_no_open_fee_debit() { + FeeEquityProbe probe; + CHECK(near(probe.flat_qty(100.0), 1000.0 / 1.01 / 100.0)); +} + +void test_adverse_margin_ledger_debits_entry_fee() { + FeeEquityProbe probe; + probe.process_seeded_adverse(100.0); + CHECK(probe.closed_count() == 1); + CHECK(probe.first_exit_comment() == "Margin call"); + CHECK(near(probe.first_closed_qty(), 0.4)); + CHECK(near(probe.live_position_qty(), 9.6)); +} + +void test_non_percent_scope_is_unchanged() { + FeeEquityProbe cash; + cash.set_commission_type(CommissionType::CASH_PER_ORDER); + cash.process_seeded_adverse(100.0); + CHECK(cash.closed_count() == 0); + CHECK(near(cash.live_position_qty(), 10.0)); + +} + +void test_margin_ledger_is_independent_of_default_percent() { + FeeEquityProbe ninety_nine; + ninety_nine.set_default_percent(99.0); + ninety_nine.process_seeded_adverse(100.0); + CHECK(ninety_nine.closed_count() == 1); + CHECK(ninety_nine.first_exit_comment() == "Margin call"); + CHECK(near(ninety_nine.first_closed_qty(), 0.4)); + CHECK(near(ninety_nine.live_position_qty(), 9.6)); +} + +void test_fifo_partial_scales_surviving_paid_fee_snapshot() { + PartialFeeSnapshotProbe probe; + const Bar bars[] = {bar(100.0), bar(100.0), bar(100.0)}; + probe.run(bars, 3); + + CHECK(near(probe.remaining_qty, 6.0)); + CHECK(near(probe.surviving_paid_fee, 6.0)); + // The closed four-contract slice realizes its $4 entry fee and $4 exit + // fee in net profit; only the surviving $6 entry-fee snapshot is debited + // from the next live-equity budget. + CHECK(near(probe.next_default_qty, 986.0 / 1.01 / 100.0)); +} + +void test_percent_typed_reversal_does_not_double_debit_old_fee() { + PercentTypedReversalProbe probe; + const Bar bars[] = {bar(100.0), bar(100.0)}; + probe.run(bars, 2); + + CHECK(probe.trade_count() == 1); + // Closing 1 @ 100 realizes its $10 entry and $10 exit commission, leaving + // $9980 equity. The new 50%-of-equity short reserves its own 10% fee: + // floor((9980 * .5 / 1.1 / 100) / .0001) * .0001 = 45.3636. + CHECK(near(probe.signed_position(), -45.3636, 1e-9)); +} + +void test_ki56_clean_room_tv_quantities() { + Ki56TvOracleProbe holding; + CHECK(near(holding.holding_probe_qty(), 2.3498, 1e-9)); + + Ki56TvOracleProbe margin; + margin.run_adverse_margin_probe(); + CHECK(margin.closed_count() == 1); + CHECK(margin.first_exit_comment() == "Margin call"); + CHECK(near(margin.first_closed_qty(), 0.0428, 1e-9)); + CHECK(near(margin.remaining_qty(), 4.7317, 1e-9)); +} + +} // namespace + +int main() { + test_sizing_debits_surviving_snapshot(); + test_flat_sizing_has_no_open_fee_debit(); + test_adverse_margin_ledger_debits_entry_fee(); + test_non_percent_scope_is_unchanged(); + test_margin_ledger_is_independent_of_default_percent(); + test_fifo_partial_scales_surviving_paid_fee_snapshot(); + test_percent_typed_reversal_does_not_double_debit_old_fee(); + test_ki56_clean_room_tv_quantities(); + std::printf("%d passed, %d failed\n", passed, failed); + return failed == 0 ? 0 : 1; +}