diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 07e88d6..b452f0e 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -177,6 +177,12 @@ struct PendingOrder { int created_bar; // bar_index when order was created int64_t created_seq = 0; PositionSide created_position_side = PositionSide::FLAT; + // True when a successful strategy.close/close_all call earlier in this + // same on_bar already targeted live quantity. This remains distinct from + // created_position_side: an immediate full close makes the engine truly + // FLAT before a paired reentry is placed, but that reentry is not an + // independent true-flat opening for KI-61 affordability purposes. + bool created_after_position_close_in_bar = false; // Snapshot of the position's quantity at the moment this order was // PLACED (0 if placed from flat). Used by execute_market_entry's // flat branch to apply TradingView's deferred-flip growth rule: @@ -243,6 +249,13 @@ struct PendingOrder { // mark-to-market total against a cost-basis deduction and the admission // threshold drifts with unrealized PnL in the wrong direction. double sizing_mark = std::numeric_limits::quiet_NaN(); + // Placement-time half of KI-61's sole opening-affordability exemption. + // True only for a high-level long MARKET call with omitted qty, a frozen + // 100%-of-equity snapshot, 100% long margin, true-flat placement, and no + // earlier paired close in this on_bar. Fill-time code must additionally + // prove true-flat fill, sizing-price admission, success, and zero actual + // opening commission before treating the queued event as exempt. + bool opening_affordability_exemption_candidate = false; std::string comment; // order comment for trade reporting bool requested_partial = false; // true iff caller passed qty_percent < 100 bool created_while_in_position = false; // true if position was open when order was placed @@ -303,6 +316,19 @@ class BacktestEngine { // --- Position state --- PositionSide position_side_ = PositionSide::FLAT; double position_entry_price_ = 0.0; // volume-weighted average (for strategy calculations) + // One-shot post-fill affordability event for a 100%-margin long. Every + // successful fresh opening or accepted positive-qty same-direction add + // queues exactly one event carrying the raw matched-price base. The event + // is eligible unless the fill proves the narrow frozen-all-in true-flat + // MARKET exemption; rejected/no-op attempts leave an existing event + // untouched. process_margin_call consumes and clears the event on the + // current script bar. Do not reconstruct it from trade rows or + // position_entry_count_: a paired close/reentry can create zero-PnL rows, + // and FIFO can reduce a real pyramid back to one live lot. + bool opening_affordability_pending_ = false; + bool opening_affordability_eligible_ = false; + double opening_affordability_raw_fill_base_ = + std::numeric_limits::quiet_NaN(); int64_t position_entry_time_ = 0; // Position is FLAT until the first entry fires; the canonical // accessor ``signed_position_size`` already reads as 0 when FLAT @@ -731,10 +757,10 @@ class BacktestEngine { void process_pending_orders(const Bar& bar); - // TradingView forced-liquidation (margin call). Run once per script bar - // after order processing: if the bar's adverse extreme (high for shorts, - // low for longs) breaches the open position's required margin, force-close - // 4x the minimum qty needed to restore margin at the liquidation price. + // TradingView forced-liquidation (margin call). Finite-price liquidation + // paths use the bar's adverse extreme. A 100%-margin long has no later + // adverse-price liquidation; only an eligible one-shot post-fill + // affordability event can trim it. void process_margin_call(const Bar& bar); // --- Slippage helper --- @@ -1071,10 +1097,8 @@ class BacktestEngine { // broker emulator force-liquidates the current open position. Returns na // when flat, when the instrument has no valid size/point-value, or when // ``margin/100 - direction == 0`` (a 1x long has no leverage-derived - // liquidation PRICE — but it CAN still be force-liquidated when its - // notional exceeds equity: process_margin_call's long_full_margin - // branch deliberately bypasses the na bail for exactly that case, and - // TV emits such long margin calls too). See compute_liquidation_price + // liquidation price; process_margin_call separately handles an eligible + // one-shot post-fill affordability trim). See compute_liquidation_price // for the derivation. double margin_liquidation_price() const { return compute_liquidation_price(); } @@ -1097,7 +1121,9 @@ class BacktestEngine { const double margin_pct = (position_side_ == PositionSide::LONG) ? margin_long_ : margin_short_; const double denom = (margin_pct / 100.0) - direction; - // A long at 100% margin (denom == 0) cannot be liquidated. + // A long at 100% margin (denom == 0) has no liquidation PRICE. + // Its separate post-fill affordability trim is handled by + // process_margin_call without fabricating a later adverse threshold. if (std::abs(denom) < 1e-12) return na(); // equity_basis is account-currency (initial_capital_ is account- // currency-native; net_profit_sum_ is account-currency post-FX — diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 42a4d65..fd7c389 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -97,17 +97,16 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { // TradingView force-liquidation (margin call). // // Run once per script bar (end of dispatch_bar / magnifier bar) after all -// order processing. While a position is open, TV's broker emulator checks -// whether strategy equity still covers the position's required margin using -// the bar's ADVERSE extreme (bar HIGH for shorts, bar LOW for longs). When the -// adverse extreme reaches/passes the liquidation price the emulator force- -// closes part of the position: +// order processing. Finite liquidation-price positions use the bar's ADVERSE +// extreme (bar HIGH for shorts, bar LOW for leveraged longs). A long at +// margin_long=100 has no adverse-price liquidation; it can only receive the +// one-shot affordability event queued by a successful opening/add fill: // -// - fill price = the bar's ADVERSE extreme (high for shorts, low for longs), -// which is what TV's exported margin-call rows report — not the bare -// liquidation level (the liq price only gates whether a call happens). -// - quantity = 4x the minimum amount needed to restore margin at the adverse -// extreme, capped at the full position. The documented 4x over-liquidation +// - fill base = the adverse extreme for finite-price calls, or the raw +// matched entry/add fill for the 1x-long affordability trim. The closing +// helper independently applies exit-side snap/slippage. +// - quantity = 4x the minimum amount needed to restore margin at the check +// price, capped at the full position. The documented 4x over-liquidation // prevents a margin call recurring on every subsequent bar and produces // TV's iterative "nibble" pattern (a deep-underwater position closes in // several 4x chunks across bars). @@ -116,40 +115,57 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { // Validated against the p2 margin-call short probe (TV: 68 margin calls, first // at ~1798.26) and the leverage-margin-call-perp-5x long probe. void BacktestEngine::process_margin_call(const Bar& bar) { + // Consume first, including on disabled/degenerate paths. This is an event + // attached to the just-completed fill cycle, never durable per-position + // 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 double opening_event_raw_fill_base = + opening_affordability_raw_fill_base_; + opening_affordability_pending_ = false; + opening_affordability_eligible_ = false; + opening_affordability_raw_fill_base_ = + std::numeric_limits::quiet_NaN(); + if (!margin_call_enabled_) return; if (position_side_ == PositionSide::FLAT) return; - // No post-entry adverse path exists on a bar where the position opened at - // the bar CLOSE: with process_orders_on_close, market entries fill at the - // close, so there is no remaining intrabar movement for price to breach - // margin on the entry bar itself (TV emits the first margin call on the - // NEXT bar — p2 probe: entry 15:30, first call 15:45). When entries fill at - // the bar OPEN (process_orders_on_close=false) the full bar path IS - // post-entry, so a same-bar liquidation is allowed (leverage probe: entry - // and first margin call land on the same bar). - if (process_orders_on_close_ && position_open_bar_ == bar_index_) return; - - const double liq = compute_liquidation_price(); - // A LONG at exactly 100% margin yields denom == (m/100 - dir) == 0, so - // compute_liquidation_price() returns na (there is no leverage-derived - // liquidation price — a 1x long can only be wiped at price 0). But such a - // position CAN still be force-liquidated when its NOTIONAL exceeds equity: - // TradingView over-allocates percent_of_equity entries whose fill price - // exceeds the sizing price (stop/gap fills, slippage, reversals, or the - // entry commission), leaving the fresh position momentarily under-margined - // and triggering a same-bar "Margin call" trim on longs. The equity / - // required-margin gate below is the only check that can decide this case - // (it reduces to notional > equity for a 1x long), so do NOT hard-bail on - // na here for that one situation. Every OTHER na cause (flat, qty<=0, - // pv<=0, non-finite) is still rejected by the explicit guards that follow, - // and shorts never hit denom==0 (denom = m/100 + 1 > 0), so their behavior - // is byte-identical. margin_liquidation_price() itself keeps returning na - // for a 1x long, matching TradingView's strategy.margin_liquidation_price. + const bool opened_this_bar = position_open_bar_ == bar_index_; + // A LONG at exactly 100% margin has no leverage-derived liquidation price: + // compute_liquidation_price() returns na because m/100 - direction == 0. + // Its only broker action is the non-price affordability event attached to + // the successful fill. Explicit pending provenance is essential: an add + // can occur on a carried position, and FIFO can later make the mutable + // position_entry_count_ equal one again. const bool long_full_margin = (position_side_ == PositionSide::LONG) && std::isfinite(margin_long_) && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; - if (std::isnan(liq) && !long_full_margin) return; // flat / denom==0 / invalid size already filtered + const bool long_opening_affordability = + long_full_margin + && opening_event_pending + && opening_event_eligible + && std::isfinite(opening_event_raw_fill_base) + && opening_event_raw_fill_base > 0.0; + + // 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 + // event is consumed above and deliberately performs no affordability trim. + if (long_full_margin && !long_opening_affordability) return; + + // A leveraged position filled at the bar CLOSE has no post-fill adverse + // path on that bar, so its first price liquidation remains next-bar-only. + // The 1x-long opening check is affordability at the fill, not an adverse- + // path test, and therefore still runs for a POOC close fill. + if (process_orders_on_close_ && opened_this_bar + && !long_opening_affordability) { + return; + } + + const double liq = compute_liquidation_price(); + if (std::isnan(liq) && !long_opening_affordability) { + return; // includes every carried/ineligible 1x long + } const double pv = syminfo_.pointvalue; const double qty = position_qty_; @@ -166,23 +182,61 @@ void BacktestEngine::process_margin_call(const Bar& bar) { return; } - // Adverse extreme: shorts lose as price rises (bar high); longs lose as - // price falls (bar low). - const double adverse = (position_side_ == PositionSide::LONG) ? bar.low : bar.high; - if (!std::isfinite(adverse) || !(adverse > 0.0)) return; - - // Equity at the adverse extreme = capital + realized PnL + open P/L on the - // FULL position. Required margin = position value at the adverse price. - const double equity_adv = initial_capital_ + net_profit_sum_ - + direction * (adverse - position_entry_price_) * qty * pv; - const double req_margin_adv = qty * adverse * pv * m; - if (equity_adv >= req_margin_adv) return; // margin still covered — no call - - // Minimum qty whose removal restores margin at the adverse extreme. Closing - // q units leaves the bar-equity unchanged (realized + open P/L just - // reclassify) while the required margin shrinks: need - // (qty - q) * adverse * pv * m <= equity_adv => q >= qty - equity_adv/(adverse*pv*m). - double q_min = qty - equity_adv / (adverse * pv * m); + double q_min = 0.0; + double raw_exit_fill_base = 0.0; + if (long_opening_affordability) { + // Post-fill affordability is evaluated from the current position's + // actual, directionally snapped/slipped entry basis. Capital and + // realized PnL are account-currency-native; price notional is quote + // currency, so pointvalue and FX must both be present. The entry fee is + // an immediate cost in TV, + // while this engine normally realizes both commission legs only when a + // trade closes, so debit the full opening fee for this one check: + // + // qty * entry * pv * fx * margin + entry_fee > closed_equity + // + // q_min then removes only enough required margin to restore that + // opening budget. The raw matched base is retained separately for the + // broker-generated closing fill below. + const double fx = account_currency_fx_; + if (!std::isfinite(fx) || !(fx > 0.0)) return; + const double margin_per_unit = position_entry_price_ * pv * fx * m; + double entry_commission = 0.0; + for (const auto& pe : pyramid_entries_) { + // A requested add can floor to zero yet leave a bookkeeping row. + // It was not an accepted fill and must not incur CASH_PER_ORDER's + // fixed fee in this post-fill affordability sum. + if (pe.qty <= kQtyEpsilon) continue; + const double lot_commission = calc_commission(pe.price, pe.qty); + if (!std::isfinite(lot_commission)) return; + entry_commission += lot_commission; + } + const double opening_equity = + initial_capital_ + net_profit_sum_ - entry_commission; + if (!std::isfinite(margin_per_unit) || !(margin_per_unit > 0.0) + || !std::isfinite(entry_commission) + || !std::isfinite(opening_equity)) { + return; + } + const double required_margin = qty * margin_per_unit; + if (opening_equity >= required_margin) return; + q_min = qty - opening_equity / margin_per_unit; + raw_exit_fill_base = opening_event_raw_fill_base; + } else { + // Preserve the established finite-price cascade math and operation + // ordering byte-for-byte: shorts and leveraged longs still check the + // adverse extreme and fill there. + const double adverse = + (position_side_ == PositionSide::LONG) ? bar.low : bar.high; + if (!std::isfinite(adverse) || !(adverse > 0.0)) return; + const double equity_adv = initial_capital_ + net_profit_sum_ + + direction * (adverse - position_entry_price_) * qty * pv; + const double req_margin_adv = qty * adverse * pv * m; + if (equity_adv >= req_margin_adv) return; + q_min = qty - equity_adv / (adverse * pv * m); + raw_exit_fill_base = adverse; + } + if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return; // Per-instrument lot quantization. TradingView floors the minimum-restore // qty to the instrument's quantity step BEFORE applying the 4x over- @@ -197,6 +251,11 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (qty_step_ > 0.0) { q_min = std::floor(q_min / qty_step_) * qty_step_; } + // A sub-lot 1x-long opening shortfall is untradeable dust. It is a no-op, + // not a reason to force the generic finite-price cascade's one-step + // progress fallback. qty_step==0 intentionally retains continuous-qty + // behavior because no exchange lot floor was configured. + if (long_opening_affordability && q_min <= kQtyEpsilon) return; double qty_liq = 4.0 * q_min; if (qty_step_ > 0.0) { // q_min is already a multiple of qty_step_, so 4*q_min is mathematically @@ -207,6 +266,7 @@ 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 (long_opening_affordability) return; // A liquidation IS required (we passed the margin-shortfall gate) // but the floored lot rounds to zero (sub-lot shortfall). Take the // smallest step that still makes progress — one qty_step_, or the @@ -219,22 +279,18 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty; // cap at the whole position if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return; - // Fill at the bar's adverse extreme. TradingView's exported margin-call - // rows fill at the bar HIGH (shorts) / LOW (longs) — the worst price the - // bar reached after the liquidation level was crossed — NOT at the bare - // liquidation price. Verified row-for-row against the p2 probe TV export - // (first short call fills at 1798.26 = that bar's high, with - // liq=1793.12). ``liq`` above is used only to gate na/denominator cases. - // ``adverse`` already lies within [low, high]. current_fill_is_limit_ - // stays false so execute_market_exit / execute_partial_exit_qty apply - // market (slipped) economics. - const double fill = adverse; + // Finite-price calls pass the raw adverse extreme to the close helper. A + // 1x-long opening trim instead passes the captured raw matched entry base. + // current_fill_is_limit_ is false here, so both routes independently apply + // the closing side's market snap/slippage. This is load-bearing for both a + // buy-slipped stop/market entry and an unslipped limit entry; attempting to + // invert position_entry_price_ would lose directional snap information. const size_t trades_before = trades_.size(); if (qty_liq >= qty - kQtyEpsilon) { - execute_market_exit(fill); + execute_market_exit(raw_exit_fill_base); } else { - execute_partial_exit_qty(fill, qty_liq); + execute_partial_exit_qty(raw_exit_fill_base, qty_liq); } // Tag every trade row this liquidation produced with TV's "Margin call". for (size_t ti = trades_before; ti < trades_.size(); ++ti) { @@ -475,6 +531,12 @@ void BacktestEngine::apply_filled_order_to_state( int& exit_closed_from_bar, bool& exit_closed_was_long, std::vector& filled_indices) { + // Fill-local proof that KI-54 admitted this order as a flat open on its + // frozen sizing price. Merely carrying a snapshot is insufficient: true + // reversals are admitted on their actual fill, and paired reentries may + // fill from flat despite having been placed from a live position. + bool admitted_flat_on_frozen_sizing_price = false; + if (order.type == OrderType::MARKET || order.type == OrderType::ENTRY) { PositionSide requested = order.is_long ? PositionSide::LONG : PositionSide::SHORT; bool is_opposite_entry = @@ -638,6 +700,11 @@ void BacktestEngine::apply_filled_order_to_state( filled_indices.push_back(order_index); return; } + admitted_flat_on_frozen_sizing_price = + position_side_ == PositionSide::FLAT + && order.type == OrderType::MARKET + && !reversal && !same_dir + && admit_price == order.sizing_price; } } @@ -700,6 +767,9 @@ void BacktestEngine::apply_filled_order_to_state( if (position_side_ == PositionSide::SHORT) return -position_qty_; return 0.0; }; + const PositionSide position_side_before_fill = position_side_; + const double position_qty_before_fill = position_qty_; + const size_t pyramid_lots_before_fill = pyramid_entries_.size(); double signed_pos_before = signed_pos(); // Priced (stop/limit) fills happen mid-bar: any trade they close must @@ -771,6 +841,74 @@ void BacktestEngine::apply_filled_order_to_state( fold_exit_trail_peak_ = std::numeric_limits::quiet_NaN(); } // fill_kind_guard dtor clears current_fill_is_limit_ + // Queue the one-shot 1x-long post-fill affordability event at the single + // dispatch point shared by MARKET, priced ENTRY, and RAW_ORDER fills while + // the exact raw matched base is still available. A rejected or zero-effect + // attempt changes neither the live quantity nor the pyramid roster and + // therefore leaves a prior event untouched. + const bool entry_like_order = + order.type == OrderType::MARKET + || order.type == OrderType::ENTRY + || order.type == OrderType::RAW_ORDER; + if (entry_like_order) { + const PositionSide requested_side = + order.is_long ? PositionSide::LONG : PositionSide::SHORT; + const bool successful_fresh_open = + position_side_before_fill != requested_side + && position_side_ == requested_side + && position_qty_ > kQtyEpsilon + && !pyramid_entries_.empty() + && pyramid_entries_.back().qty > kQtyEpsilon; + const bool accepted_additional_entry = + position_side_before_fill == requested_side + && position_side_ == requested_side + && pyramid_entries_.size() > pyramid_lots_before_fill + && pyramid_entries_.back().qty > kQtyEpsilon + && position_qty_ > position_qty_before_fill + kQtyEpsilon; + const bool long_full_margin_after_fill = + position_side_ == PositionSide::LONG + && std::isfinite(margin_long_) + && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; + const bool positive_raw_base = + std::isfinite(fill_price) && fill_price > 0.0; + if (long_full_margin_after_fill && positive_raw_base + && (successful_fresh_open || accepted_additional_entry)) { + // The only exemption requires every item of provenance to agree: + // omitted qty; a frozen 100%-equity high-level MARKET snapshot; + // true-flat placement and true-flat fill; successful admission on + // 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() + : calc_commission(pyramid_entries_.back().price, + pyramid_entries_.back().qty); + const bool frozen_all_in_true_flat_exemption = + successful_fresh_open + && order.opening_affordability_exemption_candidate + && order.type == OrderType::MARKET + && order.is_long + && std::isnan(order.qty) + && std::isfinite(order.frozen_default_qty) + && std::isfinite(order.sizing_equity) + && std::isfinite(order.sizing_price) + && std::isfinite(order.sizing_mark) + && 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 + && std::isfinite(new_opening_commission) + && new_opening_commission == 0.0; + + opening_affordability_pending_ = true; + opening_affordability_eligible_ = + accepted_additional_entry + || !frozen_all_in_true_flat_exemption; + opening_affordability_raw_fill_base_ = fill_price; + } + } + double signed_pos_after = signed_pos(); double filled_qty = std::abs(signed_pos_after - signed_pos_before); @@ -1140,6 +1278,13 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price : (std::isnan(order.qty) ? calc_qty(fill_price) : order.qty); position_side_ = order.is_long ? PositionSide::LONG : PositionSide::SHORT; position_entry_price_ = fill_price; + // The shared post-dispatch hook queues the new fill's event. Clear any + // prior-cycle provenance first; RAW_ORDER opens do not route through + // open_fresh_position. + opening_affordability_pending_ = false; + opening_affordability_eligible_ = false; + opening_affordability_raw_fill_base_ = + std::numeric_limits::quiet_NaN(); position_entry_time_ = current_bar_.timestamp; position_qty_ = qty; position_entry_count_ = 1; diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 7bf9826..1a77352 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -485,6 +485,10 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, void BacktestEngine::reset_position_state_to_flat() { position_side_ = PositionSide::FLAT; position_entry_price_ = 0.0; + opening_affordability_pending_ = false; + opening_affordability_eligible_ = false; + opening_affordability_raw_fill_base_ = + std::numeric_limits::quiet_NaN(); position_entry_time_ = 0; position_qty_ = 0.0; position_entry_count_ = 0; @@ -525,6 +529,13 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri double qty, const std::string& id) { position_side_ = requested; position_entry_price_ = fill_price; + // The shared post-dispatch lifecycle hook queues the new fill's event. + // Clear prior-cycle provenance now so reversals cannot expose it even + // transiently. + opening_affordability_pending_ = false; + opening_affordability_eligible_ = false; + opening_affordability_raw_fill_base_ = + std::numeric_limits::quiet_NaN(); position_entry_time_ = current_bar_.timestamp; position_qty_ = qty; position_entry_count_ = 1; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index fb36888..a6da78e 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -180,6 +180,8 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.created_bar = bar_index_; order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++; order.created_position_side = position_side_; + order.created_after_position_close_in_bar = + pending_close_qty_in_bar_ > kQtyEpsilon; // TradingView empirical rule (probe 52 trade 113): the deferred-flip // carry is the position size at THIS placement, not the original. // ``strategy.entry`` with the same id replaces the pending order @@ -245,6 +247,18 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.sizing_equity = current_equity() + open_profit(current_bar_.close); order.sizing_mark = current_bar_.close; + order.opening_affordability_exemption_candidate = + is_long + && order.created_position_side == PositionSide::FLAT + && !order.created_after_position_close_in_bar + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && std::isfinite(margin_long_) + && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12 + && std::isfinite(order.frozen_default_qty) + && std::isfinite(order.sizing_equity) + && std::isfinite(order.sizing_price) + && std::isfinite(order.sizing_mark); } } else { order.type = OrderType::ENTRY; @@ -699,6 +713,8 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double order.created_bar = bar_index_; order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++; order.created_position_side = position_side_; + order.created_after_position_close_in_bar = + pending_close_qty_in_bar_ > kQtyEpsilon; order.tv_carry_qty = position_qty_; bool has_limit = !std::isnan(limit_price); diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index 2a3d41f..782617b 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -1,7 +1,8 @@ /* * test_margin_call.cpp — verify TradingView forced-liquidation (margin call). * - * Covers the three behaviours of process_margin_call / margin_liquidation_price: + * Covers the principal behaviours of process_margin_call / + * margin_liquidation_price: * * A. A 100%-equity SHORT held through an adverse (rising) move is force- * liquidated. At least one "Margin call" exit is produced; the first one @@ -9,18 +10,18 @@ * of the margin shortfall (capped at the full position). The reported * margin_liquidation_price equals the closed-form formula while open. * - * B. A LONG at the default 100% margin can NEVER be liquidated (the formula - * denominator margin/100 - direction = 0). Even a catastrophic price - * crash produces NO margin call and margin_liquidation_price() == na. + * B. A LONG at the default 100% margin has no adverse-price liquidation + * (the formula denominator margin/100 - direction = 0). A sub-lot + * opening affordability overage is held even through a later crash. * - * C. A LEVERAGED long (margin_long = 20 => 5x) IS liquidated when price falls + * C. A one-lot-or-larger opening affordability shortfall is trimmed on the + * entry bar using entry affordability and exit-side fill semantics. + * + * D. A LEVERAGED long (margin_long = 20 => 5x) IS liquidated when price falls * far enough; the forced exit fills at the bar's adverse extreme (LOW). * - * D. The margin-call emulator can be switched off (set_margin_call_enabled + * E. The margin-call emulator can be switched off (set_margin_call_enabled * false); the underwater short is then held with no forced exit. - * - * Engine fill timing here uses process_orders_on_close = true so the market - * entry fills at the signal bar's close, mirroring the p2 probe. */ #include @@ -72,7 +73,16 @@ class MCEngine : public BacktestEngine { double exit_price(int i) const { return closed_trade_exit_price(i); } double entry_price(int i) const { return closed_trade_entry_price(i); } double trade_size(int i) const { return closed_trade_size(i); } + int entry_bar(int i) const { return closed_trade_entry_bar_index(i); } + int exit_bar(int i) const { return closed_trade_exit_bar_index(i); } + double position_size() const { return signed_position_size(); } double liq_price() const { return margin_liquidation_price(); } + bool opening_pending() const { return opening_affordability_pending_; } + bool opening_eligible() const { return opening_affordability_eligible_; } + double opening_raw_base() const { + return opening_affordability_raw_fill_base_; + } + int live_entry_count() const { return position_entry_count_; } }; // ---- A: 100%-equity short force-liquidated by a rising market -------------- @@ -206,6 +216,13 @@ static void test_short_margin_call_qty_step() { CHECK(near(eng.exit_price(0), 105.0)); CHECK(eng.exit_comment(0) == std::string("Margin call")); + // Negative sentinel for the 1x-long fix: the established short cascade + // stays exactly two forced rows, including the residual close at bar2 HIGH. + CHECK(eng.trade_count() == 2); + CHECK(near(eng.trade_size(1), 8.0)); + CHECK(near(eng.exit_price(1), 130.0)); + CHECK(eng.exit_comment(1) == std::string("Margin call")); + // Every partial (non-final) forced lot must be a step multiple. The final // exit closes whatever residual remains (the position size itself is not a // step multiple, so only the intermediate nibbles are checked). @@ -256,44 +273,787 @@ static void test_long_100pct_margin_no_call() { CHECK(std::isnan(eng.liq_price())); } -// ---- B': OVER-ALLOCATED long at 100% margin IS force-liquidated ------------- +// A default 100%-of-equity MARKET order placed and filled from true flat is +// admitted on its frozen signal-price notional. With no opening commission, +// a gap above that frozen price is deliberately exempt from the one-shot +// post-fill affordability trim. +class FrozenAllInFlatLongProbe : public MCEngine { +public: + explicit FrozenAllInFlatLongProbe(double commission_percent) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = commission_percent; + margin_long_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) strategy_entry("L", true, kNaN, kNaN, kNaN); + } +}; + +static void test_zero_cost_frozen_all_in_true_flat_gap_is_exempt() { + std::printf("test_zero_cost_frozen_all_in_true_flat_gap_is_exempt\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(2000, 120.0, 125.0, 80.0, 110.0, 1.0), + }; + FrozenAllInFlatLongProbe eng(/*commission_percent=*/0.0); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 0); + CHECK(near(eng.position_size(), 10.0)); + CHECK(std::isnan(eng.liq_price())); +} + +static void test_commissioned_frozen_all_in_true_flat_gap_is_eligible() { + std::printf("test_commissioned_frozen_all_in_true_flat_gap_is_eligible\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(2000, 120.0, 125.0, 80.0, 110.0, 1.0), + }; + FrozenAllInFlatLongProbe eng(/*commission_percent=*/10.0); + eng.run(bars.data(), (int)bars.size()); + + // Signal sizing reserves the 10% entry fee: floor(1000/1.1/100)=9. + // At the 120 fill, margin plus the 108 fee is unaffordable. Restoring + // needs 1.566... lots, floored to one then multiplied by four. + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(eng.entry_bar(0) == 1); + CHECK(eng.exit_bar(0) == 1); + CHECK(near(eng.entry_price(0), 120.0)); + CHECK(near(eng.exit_price(0), 120.0)); + CHECK(near(eng.trade_size(0), 4.0)); + CHECK(near(eng.position_size(), 5.0)); +} + +// The short closes at zero PnL immediately before the long is placed, so both +// placement and fill observe FLAT. Direct same-on_bar close provenance (not a +// trade-count/PnL heuristic) must still identify the paired reentry; otherwise +// its next-open gap would be mistaken for the true-flat exemption. +class PairedCloseDefaultLongProbe : public MCEngine { +public: + PairedCloseDefaultLongProbe() { + initial_capital_ = 1000.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; + process_orders_on_close_ = false; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("S", false, kNaN, kNaN, /*qty=*/1.0); + } else if (bar_index_ == 1) { + // Close immediately first, so the reentry is placed from an + // actually FLAT engine state. Its same-on_bar paired-close + // provenance must still exclude it from the true-flat exemption. + strategy_close("S", "paired close", kNaN, kNaN, + /*immediately=*/true); + strategy_entry("L", true, kNaN, kNaN, kNaN); + } + } +}; + +static void test_paired_short_close_default_long_gap_remains_eligible() { + std::printf("test_paired_short_close_default_long_gap_remains_eligible\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, 120.0, 125.0, 80.0, 110.0, 1.0), + }; + PairedCloseDefaultLongProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 2); + CHECK(eng.exit_comment(1) == std::string("Margin call")); + CHECK(eng.entry_bar(1) == 2); + CHECK(eng.exit_bar(1) == 2); + CHECK(near(eng.entry_price(1), 120.0)); + CHECK(near(eng.exit_price(1), 120.0)); + CHECK(near(eng.trade_size(1), 4.0)); + CHECK(near(eng.position_size(), 6.0)); +} + +// ---- B'/C: 1x-long opening affordability is lot-floored and entry-priced --- class LongOverAllocProbe : public MCEngine { public: - LongOverAllocProbe() { + explicit LongOverAllocProbe(double qty_step, + double commission_percent = 0.0, + bool process_on_close = false, + int slippage_ticks = 0, + double mintick = 0.01, + double account_fx = 1.0, + double pointvalue = 1.0, + double qty = 10.0) : qty_(qty) { initial_capital_ = 1000.0; default_qty_type_ = QtyType::FIXED; - default_qty_value_ = 10.0; - commission_value_ = 0.0; + default_qty_value_ = qty; + commission_type_ = CommissionType::PERCENT; + commission_value_ = commission_percent; margin_long_ = 100.0; // 1x -> denominator (1 - 1) = 0 -> na - process_orders_on_close_ = false; // market entry fills at NEXT bar open + process_orders_on_close_ = process_on_close; + qty_step_ = qty_step; + slippage_ = slippage_ticks; + syminfo_mintick_ = mintick; + account_currency_fx_ = account_fx; + syminfo_.pointvalue = pointvalue; } void on_bar(const Bar& /*bar*/) override { - if (bar_index_ == 0) strategy_entry("L", true, kNaN, kNaN, 10.0); + if (bar_index_ == 0) strategy_entry("L", true, kNaN, kNaN, qty_); } +private: + double qty_; }; -static void test_long_100pct_margin_overalloc_call() { - std::printf("test_long_100pct_margin_overalloc_call\n"); +static void test_long_100pct_margin_sublot_overage_is_held() { + std::printf("test_long_100pct_margin_sublot_overage_is_held\n"); std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), // 0: signal @ close 100 - mk_bar(2000, 110.0, 112.0, 108.0, 109.0, 1.0), // 1: fills @ open 110 - mk_bar(3000, 109.0, 111.0, 107.0, 110.0, 1.0), // 2: filler + mk_bar(2000, 110.0, 112.0, 50.0, 90.0, 1.0), // 1: restore=.909 lot + mk_bar(3000, 90.0, 91.0, 1.0, 2.0, 1.0), // 2: later crash }; - LongOverAllocProbe eng; + LongOverAllocProbe eng(/*qty_step=*/1.0); eng.run(bars.data(), (int)bars.size()); - // Signal-time affordability admits the fixed qty=10 against bar0's close - // (10*100 == equity 1000), but the non-POOC market entry actually fills - // at bar1's OPEN (110) -- notional 1100 > equity 1000, an over-allocated - // 1x long. TradingView force-liquidates this on the SAME bar it fired. - CHECK(eng.trade_count() >= 1); + + // Restoring affordability at the 110 fill needs only 0.909 lot. Flooring + // to qty_step=1 produces zero, so TV does not trim. Later lows cannot turn + // that dust overage into an adverse-price liquidation. + CHECK(eng.trade_count() == 0); + CHECK(near(eng.position_size(), 10.0)); + CHECK(std::isnan(eng.liq_price())); +} + +static void test_long_100pct_margin_lot_trim_uses_entry_affordability() { + std::printf("test_long_100pct_margin_lot_trim_uses_entry_affordability\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 120.0, 122.0, 100.0, 110.0, 1.0), + mk_bar(3000, 110.0, 111.0, 10.0, 20.0, 1.0), + }; + LongOverAllocProbe eng(/*qty_step=*/1.0, /*commission_percent=*/4.0); + eng.run(bars.data(), (int)bars.size()); + + // Entry fee is 48, so q_restore = 10 - (1000-48)/120 = 2.066..., floors + // to two lots and the 4x rule trims eight. Omitting the entry commission + // would floor q_restore to one lot. The action is entry-bar/entry-priced, + // never based on that bar's later low of 100. + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.entry_price(0), 120.0)); + CHECK(near(eng.exit_price(0), 120.0)); + CHECK(near(eng.trade_size(0), 8.0)); + CHECK(eng.entry_bar(0) == 1); + CHECK(eng.exit_bar(0) == 1); + CHECK(near(eng.position_size(), 2.0)); + CHECK(std::isnan(eng.liq_price())); +} + +static void test_long_100pct_margin_trim_without_qty_step() { + std::printf("test_long_100pct_margin_trim_without_qty_step\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 110.0, 112.0, 50.0, 90.0, 1.0), + mk_bar(3000, 90.0, 91.0, 1.0, 2.0, 1.0), + }; + LongOverAllocProbe eng(/*qty_step=*/0.0); + eng.run(bars.data(), (int)bars.size()); + + // qty_step==0 is the engine's continuous-quantity mode: there is no lot + // floor, so the positive restore quantity remains a fractional trim. + const double expected = 4.0 * (10.0 - 1000.0 / 110.0); + CHECK(eng.trade_count() == 1); + CHECK(near(eng.trade_size(0), expected)); + CHECK(near(eng.entry_price(0), 110.0)); + CHECK(near(eng.exit_price(0), 110.0)); + CHECK(near(eng.position_size(), 10.0 - expected)); +} + +static void test_long_100pct_margin_combines_fx_pointvalue_and_commission() { + std::printf("test_long_100pct_margin_combines_fx_pointvalue_and_commission\n"); + std::vector bars = { + // Signal-time admission is exact: 5 * 10 * pv10 * fx2 == 1000. + mk_bar(1000, 10.0, 10.0, 9.0, 10.0, 1.0), + mk_bar(2000, 12.0, 13.0, 8.0, 11.0, 1.0), + mk_bar(3000, 11.0, 12.0, 1.0, 2.0, 1.0), + }; + LongOverAllocProbe eng(/*qty_step=*/1.0, /*commission_percent=*/10.0, + /*process_on_close=*/false, /*slippage_ticks=*/0, + /*mintick=*/0.01, /*account_fx=*/2.0, + /*pointvalue=*/10.0, /*qty=*/5.0); + eng.run(bars.data(), (int)bars.size()); + + // Fill notional is 5*12*10*2=1200 and entry fee is 120, so + // q_restore = 5 - (1000-120)/(12*10*2) = 1.333..., floors to one lot, + // then 4x -> four. Omitting commission, FX, or pointvalue misses this + // deliberately chosen lot boundary. + CHECK(eng.trade_count() == 1); + CHECK(near(eng.trade_size(0), 4.0)); + CHECK(near(eng.entry_price(0), 12.0)); + CHECK(near(eng.exit_price(0), 12.0)); + CHECK(near(eng.position_size(), 1.0)); +} + +static void test_long_100pct_margin_trim_process_orders_on_close() { + std::printf("test_long_100pct_margin_trim_process_orders_on_close\n"); + std::vector bars = { + mk_bar(1000, 100.0, 101.0, 99.0, 100.0, 1.0), + mk_bar(2000, 100.0, 101.0, 10.0, 20.0, 1.0), + }; + LongOverAllocProbe eng(/*qty_step=*/1.0, /*commission_percent=*/12.0, + /*process_on_close=*/true); + eng.run(bars.data(), (int)bars.size()); + + // Entry commission makes q_restore=1.2, floors to one and trims four on + // bar0 itself. The generic "no adverse path after a close fill" rule must + // not suppress this non-price affordability action. + CHECK(eng.trade_count() == 1); + CHECK(near(eng.trade_size(0), 4.0)); + CHECK(eng.entry_bar(0) == 0); + CHECK(eng.exit_bar(0) == 0); + CHECK(near(eng.entry_price(0), 100.0)); + CHECK(near(eng.exit_price(0), 100.0)); + CHECK(near(eng.position_size(), 6.0)); +} + +class LongPricedOverAllocProbe : public MCEngine { +public: + enum class Kind { Stop, Limit }; + + explicit LongPricedOverAllocProbe(Kind kind) : kind_(kind) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 10.0; + commission_value_ = 0.0; + margin_long_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 1.0; + slippage_ = 2; + syminfo_mintick_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + if (kind_ == Kind::Stop) { + strategy_entry("L", true, kNaN, /*stop=*/120.2, /*qty=*/10.0); + } else { + strategy_entry("L", true, /*limit=*/120.8, kNaN, /*qty=*/10.0); + } + } + +private: + Kind kind_; +}; + +static void test_long_100pct_margin_stop_trim_uses_raw_base_and_exit_slip() { + std::printf("test_long_100pct_margin_stop_trim_uses_raw_base_and_exit_slip\n"); + std::vector bars = { + mk_bar(1000, 100.0, 101.0, 99.0, 100.0, 1.0), + mk_bar(2000, 110.0, 130.0, 90.0, 115.0, 1.0), + mk_bar(3000, 115.0, 116.0, 10.0, 20.0, 1.0), + }; + LongPricedOverAllocProbe eng(LongPricedOverAllocProbe::Kind::Stop); + eng.run(bars.data(), (int)bars.size()); + + // stop 120.2 first snaps upward to raw matched base 121, then buy-side + // slippage reports entry 123. The broker trim independently applies + // sell-side slippage to raw 121, reporting 119. It must not use bar.low. + CHECK(eng.trade_count() == 1); + CHECK(near(eng.trade_size(0), 4.0)); + CHECK(near(eng.entry_price(0), 123.0)); + CHECK(near(eng.exit_price(0), 119.0)); + CHECK(eng.entry_bar(0) == 1); + CHECK(eng.exit_bar(0) == 1); + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); +} + +static void test_long_100pct_margin_limit_trim_uses_raw_base_and_exit_slip() { + std::printf("test_long_100pct_margin_limit_trim_uses_raw_base_and_exit_slip\n"); + std::vector bars = { + mk_bar(1000, 130.0, 131.0, 129.0, 130.0, 1.0), + mk_bar(2000, 130.0, 134.0, 100.0, 110.0, 1.0), + mk_bar(3000, 110.0, 111.0, 10.0, 20.0, 1.0), + }; + LongPricedOverAllocProbe eng(LongPricedOverAllocProbe::Kind::Limit); + eng.run(bars.data(), (int)bars.size()); + + // buy limit 120.8 snaps favorably to entry 120 and receives no entry + // slippage. The affordability trim is a broker market sell: raw matched + // base 120.8 minus two ticks snaps down to 118. + CHECK(eng.trade_count() == 1); + CHECK(near(eng.trade_size(0), 4.0)); + CHECK(near(eng.entry_price(0), 120.0)); + CHECK(near(eng.exit_price(0), 118.0)); + CHECK(eng.entry_bar(0) == 1); + CHECK(eng.exit_bar(0) == 1); + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); +} + +class RawOpeningProbe : public MCEngine { +public: + RawOpeningProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + commission_value_ = 0.0; + margin_long_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) strategy_order("RAW", true, /*qty=*/10.0); + } +}; + +static void test_raw_order_fresh_open_captures_affordability() { + std::printf("test_raw_order_fresh_open_captures_affordability\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + mk_bar(2000, 120.0, 125.0, 80.0, 110.0, 1.0), + }; + RawOpeningProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); CHECK(eng.exit_comment(0) == std::string("Margin call")); - CHECK(near(eng.entry_price(0), 110.0)); // notional 1100 > equity 1000 - CHECK(near(eng.exit_price(0), 108.0)); // long adverse extreme = bar low - CHECK(std::isnan(eng.liq_price())); // still na for 1x long (matches TV) + CHECK(near(eng.trade_size(0), 4.0)); + CHECK(near(eng.entry_price(0), 120.0)); + CHECK(near(eng.exit_price(0), 120.0)); + CHECK(near(eng.position_size(), 6.0)); + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); +} + +// ---- C': explicit opening-affordability lifecycle ------------------------- + +static int margin_call_rows(const MCEngine& eng) { + int count = 0; + for (int i = 0; i < eng.trade_count(); ++i) { + if (eng.exit_comment(i) == std::string("Margin call")) ++count; + } + return count; +} + +// A genuine accepted same-direction add is itself a post-fill affordability +// event. FIFO then drains the original lot and makes the mutable entry count +// equal one again; the event must survive because it came from the accepted +// add directly, not from reconstructing provenance from the remaining count. +class AcceptedAddFifoProbe : public MCEngine { +public: + bool captured_after_open = false; + bool eligible_after_add = false; + bool eligible_after_fifo = false; + int count_after_fifo = -1; + + AcceptedAddFifoProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + margin_long_ = 100.0; + process_orders_on_close_ = true; + pyramiding_ = 2; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + + strategy_entry("OPEN", true, kNaN, kNaN, /*qty=*/10.0); + process_pending_orders(current_bar_); + captured_after_open = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(opening_affordability_raw_fill_base_, 100.0); + + // A priced explicit entry bypasses the market-only signal admission + // gate and is a genuine accepted append (10 -> 25), not a rejected + // over-allocation attempt. It is immediately marketable at this close. + strategy_entry("ADD", true, /*limit=*/100.0, kNaN, /*qty=*/15.0); + process_pending_orders(current_bar_); + eligible_after_add = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(opening_affordability_raw_fill_base_, 100.0); + + // FIFO removes the opening lot, leaving only ADD and restoring the + // mutable position_entry_count_ to one. The add event must stay live. + strategy_close("OPEN", "fifo drain", /*qty=*/10.0, + /*qty_percent=*/kNaN, /*immediately=*/true); + count_after_fifo = position_entry_count_; + eligible_after_fifo = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(opening_affordability_raw_fill_base_, 100.0); + } +}; + +static void test_accepted_add_fifo_keeps_add_affordability_event() { + std::printf("test_accepted_add_fifo_keeps_add_affordability_event\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + AcceptedAddFifoProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.captured_after_open); + CHECK(eng.eligible_after_add); + CHECK(eng.count_after_fifo == 1); // cannot reconstruct from this count + CHECK(eng.eligible_after_fifo); + // The one-shot event is consumed at the end-of-bar margin pass. + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.trade_count() == 2); // explicit FIFO close + margin call + CHECK(eng.exit_comment(1) == std::string("Margin call")); + CHECK(near(eng.trade_size(1), 15.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +// A rejected same-direction attempt must not erase the fresh opening's state. +// Commission then makes the opening itself genuinely unaffordable, proving the +// preserved state remains actionable in the end-of-bar check. +class RejectedAddProbe : public MCEngine { +public: + bool preserved_after_rejection = false; + + RejectedAddProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 20.0; + margin_long_ = 100.0; + process_orders_on_close_ = true; + pyramiding_ = 1; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + strategy_entry("OPEN", true, kNaN, kNaN, /*qty=*/10.0); + process_pending_orders(current_bar_); + strategy_entry("REJECTED_ADD", true, kNaN, kNaN, /*qty=*/1.0); + process_pending_orders(current_bar_); // rejected by pyramiding=1 + preserved_after_rejection = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(opening_affordability_raw_fill_base_, 100.0) + && position_entry_count_ == 1 + && near(position_qty_, 10.0); + } +}; + +static void test_rejected_add_preserves_opening_eligibility() { + std::printf("test_rejected_add_preserves_opening_eligibility\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + RejectedAddProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.preserved_after_rejection); + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.trade_count() == 1); + CHECK(near(eng.trade_size(0), 8.0)); + CHECK(near(eng.position_size(), 2.0)); +} + +// A same-bar add whose requested quantity floors to zero has no accepted +// position effect. Its implementation currently appends a zero-qty roster +// element, so the opening-affordability lifecycle must key on positive added +// quantity rather than vector growth alone. +class ZeroQtyAddProbe : public MCEngine { +public: + bool preserved_after_zero_add = false; + + ZeroQtyAddProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 20.0; + margin_long_ = 100.0; + process_orders_on_close_ = true; + pyramiding_ = 2; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + strategy_entry("OPEN", true, kNaN, kNaN, /*qty=*/10.0); + process_pending_orders(current_bar_); + + // apply_qty_step(0.5) == 0 with qty_step=1: the fill kernel appends a + // zero-qty bookkeeping lot but live position quantity stays exactly 10. + strategy_entry("ZERO_ADD", true, kNaN, kNaN, /*qty=*/0.5); + process_pending_orders(current_bar_); + preserved_after_zero_add = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(opening_affordability_raw_fill_base_, 100.0) + && near(position_qty_, 10.0); + } +}; + +static void test_zero_qty_add_preserves_opening_eligibility() { + std::printf("test_zero_qty_add_preserves_opening_eligibility\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + ZeroQtyAddProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.preserved_after_zero_add); + // The original opening remains actionable: its 20% entry commission gives + // q_restore=2 lots, so the 4x rule still trims eight on the opening bar. + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.trade_count() == 1); + CHECK(near(eng.trade_size(0), 8.0)); + CHECK(near(eng.position_size(), 2.0)); +} + +// CASH_PER_ORDER charges once per accepted order, not once per bookkeeping +// row. A high-level add that floors to zero currently appends a zero-qty +// pyramid row; counting that row as a second fee crosses this deliberately +// chosen lot-floor boundary and manufactures a four-lot trim. +class ZeroQtyCashPerOrderAddProbe : public MCEngine { +public: + bool preserved_after_zero_add = false; + + ZeroQtyCashPerOrderAddProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::CASH_PER_ORDER; + commission_value_ = 60.0; + margin_long_ = 100.0; + process_orders_on_close_ = true; + pyramiding_ = 2; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + strategy_entry("OPEN", true, kNaN, kNaN, /*qty=*/10.0); + process_pending_orders(current_bar_); + strategy_entry("ZERO_ADD", true, kNaN, kNaN, /*qty=*/0.5); + process_pending_orders(current_bar_); + preserved_after_zero_add = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(position_qty_, 10.0); + } +}; + +static void test_zero_qty_add_does_not_duplicate_cash_per_order_fee() { + std::printf("test_zero_qty_add_does_not_duplicate_cash_per_order_fee\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + ZeroQtyCashPerOrderAddProbe eng; + eng.run(bars.data(), (int)bars.size()); + + // One real $60 fee: q_min=(1000-(1000-60))/100=.6, floors to zero. + // Charging the zero-qty row adds a phantom second fee: q_min=1.2, + // floors to one, then the 4x rule incorrectly trims four contracts. + CHECK(eng.preserved_after_zero_add); + CHECK(eng.trade_count() == 0); + CHECK(near(eng.position_size(), 10.0)); + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); +} + +// A full close clears the state; a later RAW fresh opening in the same bar +// captures a new raw base and can receive its own affordability trim. +class FlatThenRawFreshProbe : public MCEngine { +public: + bool first_captured = false; + bool add_eligible = false; + bool flat_cleared = false; + bool raw_fresh_captured = false; + + FlatThenRawFreshProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + commission_value_ = 0.0; + margin_long_ = 100.0; + process_orders_on_close_ = true; + pyramiding_ = 2; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + strategy_entry("OPEN", true, kNaN, kNaN, /*qty=*/10.0); + process_pending_orders(current_bar_); + first_captured = opening_affordability_pending_ + && opening_affordability_eligible_; + + strategy_order("ADD", true, /*qty=*/15.0); + process_pending_orders(current_bar_); + add_eligible = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(opening_affordability_raw_fill_base_, 100.0); + + strategy_close_all(); + flat_cleared = position_side_ == PositionSide::FLAT + && !opening_affordability_pending_ + && !opening_affordability_eligible_ + && std::isnan(opening_affordability_raw_fill_base_); + + strategy_order("RAW_FRESH", true, /*qty=*/12.0); + process_pending_orders(current_bar_); + raw_fresh_captured = opening_affordability_pending_ + && opening_affordability_eligible_ + && near(opening_affordability_raw_fill_base_, 100.0); + } +}; + +static void test_flat_clears_and_raw_fresh_reuses_state() { + std::printf("test_flat_clears_and_raw_fresh_reuses_state\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + FlatThenRawFreshProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.first_captured); + CHECK(eng.add_eligible); + CHECK(eng.flat_cleared); + CHECK(eng.raw_fresh_captured); + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); + CHECK(margin_call_rows(eng) == 1); + CHECK(near(eng.position_size(), 4.0)); +} + +// Reversal is a fresh position cycle. The closing short's realized loss must +// be in the new long's opening-equity basis, and the raw reversal match must be +// captured for the broker-generated closing leg. +class ReversalOpeningProbe : public MCEngine { +public: + ReversalOpeningProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + pyramiding_ = 1; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("S", false, kNaN, kNaN, /*qty=*/1.0); + } else if (bar_index_ == 1) { + strategy_entry("L", true, kNaN, kNaN, /*qty=*/10.0); + } + } +}; + +static void test_reversal_captures_fresh_opening_state() { + std::printf("test_reversal_captures_fresh_opening_state\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, 120.0, 121.0, 80.0, 110.0, 1.0), + }; + ReversalOpeningProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 2); // short reversal close + long MC nibble + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.exit_comment(1) == std::string("Margin call")); + CHECK(near(eng.entry_price(1), 120.0)); + CHECK(near(eng.exit_price(1), 120.0)); + CHECK(near(eng.trade_size(1), 4.0)); + CHECK(near(eng.position_size(), 6.0)); + CHECK(!eng.opening_pending()); + CHECK(!eng.opening_eligible()); + CHECK(std::isnan(eng.opening_raw_base())); +} + +// A reused engine handle must clear the per-position state before on_bar of +// the next run. Run 1 deliberately ends with an open position whose one-shot +// event was consumed; run 2 observes a clean state before opening a new RAW +// position and must equal a fresh handle executing run 2 directly. +class ReuseOpeningProbe : public MCEngine { +public: + bool second_mode = false; + bool saw_clean_run_start = false; + + ReuseOpeningProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 10.0; + margin_long_ = 100.0; + process_orders_on_close_ = true; + qty_step_ = 1.0; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + saw_clean_run_start = position_side_ == PositionSide::FLAT + && !opening_affordability_pending_ + && !opening_affordability_eligible_ + && std::isnan(opening_affordability_raw_fill_base_); + if (second_mode) { + strategy_order("RAW", true, /*qty=*/10.0); + } else { + // 9*100 + 10% fee = 990 <= 1000: eligible but no trim. + strategy_entry("L", true, kNaN, kNaN, /*qty=*/9.0); + } + process_pending_orders(current_bar_); + } +}; + +static void test_run_reuse_clears_opening_state() { + std::printf("test_run_reuse_clears_opening_state\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 100.0, 100.0, 1.0), + }; + + ReuseOpeningProbe reused; + reused.run(bars.data(), (int)bars.size()); + CHECK(!reused.opening_pending()); + CHECK(!reused.opening_eligible()); + CHECK(std::isnan(reused.opening_raw_base())); + CHECK(near(reused.position_size(), 9.0)); + + reused.second_mode = true; + reused.run(bars.data(), (int)bars.size()); + CHECK(reused.saw_clean_run_start); + CHECK(margin_call_rows(reused) == 1); + CHECK(near(reused.position_size(), 6.0)); + + ReuseOpeningProbe fresh; + fresh.second_mode = true; + fresh.run(bars.data(), (int)bars.size()); + CHECK(fresh.saw_clean_run_start); + CHECK(fresh.trade_count() == reused.trade_count()); + CHECK(near(fresh.position_size(), reused.position_size())); + CHECK(near(fresh.trade_size(0), reused.trade_size(0))); + CHECK(near(fresh.entry_price(0), reused.entry_price(0))); + CHECK(near(fresh.exit_price(0), reused.exit_price(0))); } -// ---- C: leveraged long (5x) is liquidated by a falling market -------------- +// ---- D: leveraged long (2x) is liquidated by a falling market -------------- class LongLevLiqProbe : public MCEngine { public: @@ -327,6 +1087,8 @@ static void test_long_leveraged_margin_call() { // First forced exit fills at bar1's adverse extreme (low = 95). CHECK(near(eng.exit_price(0), 95.0)); CHECK(near(eng.entry_price(0), 100.0)); + CHECK(eng.trade_count() == 1); + CHECK(near(eng.trade_size(0), 4.2105263157894735)); } } // namespace @@ -337,7 +1099,24 @@ int main() { test_margin_liquidation_price_formula(); test_short_margin_call_disabled(); test_long_100pct_margin_no_call(); - test_long_100pct_margin_overalloc_call(); + test_zero_cost_frozen_all_in_true_flat_gap_is_exempt(); + test_commissioned_frozen_all_in_true_flat_gap_is_eligible(); + test_paired_short_close_default_long_gap_remains_eligible(); + test_long_100pct_margin_sublot_overage_is_held(); + test_long_100pct_margin_lot_trim_uses_entry_affordability(); + test_long_100pct_margin_trim_without_qty_step(); + test_long_100pct_margin_combines_fx_pointvalue_and_commission(); + test_long_100pct_margin_trim_process_orders_on_close(); + test_long_100pct_margin_stop_trim_uses_raw_base_and_exit_slip(); + test_long_100pct_margin_limit_trim_uses_raw_base_and_exit_slip(); + test_raw_order_fresh_open_captures_affordability(); + test_accepted_add_fifo_keeps_add_affordability_event(); + test_rejected_add_preserves_opening_eligibility(); + test_zero_qty_add_preserves_opening_eligibility(); + test_zero_qty_add_does_not_duplicate_cash_per_order_fee(); + test_flat_clears_and_raw_fresh_reuses_state(); + test_reversal_captures_fresh_opening_state(); + test_run_reuse_clears_opening_state(); test_long_leveraged_margin_call(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed);