diff --git a/corpus b/corpus index b5d53d2..93d3e01 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit b5d53d2903e06f56dac88eb8eecef33465eb80ad +Subproject commit 93d3e01eba345a1796cb02c899b36eb95dc66fd6 diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 226a990..323e48c 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -902,6 +902,22 @@ class BacktestEngine { // callers that want the legacy hold-to-infinity behaviour. bool margin_call_enabled_ = true; + // finding-308 margin-call intrabar chronology state. TV places the + // forced-liquidation event chronologically on the synthesized intrabar + // path, so a priced exit that fills strictly AFTER the bar's adverse + // extreme (on the engine's own OHLC path) must let a pre-fill deficit + // slice first. ``last_margin_call_event_bar_`` records the last + // bar_index_ on which ANY margin-call trade row was booked (FX broker- + // open rollover, end-of-bar cascade, or the pre-exit slice); the + // pre-exit hook consults it so at most one forced-liquidation event + // fires per bar. ``intrabar_exit_margin_call_bar_`` is set ONLY by the + // pre-exit slice and tells the end-of-bar process_margin_call that this + // bar's adverse-extreme event was already consumed chronologically (the + // surviving remainder is re-checked from the next bar on, preserving + // TV's one-nibble-per-bar cascade). + int last_margin_call_event_bar_ = -1; + int intrabar_exit_margin_call_bar_ = -1; + // Legacy codegen compatibility bit. Older and current generated classes // set this when the Pine source contains ``strategy.close`` or // ``strategy.close_all``. Runtime behavior must not depend on it: a @@ -1295,6 +1311,19 @@ class BacktestEngine { // adverse-price liquidation; only an eligible one-shot post-fill // affordability event can trim it. void process_margin_call(const Bar& bar); + // finding-308: chronological pre-exit forced-liquidation slice. Called + // from the process_pending_orders fill loop immediately BEFORE a priced + // exit of the live position is applied. Fires only when (a) no margin + // call was booked on this bar yet, (b) the bar's adverse extreme comes + // STRICTLY earlier on the synthesized intrabar path than the exit's + // fill (a tie — the exit filling exactly at the extreme — keeps the + // exit first), and (c) the pre-fill position is already in margin + // deficit at that extreme. The slice mirrors the adverse-cascade + // trigger/slice arithmetic of process_margin_call byte-for-byte. + // 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); // A timestamped FX rollover is a broker-open event, not an end-of-bar // adverse-price check. Cell A1 supports carried 1x full-margin long and // short in ordinary historical dispatch; leveraged shapes stay fail-closed. @@ -2460,6 +2489,12 @@ 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 + // finding-308 pre-exit margin-call slice requires. + bool exit_path_fill = false; }; 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 7f48c37..9a34714 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -218,6 +218,18 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { continue; } + // finding-308: TV books forced liquidation chronologically on the + // intrabar path. If this priced exit fills strictly AFTER the bar's + // adverse extreme and the pre-fill position is already in deficit + // there, the margin-call slice happens first and the exit below + // closes the reduced remainder. The slice is a broker fill on + // 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)) { + refresh_frozen_default_sizing_after_margin_call(); + } + const bool path_winner_stop_margin_decline = continue_after_stop_margin_decline_scope && ((dual_entry_path_ == DualEntryStopPathWinner::LongFirst @@ -681,6 +693,7 @@ bool BacktestEngine::process_carried_position_fx_rollover(const Bar& bar) { if (trades_.size() == trades_before) return false; ++broker_fill_event_seq_; + last_margin_call_event_bar_ = bar_index_; // finding-308: one MC event/bar for (std::size_t ti = trades_before; ti < trades_.size(); ++ti) { trades_[ti].exit_comment = "Margin call"; trades_[ti].exit_id = "__margin_call__"; @@ -870,6 +883,12 @@ void BacktestEngine::process_margin_call(const Bar& bar) { q_min = qty - opening_equity / margin_per_unit; raw_exit_fill_base = opening_event_raw_fill_base; } else { + // finding-308: a chronological pre-exit slice already consumed this + // bar's adverse-extreme forced-liquidation event (the exit that + // triggered it fills the reduced remainder inside the order loop). + // The surviving position is re-checked from the next bar on — TV's + // one-nibble-per-bar cascade. + if (intrabar_exit_margin_call_bar_ == bar_index_) return; // Shorts and leveraged longs without a fresh opening event keep the // established adverse-extreme cascade. Equity and required margin are // account-currency values, so quote-currency price PnL/notional must @@ -1034,6 +1053,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { } if (trades_.size() != trades_before) { ++broker_fill_event_seq_; + last_margin_call_event_bar_ = bar_index_; // finding-308: one MC/bar } // Tag every trade row this liquidation produced with TV's "Margin call". for (size_t ti = trades_before; ti < trades_.size(); ++ti) { @@ -1047,6 +1067,165 @@ void BacktestEngine::process_margin_call(const Bar& bar) { run_default_short_adverse_retry(); } +// finding-308 (margin-call intrabar chronology). TradingView places the +// forced-liquidation event chronologically on the synthesized intrabar path. +// When a priced exit of the live position fills on a bar whose adverse +// extreme comes STRICTLY earlier on that path than the exit's fill, and the +// position is already in margin deficit at the extreme, TV slices FIRST (the +// ordinary floor-before-4x nibble, filled at the extreme, tagged +// "Margin call") and the exit then closes the reduced remainder. The +// engine's once-per-bar check at the end of dispatch_bar ran AFTER all order +// processing, so a same-bar full exit hid the deficit (the FLAT early-return +// above) and the event was lost. +// +// The trigger and slice arithmetic below mirror process_margin_call's +// adverse-extreme (non-opening) branch byte-for-byte — the confirmed +// trigger/slice rules themselves are untouched. The derivation (Lab finding +// 308, rhyme17 whole-tape per-position replay) confirmed 3/3 TP-exit +// adverse-first deficit bars produce TV's slices bit-exact through this +// arithmetic (0.0084 / 0.0044 / 0.0384), while both LOW-first large-deficit +// bars (extreme AFTER the exit fill on the path) and 157/158 SL-stop deficit +// bars (stop fills at-or-before the extreme -> tie or earlier -> exit first) +// stay quiet under the chronology condition. +// +// One margin-call event per bar: a prior event this bar (FX broker-open +// rollover, an earlier hook firing, or a stream-tick cascade) blocks the +// hook, and a hook firing marks the bar so the end-of-bar +// process_margin_call does not double-liquidate the survivor. The magnifier +// 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) { + if (!margin_call_enabled_) return false; + if (position_side_ == PositionSide::FLAT) return false; + if (last_margin_call_event_bar_ == bar_index_) return false; + if (bar_magnifier_enabled_ || coof_scheduler_active_) return false; + + // Eligibility gates, mirroring process_margin_call's finite-price path. + // A 1x long has no adverse-price liquidation (its only broker action is + // the one-shot post-fill affordability event, which stays end-of-bar); + // a POOC position filled at this bar's close has no post-fill adverse + // path on the bar. + const bool opened_this_bar = position_open_bar_ == bar_index_; + const bool long_full_margin = + (position_side_ == PositionSide::LONG) + && std::isfinite(margin_long_) + && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; + if (long_full_margin) return false; + if (process_orders_on_close_ && opened_this_bar) return false; + const double liq = compute_liquidation_price(); + if (std::isnan(liq)) return false; + + const double pv = syminfo_.pointvalue; + const double qty = position_qty_; + const double margin_pct = (position_side_ == PositionSide::LONG) + ? margin_long_ : margin_short_; + const double m = margin_pct / 100.0; + if (!(m > 0.0)) return false; + if (!std::isfinite(qty) || !(qty > 0.0) + || !std::isfinite(position_entry_price_) + || !std::isfinite(pv) || !std::isfinite(initial_capital_) + || !std::isfinite(net_profit_sum_)) { + return false; + } + + // (b) Chronology: the adverse extreme must come STRICTLY earlier on the + // engine's own synthesized OHLC path (bar_path_uses_high_first proximity + // 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. + const double adverse = + (position_side_ == PositionSide::LONG) ? bar.low : bar.high; + if (!std::isfinite(adverse) || !(adverse > 0.0)) return false; + double adverse_pos = 0.0; + double exit_pos = 0.0; + if (!internal::first_touch_position(bar, adverse, &adverse_pos)) { + return false; + } + if (!internal::first_touch_position(bar, exit_fill_price, &exit_pos)) { + return false; + } + if (!(adverse_pos < exit_pos - kPathPosEps)) return false; + + // (c) Pre-fill deficit at the extreme: the same fee-net eq/req + // arithmetic as the adverse cascade (the position state is pre-fill + // because the triggering exit has not been applied yet). + const double fx = active_account_currency_fx(); + if (!std::isfinite(fx) || !(fx > 0.0)) return false; + const double equity_adv = percent_commission_live_equity(adverse); + if (!std::isfinite(equity_adv)) return false; + const double margin_per_unit_adv = adverse * pv * fx * m; + const double req_margin_adv = qty * margin_per_unit_adv; + if (equity_adv >= req_margin_adv) return false; + double q_min = qty - equity_adv / margin_per_unit_adv; + if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return false; + + // Slice quantity: floor-before-4x, representation guards, and the + // floor-zero fallbacks — identical to the cascade (see + // process_margin_call for the fitted evidence on each rule). + const double raw_q_min = q_min; + if (qty_step_ > 0.0) { + double step_count = q_min / qty_step_; + if (margin_zero_cover_full_liquidation_) { + const double nearest_step = std::round(step_count); + if (std::abs(step_count - nearest_step) < 1e-6) { + step_count = nearest_step; + } + } + q_min = std::floor(step_count) * qty_step_; + } + double qty_liq = 4.0 * q_min; + if (qty_step_ > 0.0) { + double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_; + if (floored <= kQtyEpsilon) { + double one_contract_fallback = + std::numeric_limits::quiet_NaN(); + if (qty_step_ <= 1.0 + && raw_q_min > kQtyEpsilon + && raw_q_min < 1.0) { + const double candidate = std::min(1.0, qty); + const bool full_position_cap = + candidate >= qty - kQtyEpsilon; + const double gridded = apply_exit_qty_step(candidate); + const double grid_guard = std::max( + 1e-12, std::abs(candidate) * 1e-12); + if (full_position_cap + || std::abs(gridded - candidate) <= grid_guard) { + one_contract_fallback = candidate; + } + } + if (margin_zero_cover_full_liquidation_) { + floored = qty; + } else if (std::isfinite(one_contract_fallback)) { + floored = one_contract_fallback; + } else { + return false; + } + } + qty_liq = floored; + } + if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty; + if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return false; + + const size_t trades_before = trades_.size(); + if (qty_liq >= qty - kQtyEpsilon) { + execute_market_exit(adverse); + } else { + execute_partial_exit_qty( + adverse, qty_liq, PositionReductionCause::MARGIN_CALL); + } + if (trades_.size() == trades_before) return false; + + ++broker_fill_event_seq_; + for (size_t ti = trades_before; ti < trades_.size(); ++ti) { + trades_[ti].exit_comment = "Margin call"; + trades_[ti].exit_id = "__margin_call__"; + } + last_margin_call_event_bar_ = bar_index_; + intrabar_exit_margin_call_bar_ = bar_index_; + return true; +} + // ──────────────────────────────────────────────────────────────────── // process_pending_orders helpers // ──────────────────────────────────────────────────────────────────── @@ -4979,6 +5158,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( double fill_price = 0.0; bool should_fill = false; bool is_limit_fill = false; + bool exit_path_fill = false; // A valid child that was armed with its pending MARKET parent and whose // stop is already breached — or whose limit is already marketable — at @@ -5103,6 +5283,12 @@ 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; } } else if (!should_fill && (order.type == OrderType::MARKET || (!has_stop && !has_limit && !has_trail))) { @@ -5189,7 +5375,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( } return {should_fill ? FillEvaluation::Kind::Fill : FillEvaluation::Kind::NoFill, - fill_price, is_limit_fill}; + fill_price, is_limit_fill, exit_path_fill}; } } // namespace pineforge diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 134d089..763d085 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -634,6 +634,8 @@ void BacktestEngine::reset_run_state() { intraday_cap_deferred_close_pending_ = false; intraday_cap_pooc_close_inheritor_incarnation_ = 0; broker_fill_event_seq_ = 0; + last_margin_call_event_bar_ = -1; // finding-308: bar-keyed one-shot + intrabar_exit_margin_call_bar_ = -1; // markers must not survive a rerun coof_scheduler_active_ = false; coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; diff --git a/src/ta_misc.cpp b/src/ta_misc.cpp index 6700826..f84387f 100644 --- a/src/ta_misc.cpp +++ b/src/ta_misc.cpp @@ -79,8 +79,12 @@ double Linreg::compute(double src, double offset) { PercentRank::PercentRank(int length) : length_(length) {} double PercentRank::compute(double src) { - // Pine parity: keep one slot per bar (na advances the window). Rank uses only non-na - // values in the lookback; denominator is that count, not length (PineTS ta.percentrank). + // Pine parity: keep one slot per bar (na advances the window). Rank counts only + // non-na values in the lookback, but the denominator is always LENGTH, never the + // non-na count — even in the partial (na-lead) warmup window. TV tapes prove + // ta.percentrank emits count/length there (Lab finding 315; trendmatrix pair + // verified byte-exact 100.0/0/0). Dividing by the valid count (the PineTS model) + // mismatches TV as soon as count > 0. buffer_.push_back(src); while ((int)buffer_.size() > length_ + 1) { buffer_.pop_front(); @@ -109,7 +113,7 @@ double PercentRank::compute(double src) { if (valid == 0) { return na(); } - return ((double)count / (double)valid) * 100.0; + return ((double)count / (double)length_) * 100.0; } // ============================================================================ @@ -258,7 +262,9 @@ double PercentRank::recompute(double src) { if (percentrank_less_equal(v, current)) count++; } if (valid == 0) return na(); - return ((double)count / (double)valid) * 100.0; + // Denominator is LENGTH, not the non-na count — same TV rule as compute() + // (Lab finding 315). + return ((double)count / (double)length_) * 100.0; } // --- BarsSince --- diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 936e952..b55f165 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -106,6 +106,7 @@ set(TEST_SOURCES test_metrics test_drawing test_margin_call + test_margin_call_intrabar_chronology test_percent_equity_open_entry_fee test_streaming test_calc_on_order_fills diff --git a/tests/test_margin_call_intrabar_chronology.cpp b/tests/test_margin_call_intrabar_chronology.cpp new file mode 100644 index 0000000..6ca8c8f --- /dev/null +++ b/tests/test_margin_call_intrabar_chronology.cpp @@ -0,0 +1,329 @@ +/* + * test_margin_call_intrabar_chronology.cpp — finding-308: TV places the + * forced-liquidation event chronologically on the synthesized intrabar path. + * + * When a priced exit of the live position fills on a bar whose adverse + * extreme comes STRICTLY earlier on the engine's own OHLC path + * (bar_path_uses_high_first proximity rule) than the exit's fill, and the + * pre-fill position is already in margin deficit at that extreme, TV slices + * FIRST (floor-before-4x nibble, filled at the extreme, "Margin call" tag) + * and the exit then closes the reduced remainder. Previously the engine + * checked margin once AFTER all order processing, so a same-bar full exit + * hid the deficit (FLAT early-return) and the event was lost. + * + * The fixtures reproduce the rhyme17 derivation's 2025-10-11 20:45 seed + * arithmetic exactly (short 2.5105, adverse high 3721.62, deficit ~8.15, + * q_min 0.0021894... -> floor 0.0021 -> 4x = 0.0084): + * + * A. HIGH-first bar, TP limit fills after the high -> slice 0.0084@high, + * TP closes the remainder 2.5021. (The confirmed gap event.) + * B. LOW-first bar with the SAME large deficit at the high -> the exit + * fills before the extreme on the path -> NO margin call. (The two + * tape bars 2025-06-28 08:15 / 2025-09-17 18:45 that a naive + * check-before-orders would false-fire.) + * C. TIE — the exit stop fills exactly AT the adverse extreme -> exit + * first, NO margin call. (Protects the 157/158 quiet SL-stop bars.) + * D. SL stop strictly BEFORE the extreme -> quiet (same protection). + * E. Partial exit variant: exactly ONE margin-call slice on the bar (the + * end-of-bar cascade is consumed by the chronological slice; the + * survivor is re-checked from the next bar on). + * F. Emulator off -> nothing fires. + * G. Handle reuse: a rerun reproduces the same rows (bar-keyed one-shot + * markers reset with reset_run_state). + */ + +#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; +} + +// A 1x short (the rhyme17 seed's margin regime) opened with explicit qty +// 2.5105 at 3706.26 and carried into the event bar with a resting +// strategy.exit. Chosen so the deficit at the adverse high 3721.62 is +// 8.152... USDT: q_min = 2.5105 - 9334.978672/3721.62 = 0.0021894..., +// floored to 0.0021 at qty_step 0.0001, 4x = the seed's bit-exact 0.0084. +class ChronologyShortProbe : public MCEngine { +public: + enum class ExitKind { TpLimit, SlStop }; + + ChronologyShortProbe(ExitKind kind, double exit_level, + double exit_qty_percent = 100.0, + bool disable_mc = false) + : kind_(kind), exit_level_(exit_level), + exit_qty_percent_(exit_qty_percent) { + initial_capital_ = 9373.54; + 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) { + // Market short queued at the signal bar; fills at bar1 open. + strategy_entry("S", false, kNaN, kNaN, /*qty=*/2.5105); + } else if (bar_index_ == 1) { + // Armed while the position is live; rests for the event bar. + const double limit = + kind_ == ExitKind::TpLimit ? exit_level_ : kNaN; + const double stop = + kind_ == ExitKind::SlStop ? exit_level_ : kNaN; + strategy_exit("X", "S", limit, stop, kNaN, kNaN, kNaN, + exit_qty_percent_, "", kNaN, ""); + } + } + +private: + ExitKind kind_; + double exit_level_; + double exit_qty_percent_; +}; + +static std::vector seed_bars(const Bar& event_bar) { + return { + mk_bar(1000, 3706.26, 3706.26, 3706.26, 3706.26, 1.0), // 0: signal + mk_bar(2000, 3706.26, 3706.26, 3706.26, 3706.26, 1.0), // 1: fill+arm + event_bar, // 2: event + }; +} + +// HIGH-first event bar (|3721.62-3712| = 9.62 < |3712-3660| = 52): the +// path is O -> H -> L -> C, so the adverse high (path position 1.0) comes +// strictly before the TP limit 3664.69 on the H->L leg (position ~1.92). +static Bar high_first_event_bar() { + return mk_bar(3000, 3712.0, 3721.62, 3660.0, 3665.0, 1.0); +} + +// ---- A: the confirmed gap event fires the 0.0084 slice --------------------- + +static void test_gap_event_slices_before_tp_exit() { + std::printf("test_gap_event_slices_before_tp_exit\n"); + std::vector bars = seed_bars(high_first_event_bar()); + + ChronologyShortProbe eng(ChronologyShortProbe::ExitKind::TpLimit, + /*exit_level=*/3664.69); + eng.run(bars.data(), (int)bars.size()); + + // Slice first (0.0084 @ the adverse high), then the TP closes the + // remainder 2.5021 at the unslipped limit. + 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.0084, 1e-9)); + CHECK(near(eng.exit_price(0), 3721.62)); + CHECK(near(eng.entry_price(0), 3706.26)); + CHECK(eng.exit_bar(0) == 2); + CHECK(eng.exit_comment(1) != std::string("Margin call")); + CHECK(near(eng.trade_size(1), 2.5021, 1e-9)); + CHECK(near(eng.exit_price(1), 3664.69)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- B: a LOW-first bar with the same deficit must NOT fire ---------------- + +static void test_low_first_large_deficit_stays_quiet() { + std::printf("test_low_first_large_deficit_stays_quiet\n"); + // LOW-first (|3721.62-3666| = 55.62 > |3666-3660| = 6): path is + // O -> L -> H -> C. The TP fills on the O->L leg (position ~0.22), + // BEFORE the adverse high (position 2.0), even though the deficit at + // the high is the same 8.15. This is the naive check-before-orders + // false-fire shape (tape bars 2025-06-28 08:15 / 2025-09-17 18:45). + std::vector bars = seed_bars( + mk_bar(3000, 3666.0, 3721.62, 3660.0, 3700.0, 1.0)); + + ChronologyShortProbe eng(ChronologyShortProbe::ExitKind::TpLimit, + /*exit_level=*/3664.69); + 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.5105, 1e-9)); + CHECK(near(eng.exit_price(0), 3664.69)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- C: a fill exactly AT the extreme ties -> exit first ------------------- + +static void test_exit_at_extreme_ties_to_exit_first() { + std::printf("test_exit_at_extreme_ties_to_exit_first\n"); + // Exit stop exactly at the adverse high: both first-touch positions are + // 1.0 on the O->H leg. The tie keeps the exit first — no slice. + std::vector bars = seed_bars(high_first_event_bar()); + + ChronologyShortProbe eng(ChronologyShortProbe::ExitKind::SlStop, + /*exit_level=*/3721.62); + 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.5105, 1e-9)); + CHECK(near(eng.exit_price(0), 3721.62)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- D: an SL stop strictly before the extreme stays quiet ----------------- + +static void test_sl_stop_before_extreme_stays_quiet() { + std::printf("test_sl_stop_before_extreme_stays_quiet\n"); + // Stop 3715 fills on the O->H leg at position ~0.31, before the high at + // 1.0 — the 157/158 quiet SL-stop class. + std::vector bars = seed_bars(high_first_event_bar()); + + ChronologyShortProbe eng(ChronologyShortProbe::ExitKind::SlStop, + /*exit_level=*/3715.0); + 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.5105, 1e-9)); + CHECK(near(eng.exit_price(0), 3715.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- E: one forced-liquidation event per bar ------------------------------- + +static void test_partial_exit_single_slice_per_bar() { + std::printf("test_partial_exit_single_slice_per_bar\n"); + // A 1% partial TP leaves a live survivor after the slice + exit. The + // end-of-bar cascade must not book a second same-bar slice (the + // chronological one consumed the bar's event; TV re-checks the survivor + // on the NEXT bar — the seed's own 20:30 -> 20:45 sequence). + std::vector bars = seed_bars(high_first_event_bar()); + bars.push_back(mk_bar(4000, 3665.0, 3665.0, 3665.0, 3665.0, 1.0)); + + ChronologyShortProbe eng(ChronologyShortProbe::ExitKind::TpLimit, + /*exit_level=*/3664.69, + /*exit_qty_percent=*/1.0); + eng.run(bars.data(), (int)bars.size()); + + // On the event bar: exactly one Margin-call slice (0.0084) plus the + // 1%-frozen partial (0.0251). The survivor stays short. + int event_bar_mc_rows = 0; + for (int i = 0; i < eng.trade_count(); ++i) { + if (eng.exit_bar(i) == 2 + && eng.exit_comment(i) == std::string("Margin call")) { + ++event_bar_mc_rows; + } + } + CHECK(event_bar_mc_rows == 1); + CHECK(eng.trade_count() >= 2); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.trade_size(0), 0.0084, 1e-9)); + CHECK(near(eng.exit_price(0), 3721.62)); + CHECK(near(eng.trade_size(1), 0.0251, 1e-9)); + CHECK(near(eng.exit_price(1), 3664.69)); + CHECK(eng.position_size() < 0.0); // survivor carried past the event bar +} + +// ---- F: emulator off -> nothing fires -------------------------------------- + +static void test_disabled_emulator_stays_quiet() { + std::printf("test_disabled_emulator_stays_quiet\n"); + std::vector bars = seed_bars(high_first_event_bar()); + + ChronologyShortProbe eng(ChronologyShortProbe::ExitKind::TpLimit, + /*exit_level=*/3664.69, + /*exit_qty_percent=*/100.0, + /*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.5105, 1e-9)); +} + +// ---- G: handle reuse reproduces the same rows ------------------------------ + +static void test_rerun_reproduces_slice() { + std::printf("test_rerun_reproduces_slice\n"); + std::vector bars = seed_bars(high_first_event_bar()); + + ChronologyShortProbe eng(ChronologyShortProbe::ExitKind::TpLimit, + /*exit_level=*/3664.69); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 2); + CHECK(margin_call_rows(eng) == 1); + + // Rerun on the same handle: bar-keyed one-shot markers must reset. + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 2); + CHECK(margin_call_rows(eng) == 1); + CHECK(near(eng.trade_size(0), 0.0084, 1e-9)); + CHECK(near(eng.exit_price(0), 3721.62)); + CHECK(near(eng.trade_size(1), 2.5021, 1e-9)); +} + +} // namespace + +int main() { + std::printf("=== test_margin_call_intrabar_chronology ===\n"); + + test_gap_event_slices_before_tp_exit(); + test_low_first_large_deficit_stays_quiet(); + test_exit_at_extreme_ties_to_exit_first(); + test_sl_stop_before_extreme_stays_quiet(); + test_partial_exit_single_slice_per_bar(); + test_disabled_emulator_stays_quiet(); + test_rerun_reproduces_slice(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return (tests_failed > 0) ? 1 : 0; +} diff --git a/tests/test_ta.cpp b/tests/test_ta.cpp index a676e5d..223b4e0 100644 --- a/tests/test_ta.cpp +++ b/tests/test_ta.cpp @@ -146,7 +146,8 @@ static int test_percentrank_pine_semantics() { } } { - // Two na in lookback: valid priors 20 and 30; current 40; both <= 40 -> 100% + // Two na in lookback: valid priors 20 and 30; current 40; both <= 40. + // TV divides by LENGTH (4), not the valid count (2) -> 50% (finding 315). ta::PercentRank pr(4); double nanv = std::numeric_limits::quiet_NaN(); double seq[] = {10, 20, nanv, nanv, 30, 40}; @@ -154,8 +155,25 @@ static int test_percentrank_pine_semantics() { for (double v : seq) { last = pr.compute(v); } - if (!near(last, 100.0)) { - std::printf("FAIL percentrank na history: got %g want 100\n", last); + if (!near(last, 50.0)) { + std::printf("FAIL percentrank na history: got %g want 50\n", last); + fails++; + } + } + { + // Pin the partial (na-lead) warmup window rule: the denominator stays LENGTH + // even when only one lookback value is non-na (finding 315). Lookback for the + // final bar is {na, na, na, 10}: count = 1, valid = 1, but TV emits + // 1/4 * 100 = 25 — never 1/1 * 100. + ta::PercentRank pr(4); + double nanv = std::numeric_limits::quiet_NaN(); + double seq[] = {nanv, nanv, nanv, 10, 20}; + double last = na(); + for (double v : seq) { + last = pr.compute(v); + } + if (!near(last, 25.0)) { + std::printf("FAIL percentrank partial-window denominator: got %g want 25\n", last); fails++; } }