diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index fb81c20..01ab2c1 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1338,7 +1338,8 @@ class BacktestEngine { // Returns true when a "Margin call" row was booked; the triggering exit // then fills the reduced remainder. bool margin_call_slice_before_priced_exit(const Bar& bar, - double exit_fill_price); + double exit_fill_price, + double exit_path_position); // finding-325 (1x-long entry-fill affordability chronology): TV runs the // 1x-long (margin_long=100) opening-affordability check AT THE ENTRY // FILL, chronologically before the same bar's intrabar exits. When a @@ -2535,12 +2536,15 @@ class BacktestEngine { // limit, or the limit leg of an entry stop-limit) — routes the // fill onto the unslipped limit-or-better price path. bool is_limit_fill = false; - // True when the fill came from resolve_exit_path_fill's stop/limit - // walk of the intrabar path for an exit-style order (not a TRAIL - // fill, and not a market / same-bar-close-priced exit). Only such - // fills carry a meaningful chronological path position, which the + // True when the fill came from resolve_exit_path_fill's walk of the + // intrabar path for an exit-style order (stop, limit, gap-open or + // TRAIL leg — but not a market / same-bar-close-priced exit). Only + // such fills carry a chronological path position, which the // finding-308 pre-exit margin-call slice requires. bool exit_path_fill = false; + // The fill's position on the bar's 4-waypoint path, in + // first_touch_position units. Set whenever exit_path_fill is true. + double exit_path_position = std::numeric_limits::quiet_NaN(); }; FillEvaluation evaluate_fill_price( PendingOrder& order, size_t order_index, const Bar& bar, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 7dc878c..1e203c0 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -226,7 +226,8 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { // pre-on_bar equity, so re-freeze default-sized market orders // exactly like the end-of-bar call sites do. if (fill.exit_path_fill - && margin_call_slice_before_priced_exit(bar, fill.fill_price)) { + && margin_call_slice_before_priced_exit( + bar, fill.fill_price, fill.exit_path_position)) { refresh_frozen_default_sizing_after_margin_call(); } @@ -1000,7 +1001,6 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // commission-free. Competing fallbacks scored 0/974 each: one // qty_step, 4 qty_step, the whole residual, 1% of the position. // - // Only the opt-in whole-residual interpretation keeps precedence. // The structural guards are the same ones the converted-currency // carried-rollover helper above uses: the restore quantity must be // real and sub-contract, and the instrument's lot grid must be able @@ -1023,10 +1023,26 @@ void BacktestEngine::process_margin_call(const Bar& bar) { one_contract_fallback = candidate; } } - if (margin_zero_cover_full_liquidation_) { - floored = qty; - } else if (std::isfinite(one_contract_fallback)) { + // The settled slice rule stays authoritative wherever it can + // express a fill, INCLUDING under the opt-in whole-residual + // interpretation. At eps-scale free-margin deficits (~0.05-0.5 + // USD on the ETH tapes) a multi-contract position's restore + // quantity floors to zero and TV closes exactly ONE contract — + // or, one lot richer, tiny 4x nibbles — and HOLDS the remainder + // (boztilkiserhan-serhan-1 ADX 2025-06-08 / 2026-01-17 six + // partials 0.0004-0.0804 / 2026-01-26; finding 279). Letting the + // full-residual opt-in take precedence here liquidated the + // ENTIRE position at the adverse extreme, an exit TV never + // prints. The opt-in now covers the whole residual only when the + // one-contract fallback cannot express a fill at all (raw + // restore not real/sub-contract, lot grid unable to carry one + // contract); for a sub-one-contract position both readings + // coincide (min(1.0, qty) == qty), so the opt-in's original + // oracle (sub-lot $100-scale shorts) is untouched. + if (std::isfinite(one_contract_fallback)) { floored = one_contract_fallback; + } else if (margin_zero_cover_full_liquidation_) { + floored = qty; } else { return; } @@ -1156,7 +1172,7 @@ void BacktestEngine::revive_position_brackets_after_margin_call_partial( // and the COOF scheduler own finer-grained tick/recalc chronology models and // keep the established once-per-script-bar placement (no exemplar there). bool BacktestEngine::margin_call_slice_before_priced_exit( - const Bar& bar, double exit_fill_price) { + const Bar& bar, double exit_fill_price, double exit_path_position) { if (!margin_call_enabled_) return false; if (position_side_ == PositionSide::FLAT) return false; if (last_margin_call_event_bar_ == bar_index_) return false; @@ -1198,6 +1214,13 @@ bool BacktestEngine::margin_call_slice_before_priced_exit( // rule) than the exit's fill. An off-path level fails closed. A tie — // the exit filling exactly at the extreme, e.g. a stop-loss riding the // adverse leg — keeps the exit first. + // + // exit_path_position is the walk's OWN answer for where the exit filled, + // in the same units first_touch_position produces. Prefer it: it is the + // only correct reading for a TRAIL leg, whose level is not a resting one + // (the trail must arm before it fires, so the fill price's first path + // touch can precede the fill). A caller with no resolved position falls + // back to the price's first touch. const double adverse = (position_side_ == PositionSide::LONG) ? bar.low : bar.high; if (!std::isfinite(adverse) || !(adverse > 0.0)) return false; @@ -1206,7 +1229,10 @@ bool BacktestEngine::margin_call_slice_before_priced_exit( if (!internal::first_touch_position(bar, adverse, &adverse_pos)) { return false; } - if (!internal::first_touch_position(bar, exit_fill_price, &exit_pos)) { + if (std::isfinite(exit_path_position)) { + exit_pos = exit_path_position; + } else if (!internal::first_touch_position(bar, exit_fill_price, + &exit_pos)) { return false; } if (!(adverse_pos < exit_pos - kPathPosEps)) return false; @@ -1258,10 +1284,16 @@ bool BacktestEngine::margin_call_slice_before_priced_exit( one_contract_fallback = candidate; } } - if (margin_zero_cover_full_liquidation_) { - floored = qty; - } else if (std::isfinite(one_contract_fallback)) { + // Same precedence as the cascade above: the settled slice rule + // stays authoritative wherever it can express a fill, including + // under the full-residual opt-in. This copy of the arithmetic is + // reached when the deficit is discovered chronologically, before + // a same-bar priced exit — the eps-deficit shape does not stop + // being an eps-deficit because it was found there. + if (std::isfinite(one_contract_fallback)) { floored = one_contract_fallback; + } else if (margin_zero_cover_full_liquidation_) { + floored = qty; } else { return false; } @@ -5460,6 +5492,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( bool should_fill = false; bool is_limit_fill = false; bool exit_path_fill = false; + double exit_path_position = std::numeric_limits::quiet_NaN(); // A valid child that was armed with its pending MARKET parent and whose // stop is already breached — or whose limit is already marketable — at @@ -5584,12 +5617,16 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( should_fill = true; last_exit_fill_was_trail_ = exit_fill.is_trail; is_limit_fill = exit_fill.is_limit; - // finding-308: only a stop/limit fill resolved on the intrabar - // path carries a chronological position the pre-exit margin-call - // slice can compare against the adverse extreme. A TRAIL fill - // price is not a resting level (its first path touch is not its - // fill moment), so it stays outside the hook — fail closed. - exit_path_fill = !exit_fill.is_trail; + // finding-308: a fill resolved on the intrabar path carries the + // chronological position the pre-exit margin-call slice compares + // against the adverse extreme. resolve_exit_path_fill reports it + // directly, so the TRAIL leg participates too — its fill price + // is not a resting level (its first path touch is not its fill + // moment), which is exactly why the position must come from the + // walk rather than from first_touch_position(fill price). A + // fill without a resolved position still fails closed. + exit_path_position = exit_fill.path_position; + exit_path_fill = std::isfinite(exit_path_position); } } else if (!should_fill && (order.type == OrderType::MARKET || (!has_stop && !has_limit && !has_trail))) { @@ -5676,7 +5713,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( } return {should_fill ? FillEvaluation::Kind::Fill : FillEvaluation::Kind::NoFill, - fill_price, is_limit_fill, exit_path_fill}; + fill_price, is_limit_fill, exit_path_fill, exit_path_position}; } } // namespace pineforge diff --git a/src/engine_internal.hpp b/src/engine_internal.hpp index cb90438..dd3f92e 100644 --- a/src/engine_internal.hpp +++ b/src/engine_internal.hpp @@ -210,6 +210,14 @@ struct ExitPathFill { // engine.hpp); the fill-application code needs to know which leg fired // because price equality cannot distinguish a gap fill at the open. bool is_limit = false; + // Where the fill happened on the bar's 4-waypoint synthesized path, in + // first_touch_position units (0 = open, 1/2 = the extremes, 3 = close; + // fractional inside a segment). This is the fill's ACTUAL chronology, + // which for a TRAIL leg is not recoverable from the fill price alone — + // a trail's level is not a resting one, so its first path touch can + // precede the moment it arms and fires. finding-308's pre-exit + // margin-call slice compares this against the adverse extreme. + double path_position = std::numeric_limits::quiet_NaN(); }; diff --git a/src/engine_path_resolve.cpp b/src/engine_path_resolve.cpp index edfa6d3..6052b0e 100644 --- a/src/engine_path_resolve.cpp +++ b/src/engine_path_resolve.cpp @@ -864,6 +864,8 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, && (!is_entry_bar || magnifier_active || cascade_wp_gap)) { if (try_exit_open_gap_fill(bar, is_long, has_stop, stop_price, has_limit, limit_price, trail, &fill)) { + // A gap fill happens at the bar's open — path position 0. + fill.path_position = 0.0; return fill; } } @@ -904,6 +906,17 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, fill.fill_price = events.ev[0].price; fill.is_trail = (events.ev[0].kind == PathCrossKind::TRAIL); fill.is_limit = (events.ev[0].kind == PathCrossKind::LIMIT); + // Chronology of the fill itself, in first_touch_position units. + // Interpolate against the FULL segment (path[seg_idx-1] -> + // path[seg_idx]) even when a mid-path cursor truncated it, so + // the scale matches first_touch_position's exactly. + const double seg_origin = path[seg_idx - 1]; + const double seg_denom = to_price - seg_origin; + double fill_pos = seg_start; + if (std::abs(seg_denom) > kSegmentDenomEps) { + fill_pos += (fill.fill_price - seg_origin) / seg_denom; + } + fill.path_position = fill_pos; return fill; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 481f731..8998b92 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -110,6 +110,7 @@ set(TEST_SOURCES test_drawing test_margin_call test_margin_call_intrabar_chronology + test_margin_call_trail_exit_chronology test_margin_call_1x_long_entry_fill test_percent_equity_open_entry_fee test_streaming diff --git a/tests/test_direct_short_reversal_affordability.cpp b/tests/test_direct_short_reversal_affordability.cpp index 56923af..c1ed962 100644 --- a/tests/test_direct_short_reversal_affordability.cpp +++ b/tests/test_direct_short_reversal_affordability.cpp @@ -4,8 +4,10 @@ * An omitted-qty, percent-of-equity=100 MARKET LONG-to-SHORT reversal at * margin_short=100 receives a fill-price affordability pass followed by one * bounded adverse-high retry. Its position-lifecycle bit also participates in - * the established one-contract finite-price floor-zero fallback, while the - * optional full-residual interpretation keeps precedence. + * the established one-contract finite-price floor-zero fallback; the optional + * full-residual interpretation yields to that settled slice whenever the + * one-contract fallback is expressible (finding 279: TV slices and holds at + * eps-scale deficits, it never full-liquidates there). */ #include @@ -195,9 +197,9 @@ class FloorZeroPrecedenceProbe final : public DirectShortReversalProbe { } }; -void test_direct_lifecycle_floor_zero_and_full_residual_precedence() { +void test_direct_lifecycle_floor_zero_full_residual_yields_to_slice() { std::printf( - "test_direct_lifecycle_floor_zero_and_full_residual_precedence\n"); + "test_direct_lifecycle_floor_zero_full_residual_yields_to_slice\n"); const std::vector bars = { bar(1000, 4506.71, 4506.71, 4506.71, 4506.71), bar(2000, 4506.70, 4514.70, 4500.00, 4506.70), @@ -222,6 +224,10 @@ void test_direct_lifecycle_floor_zero_and_full_residual_precedence() { CHECK_NEAR(one_contract.position_size(), -1.7346, 1e-9); CHECK(one_contract.direct_lifecycle_active()); + // The full-residual opt-in yields to the settled one-contract slice at + // the floor-zero discontinuity: both cells now take the identical lots + // and HOLD the remainder (TV never full-liquidates at these eps-scale + // deficits — finding 279, serhan ADX). FloorZeroPrecedenceProbe full_residual(/*full_residual=*/true); full_residual.run(bars.data(), static_cast(bars.size())); const std::vector full_residual_qty = @@ -234,11 +240,11 @@ void test_direct_lifecycle_floor_zero_and_full_residual_precedence() { && full_residual_price.size() == 2U) { CHECK_NEAR(full_residual_qty[0], 0.0392, 1e-9); CHECK_NEAR(full_residual_price[0], 4514.70, 1e-9); - CHECK_NEAR(full_residual_qty[1], 2.7346, 1e-9); + CHECK_NEAR(full_residual_qty[1], 1.0, 1e-9); CHECK_NEAR(full_residual_price[1], 4539.00, 1e-9); } - CHECK_NEAR(full_residual.position_size(), 0.0, 1e-9); - CHECK(!full_residual.direct_lifecycle_active()); + CHECK_NEAR(full_residual.position_size(), -1.7346, 1e-9); + CHECK(full_residual.direct_lifecycle_active()); } class TrueFlatFullResidualControlProbe final @@ -280,16 +286,19 @@ class TrueFlatFullResidualControlProbe final } }; -void test_true_flat_full_residual_control_is_unchanged() { - std::printf("test_true_flat_full_residual_control_is_unchanged\n"); +// Control: the commissioned true-flat lifecycle bit does not alter the +// floor-zero outcome — with the full-residual opt-in set, the settled +// one-contract slice still applies and the remainder is HELD. +void test_true_flat_floor_zero_control_slices_one_contract() { + std::printf("test_true_flat_floor_zero_control_slices_one_contract\n"); TrueFlatFullResidualControlProbe probe; probe.trigger(); const std::vector qty = probe.margin_quantities(); CHECK(qty.size() == 1U); if (qty.size() == 1U) { - CHECK_NEAR(qty[0], 3.6930, 1e-9); + CHECK_NEAR(qty[0], 1.0, 1e-9); } - CHECK_NEAR(probe.position_size(), 0.0, 1e-9); + CHECK_NEAR(probe.position_size(), -2.6930, 1e-9); CHECK(!probe.direct_lifecycle_active()); } @@ -683,8 +692,8 @@ void test_run_reset_clears_direct_lifecycle() { int main() { std::printf("--- direct short reversal affordability ---\n"); test_direct_reversal_runs_opening_check_and_one_adverse_retry(); - test_direct_lifecycle_floor_zero_and_full_residual_precedence(); - test_true_flat_full_residual_control_is_unchanged(); + test_direct_lifecycle_floor_zero_full_residual_yields_to_slice(); + test_true_flat_floor_zero_control_slices_one_contract(); test_direct_path_exclusion_matrix(); test_no_effect_same_side_add_preserves_direct_provenance(); test_fresh_entry_and_raw_order_clear_direct_provenance(); diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index adbb842..3c28cba 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -351,12 +351,16 @@ static void test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble() CHECK(near(eng.position_size(), -(10.0 - 4.0 * step), 1e-12)); } -static void test_short_margin_call_just_below_step_still_zero_covers() { - std::printf("test_short_margin_call_just_below_step_still_zero_covers\n"); +static void test_short_margin_call_just_below_step_slices_one_contract() { + std::printf("test_short_margin_call_just_below_step_slices_one_contract\n"); constexpr double step = 0.0001; constexpr double below_guard_ratio = 1.0 - 2e-6; // This is genuinely below one step by more than the 1e-6 representation - // guard. It must still quantize to zero and close the full residual. + // guard. It must quantize to zero and land on the settled floor-zero + // discontinuity: TV closes ONE whole contract and HOLDS the remainder. + // The full-residual opt-in no longer overrides that settled slice — + // at an eps-scale deficit it used to liquidate the whole ten-contract + // position here, an exit TV never prints (finding 279, serhan ADX). const double adverse = 2000.0 / (20.0 - step * below_guard_ratio); const double equity_at_high = 1000.0 - (adverse - 100.0) * 10.0; const double q_min = 10.0 - equity_at_high / adverse; @@ -376,8 +380,135 @@ static void test_short_margin_call_just_below_step_still_zero_covers() { CHECK(eng.trade_count() == 1); CHECK(eng.exit_comment(0) == std::string("Margin call")); CHECK(near(eng.exit_price(0), 100.01, 1e-12)); - CHECK(near(eng.trade_size(0), 10.0, 1e-12)); - CHECK(near(eng.position_size(), 0.0, 1e-12)); + CHECK(near(eng.trade_size(0), 1.0, 1e-12)); + CHECK(near(eng.position_size(), -9.0, 1e-12)); + + // Control: the flag-off engine takes the identical settled slice. + ShortLiqProbe default_eng(/*disable_mc=*/false, /*qty_step=*/step); + default_eng.run(bars.data(), (int)bars.size()); + CHECK(default_eng.trade_count() == 1); + CHECK(near(default_eng.trade_size(0), 1.0, 1e-12)); + CHECK(near(default_eng.position_size(), -9.0, 1e-12)); +} + +// ETH-scale eps-deficit shape (finding 279, boztilkiserhan serhan ADX +// 2025-06-08 / 2026-01-17 / 2026-03-22): an all-in multi-contract short whose +// entry bar prints an adverse extreme a tick or two past the frozen sizing +// close leaves a free-margin deficit of a few tenths of a USD. The restore +// quantity floors to zero at the 0.0001 lot step and TV closes exactly ONE +// contract at the adverse extreme, HOLDING the remainder — under the +// full-residual opt-in exactly as without it. The engine used to liquidate +// the ENTIRE position at that extreme when the opt-in was set. +class ShortEpsDeficitProbe : public MCEngine { +public: + explicit ShortEpsDeficitProbe(bool full_residual) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; // freeze 10000/2500 = 4.0 short + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; // fill at next bar OPEN + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + set_syminfo_metadata("margin_zero_cover_full_liquidation", + full_residual ? 1.0 : 0.0); + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("S", false, kNaN, kNaN, kNaN); + } + } +}; + +static void test_short_margin_call_eps_deficit_slices_one_contract_and_holds() { + std::printf( + "test_short_margin_call_eps_deficit_slices_one_contract_and_holds\n"); + std::vector bars = { + // Signal close 2500 freezes qty = 4.0 exactly. + mk_bar(1000, 2500.00, 2500.00, 2500.00, 2500.00, 1.0), + // Entry @ open 2499.99 (notional 9999.96 < equity, admitted). The + // bar's HIGH 2500.01 is two ticks adverse: equity there 9999.92 vs + // required 10000.04 -> deficit 0.12 USD, q_min = 4.8e-5 < one lot. + mk_bar(2000, 2499.99, 2500.01, 2495.00, 2496.00, 1.0), + // No further breach: the remaining 3.0 contracts are HELD. + mk_bar(3000, 2496.00, 2499.00, 2490.00, 2492.00, 1.0), + }; + + for (int full_residual = 0; full_residual <= 1; ++full_residual) { + ShortEpsDeficitProbe eng(full_residual != 0); + 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), 2499.99, 1e-9)); + CHECK(near(eng.exit_price(0), 2500.01, 1e-9)); + CHECK(near(eng.trade_size(0), 1.0, 1e-9)); + CHECK(near(eng.position_size(), -3.0, 1e-9)); + } +} + +// finding-308's chronological pre-exit hook carries its own copy of the +// floor-zero arithmetic (the deficit is discovered at the adverse extreme, +// before a same-bar priced exit fills). The settled slice must win there +// too — an eps-deficit found on that route is still an eps-deficit. +// +// Tape geometry: boztilkiserhan serhan1 scalp, ETHUSDT.P 15m 2025-10-19 +// 08:15 UTC (O 3886.31 / H 3960 / L 3810), carrying short 2.119 @ 3879.36. +// initial_capital 8561.92 puts equity at the adverse high 3960 at +// 8391.04384 against required margin 8391.24 — a 0.196 USD deficit, so +// q_min = 4.95e-5 and floors to zero at the 0.0001 lot. +class ShortEpsDeficitChronologyProbe : public MCEngine { +public: + explicit ShortEpsDeficitChronologyProbe(bool full_residual) { + initial_capital_ = 8561.92; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + set_syminfo_metadata("margin_zero_cover_full_liquidation", + full_residual ? 1.0 : 0.0); + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("S", false, kNaN, kNaN, /*qty=*/2.119); + } else if (bar_index_ == 1) { + // A resting take-profit limit on the H->L leg, i.e. strictly + // after the adverse high on the O -> H -> L -> C path. + strategy_exit("X", "S", /*limit=*/3821.06, /*stop=*/kNaN); + } + } +}; + +static void test_eps_deficit_chronology_slice_is_one_contract() { + std::printf("test_eps_deficit_chronology_slice_is_one_contract\n"); + std::vector bars = { + mk_bar(1000, 3879.36, 3879.36, 3879.36, 3879.36, 1.0), + mk_bar(2000, 3879.36, 3879.36, 3879.36, 3879.36, 1.0), + mk_bar(3000, 3886.31, 3960.00, 3810.00, 3873.57, 1.0), + }; + + for (int full_residual = 0; full_residual <= 1; ++full_residual) { + ShortEpsDeficitChronologyProbe eng(full_residual != 0); + eng.run(bars.data(), (int)bars.size()); + + // One contract at the adverse high, then the limit closes the + // remainder. The opt-in used to liquidate the whole 2.119 here. + CHECK(eng.trade_count() == 2); + if (eng.trade_count() == 2) { + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.trade_size(0), 1.0, 1e-9)); + CHECK(near(eng.exit_price(0), 3960.0, 1e-9)); + CHECK(eng.exit_comment(1) != std::string("Margin call")); + CHECK(near(eng.trade_size(1), 1.119, 1e-9)); + CHECK(near(eng.exit_price(1), 3821.06, 1e-9)); + } + CHECK(near(eng.position_size(), 0.0, 1e-9)); + } } static void test_short_margin_call_zero_cover_without_qty_step_stays_continuous() { @@ -1613,11 +1744,16 @@ static void test_default_short_lifecycle_floor_zero_one_contract() { 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. + // The opt-in whole-residual interpretation no longer overrides the + // settled floor-zero slice: when the one-contract fallback is + // expressible, a verifier combining both candidates gets the SAME one + // whole contract and HOLDS the remainder (TV never prints a full + // liquidation at these eps-scale deficits — finding 279, serhan ADX). 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)); + CHECK(near(full_residual.exit_price(0), 1801.26)); + CHECK(near(full_residual.trade_size(0), 1.0, 1e-9)); + CHECK(near(full_residual.position_size(), -2.6930, 1e-9)); + CHECK(full_residual.lifecycle_active()); } // The commissioned lifecycle covers the opening checkpoint too. The positive @@ -2937,7 +3073,9 @@ int main() { test_short_margin_call_zero_cover_closes_full_residual(); test_short_margin_call_zero_cover_closes_sub_one_residual(); test_short_margin_call_exact_one_step_roundoff_keeps_four_x_nibble(); - test_short_margin_call_just_below_step_still_zero_covers(); + test_short_margin_call_just_below_step_slices_one_contract(); + test_short_margin_call_eps_deficit_slices_one_contract_and_holds(); + test_eps_deficit_chronology_slice_is_one_contract(); test_short_margin_call_zero_cover_without_qty_step_stays_continuous(); test_short_margin_call_nonzero_cover_keeps_four_x_nibble(); test_short_opening_affordability_zero_cover_closes_one_contract(); diff --git a/tests/test_margin_call_trail_exit_chronology.cpp b/tests/test_margin_call_trail_exit_chronology.cpp new file mode 100644 index 0000000..661c8c6 --- /dev/null +++ b/tests/test_margin_call_trail_exit_chronology.cpp @@ -0,0 +1,264 @@ +/* + * test_margin_call_trail_exit_chronology.cpp — finding-308 extended to TRAIL + * exits. + * + * The chronological pre-exit forced-liquidation slice was gated on + * `exit_path_fill = !exit_fill.is_trail`: a TRAIL fill was excluded because + * the hook derived the exit's chronology from first_touch_position(fill + * price), and a trail's fill price is not a resting level — its first path + * touch is not necessarily its fill moment (the trail must arm first). The + * exclusion failed closed and dropped every margin-call slice on a bar whose + * adverse extreme precedes a trailing exit. + * + * resolve_exit_path_fill now reports the fill's ACTUAL path position, so the + * chronology is exact for every intrabar path fill and the trail leg no + * longer needs excluding. + * + * Exemplar (boztilkiserhan serhan1 WMA/RSI trailing scalp, ETHUSDT.P 15m, + * 2025-10-19 08:15 UTC — bar O 3886.31 / H 3960 / L 3810 / C 3873.57, short + * 2.119 @ 3879.36 carried in): + * + * TV — Margin call 0.234 @ 3960 (the adverse high), then "Exit Short" + * closes the remaining 1.885 @ 3821.06 on the H->L leg. + * Engine — one row: the whole 2.119 @ 3821.06, no margin call. + * + * Fixtures: + * A. HIGH-first bar, trail fills after the high -> slice 0.234 @ 3960 and + * the trail closes the remainder 1.885 @ 3821.06 (the tape shape). + * B. LOW-first bar with the same deficit at the high -> the trail fills + * BEFORE the extreme on the path -> no slice (fail-closed chronology + * is preserved, the hook is not simply switched on for trails). + * C. Emulator off -> nothing fires. + * D. #148 regression pin: an EXPLICIT trail_offset=0 must not retro-arm + * from the carried post-entry extreme, while an OMITTED trail_offset + * still does. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) < tol; +} + +namespace { + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk_bar(int64_t ts, double o, double h, double l, double c, double v) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; b.volume = v; b.timestamp = ts; + return b; +} + +class MCEngine : public BacktestEngine { +public: + std::string exit_comment(int i) const { return closed_trade_exit_comment(i); } + 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 exit_bar(int i) const { return closed_trade_exit_bar_index(i); } + double position_size() const { return signed_position_size(); } +}; + +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; +} + +// The tape's carried short: 2.119 contracts @ 3879.36, 1x margin, no +// commission. initial_capital 8330.26 is chosen so the deficit at the +// adverse high 3960 reproduces TV's slice bit-exactly: +// +// equity(3960) = 8330.26 + (3879.36 - 3960) * 2.119 = 8159.38384 +// q_min = 2.119 - 8159.38384 / 3960 = 0.05854953... +// floor(0.0001) = 0.0585 +// 4x = 0.2340 +// +// The trailing exit is the strategy's own shape: trail_points in ticks with +// an EXPLICIT trail_offset = 0 (TV's exit-at-activation trail). 5830 ticks +// at mintick 0.01 puts the activation at 3879.36 - 58.30 = 3821.06 — the +// tape's own trail-exit price. +class TrailChronologyShortProbe : public MCEngine { +public: + explicit TrailChronologyShortProbe(bool disable_mc = false, + double trail_offset = 0.0) + : trail_offset_(trail_offset) { + initial_capital_ = 8330.26; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + if (disable_mc) set_margin_call_enabled(false); + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("S", false, kNaN, kNaN, /*qty=*/2.119); + } else if (bar_index_ == 1) { + // Armed while the position is live; rests for the event bar. + strategy_exit("X", "S", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/5830.0, + /*trail_offset=*/trail_offset_, + /*trail_price=*/kNaN); + } + } + +private: + double trail_offset_; +}; + +static std::vector seed_bars(const Bar& event_bar) { + return { + mk_bar(1000, 3879.36, 3879.36, 3879.36, 3879.36, 1.0), // 0: signal + mk_bar(2000, 3879.36, 3879.36, 3879.36, 3879.36, 1.0), // 1: fill+arm + event_bar, // 2: event + }; +} + +// The tape bar. |3960 - 3886.31| = 73.69 < |3886.31 - 3810| = 76.31, so the +// open is nearer the high: path O -> H -> L -> C. The adverse high sits at +// path position 1.0, the trail fills on the H->L leg at ~1.926. +static Bar tape_event_bar() { + return mk_bar(3000, 3886.31, 3960.0, 3810.0, 3873.57, 1.0); +} + +// ---- A: the tape shape — slice at the high, trail closes the remainder ---- + +static void test_trail_exit_slices_at_adverse_extreme_first() { + std::printf("test_trail_exit_slices_at_adverse_extreme_first\n"); + std::vector bars = seed_bars(tape_event_bar()); + + TrailChronologyShortProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 2); + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.trade_size(0), 0.234, 1e-9)); + CHECK(near(eng.exit_price(0), 3960.0)); + CHECK(near(eng.entry_price(0), 3879.36)); + CHECK(eng.exit_bar(0) == 2); + CHECK(eng.exit_comment(1) != std::string("Margin call")); + CHECK(near(eng.trade_size(1), 1.885, 1e-9)); + CHECK(near(eng.exit_price(1), 3821.06)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- B: a trail that fills BEFORE the extreme stays quiet ----------------- + +static void test_trail_exit_before_extreme_stays_quiet() { + std::printf("test_trail_exit_before_extreme_stays_quiet\n"); + // LOW-first bar: |3960 - 3830| = 130 > |3830 - 3810| = 20, so the path + // is O -> L -> H -> C and the trail fills at 3821.06 on the O->L leg + // (position ~0.45), before the adverse high at position 2.0. The + // deficit at that high is the SAME as fixture A — only the chronology + // differs, and it must keep the slice suppressed. + std::vector bars = seed_bars( + mk_bar(3000, 3830.0, 3960.0, 3810.0, 3900.0, 1.0)); + + TrailChronologyShortProbe eng; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(margin_call_rows(eng) == 0); + CHECK(near(eng.trade_size(0), 2.119, 1e-9)); + CHECK(near(eng.exit_price(0), 3821.06)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- C: emulator off -> nothing fires ------------------------------------- + +static void test_trail_chronology_disabled_emulator_stays_quiet() { + std::printf("test_trail_chronology_disabled_emulator_stays_quiet\n"); + std::vector bars = seed_bars(tape_event_bar()); + + TrailChronologyShortProbe eng(/*disable_mc=*/true); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(margin_call_rows(eng) == 0); + CHECK(near(eng.trade_size(0), 2.119, 1e-9)); + CHECK(near(eng.exit_price(0), 3821.06)); +} + +// ---- D: #148 arming semantics stay put ------------------------------------ + +// An EXPLICIT trail_offset=0 is TV's one-shot exit-at-activation trail and +// must NOT retro-arm from the carried post-entry extreme (#148, a6e46ca); +// an OMITTED trail_offset still does. Both cells share this fixture: the +// trail is armed on bar 1 (carried best = bar 1 close 3810, already past the +// 3821.06 activation for a short), and bar 2 opens ABOVE the activation and +// never trades down to it. Only a carried armed state can fill there. +static void test_zero_offset_arming_semantics_unchanged() { + std::printf("test_zero_offset_arming_semantics_unchanged\n"); + std::vector bars = { + mk_bar(1000, 3879.36, 3879.36, 3879.36, 3879.36, 1.0), + mk_bar(2000, 3879.36, 3879.36, 3800.00, 3810.00, 1.0), + mk_bar(3000, 3830.00, 3835.00, 3825.00, 3832.00, 1.0), + mk_bar(4000, 3832.00, 3836.00, 3826.00, 3833.00, 1.0), + }; + + // Explicit zero: no retro-arm -> the position is still open at the end. + TrailChronologyShortProbe explicit_zero(/*disable_mc=*/false, + /*trail_offset=*/0.0); + explicit_zero.run(bars.data(), (int)bars.size()); + CHECK(explicit_zero.trade_count() == 0); + CHECK(near(explicit_zero.position_size(), -2.119, 1e-9)); + + // Omitted offset: the carried extreme keeps the activation armed, so + // the exit fills at bar 2's open. + TrailChronologyShortProbe omitted(/*disable_mc=*/false, + /*trail_offset=*/kNaN); + omitted.run(bars.data(), (int)bars.size()); + CHECK(omitted.trade_count() == 1); + if (omitted.trade_count() == 1) { + CHECK(near(omitted.exit_price(0), 3830.0)); + CHECK(omitted.exit_bar(0) == 2); + } + CHECK(near(omitted.position_size(), 0.0)); +} + +} // namespace + +int main() { + std::printf("=== test_margin_call_trail_exit_chronology ===\n"); + + test_trail_exit_slices_at_adverse_extreme_first(); + test_trail_exit_before_extreme_stays_quiet(); + test_trail_chronology_disabled_emulator_stays_quiet(); + test_zero_offset_arming_semantics_unchanged(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return (tests_failed > 0) ? 1 : 0; +}