From 873e88f8ff021a7f804bb0859379ef8e2729bb2a Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 11 Jul 2026 01:51:13 +0800 Subject: [PATCH] Reject unaffordable frozen all-in flat market entries at fill TradingView's broker DROPS (does not trim) a percent_of_equity=100, omitted-qty strategy.entry MARKET order that is placed true-flat, is still flat at fill, carries zero opening commission, and whose frozen signal-close quantity costs more at the fill price than the sizing equity plus one lot of slack. The engine previously admitted every such fill at the sizing notional ("undeclinable"), opening over-notional positions TV never takes. The rule is direction-symmetric and mutually disjoint with the two existing frozen all-in branches: commissioned overage keeps the entry-bar affordability trim (margin-call row), and sub-lot overage keeps filling and holding (exemption). The placement flag opening_affordability_exemption_candidate is widened to both sides with the direction-appropriate margin term; its long-only affordability consumer re-checks is_long and margin_long, so its semantics are unchanged. Evidence: TV-export census across 76 all-in strategies (~74k reconstructed true-flat entries, zero over-slack admissions; rejected shorts fill at price-FAVORABLE gaps, pinning the discriminator as notional shortfall, not adverse gap sign); exemplar parity TV 0/556 gap admissions vs engine 132/684, all reject-and-cancel with re-entry only on fresh signals. Tests: new test_frozen_flat_gap_reject (short-side REDs, no-trim-rows, dangling-exit, within-slack/pct!=100/gap-down/commissioned guards); three prior unlimited-gap-admission pins repurposed to the rejection; stale "margin is never checked at fill" comments narrowed to their explicit-qty / pct<100 evidence base. Co-Authored-By: Claude Fable 5 --- include/pineforge/engine.hpp | 26 ++- src/engine_fills.cpp | 84 ++++++- src/engine_orders.cpp | 22 +- src/engine_strategy_commands.cpp | 43 ++-- tests/CMakeLists.txt | 1 + tests/test_default_qty_signal_freeze.cpp | 39 ++-- tests/test_frozen_flat_gap_reject.cpp | 280 +++++++++++++++++++++++ tests/test_margin_admission_gate.cpp | 27 ++- tests/test_margin_call.cpp | 26 ++- 9 files changed, 474 insertions(+), 74 deletions(-) create mode 100644 tests/test_frozen_flat_gap_reject.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 84cdedd..d8ee9b5 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -256,7 +256,10 @@ struct PendingOrder { // trade row). The floor in apply_qty_step guarantees // qty*sizing_price*pv*fx <= sizing_equity ONLY for percent-of-equity // sizing with pct <= 100, margin <= 100, and sizing_equity > 0 — under - // that invariant flat opens are undeclinable no matter how the bar gaps. + // that invariant THIS KI-54 gate never declines a flat open no matter how + // the bar gaps. (The narrower percent==100 zero-commission true-flat + // above-lot gap that TV DOES decline on the FILL notional is handled by a + // separate gap-reject carve-out that runs before this admit; see the gate.) // It fails for CASH default sizing (no equity term), for pct > 100, for // margin > 100 (required scales past equity), and on a bankrupt account // (apply_qty_step returns qty UNFLOORED for qty <= 0, so |qty|*price == @@ -272,12 +275,21 @@ 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. + // Direction-neutral placement-time provenance for the two fill-time + // consumers of a frozen 100%-of-equity true-flat MARKET entry. True only + // for a high-level MARKET call (either side) with omitted qty, a frozen + // 100%-of-equity snapshot, direction-appropriate margin == 100, true-flat + // placement, and no earlier paired close in this on_bar. Consumers: + // 1. KI-61 long entry-bar affordability EXEMPTION (engine_fills.cpp): + // long-only — it independently re-checks order.is_long and margin_long + // via long_full_margin_after_fill, so widening this flag to shorts + // leaves the exemption's derivation unchanged. Fill-time code must + // additionally prove true-flat fill, sizing-price admission, success, + // and zero actual opening commission before treating it as exempt. + // 2. gap-reject (design-cntvxiao-gap-reject, engine_fills.cpp): + // direction-symmetric — silently drops the entry at fill when the + // frozen-qty notional at the slipped fill price exceeds sizing_equity + // by more than one lot, given zero actual opening commission. 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 diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 2061749..d6f0bd7 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -825,12 +825,15 @@ void BacktestEngine::apply_filled_order_to_state( // filled earlier this tick): the SIZING notional. For percent-of- // equity with pct <= 100, margin <= 100 and sizing_equity > 0 the // floor in apply_qty_step guarantees - // qty*sizing_price*pv*fx <= sizing_equity, so a flat open is - // undeclinable no matter how the bar gaps. Outside that regime the - // invariant fails and the gate does not run at all. Pricing flat opens at - // the fill was refuted against TV exports: it drops razor-thin + // qty*sizing_price*pv*fx <= sizing_equity, so THIS gate never declines + // a flat open no matter how the bar gaps. Outside that regime the + // invariant fails and the gate does not run at all. Pricing flat opens + // at the fill HERE was refuted against TV exports: it drops razor-thin // gap-up entries that exact-count close-then-reenter strategies - // demonstrably take. + // demonstrably take. (The one true-flat open TV DOES decline on the + // FILL notional — a percent==100, zero-commission, above-lot gap — is + // handled by the disjoint gap-reject carve-out above, which fires + // before this admit; every OTHER flat open remains undeclinable here.) // - TRUE REVERSAL (opposite position still open when the order // processes): the FILL price, slipped the way the fill kernel // will book it. Established independently by two from-the-feed @@ -883,6 +886,77 @@ void BacktestEngine::apply_filled_order_to_state( bool reversal = position_side_ != PositionSide::FLAT && !same_dir; bool raw_opposite_close = order.type == OrderType::RAW_ORDER && reversal; double margin_pct = order.is_long ? margin_long_ : margin_short_; + // Gap-reject (design-cntvxiao-gap-reject, PANEL-CLEARED): a high-level + // strategy.entry with omitted qty, sized percent_of_equity at EXACTLY + // 100%, direction-appropriate margin == 100, placed TRUE-FLAT and still + // FLAT at THIS fill, carrying zero actual opening commission, is + // silently DROPPED (no trade row) when its frozen-qty cost at the + // SLIPPED FILL price exceeds the sizing-equity snapshot by more than one + // lot of slack: + // + // |frozen_default_qty| * slipped_fill * pv * fx * margin/100 + // > sizing_equity + // + qty_step_ * slipped_fill * pv * fx * margin/100 + // + max(1e-9, |sizing_equity|*1e-12) + // + // This is the third, mutually-disjoint branch of the frozen-100% + // all-in true-flat family. It runs BEFORE the KI-54 flat admit below — + // which prices flat opens at the SIZING notional (undeclinable by the + // floor invariant) and would let this fill through: + // - above-lot gap, ZERO opening fee -> REJECT here (this rule) + // - above-lot gap, NONZERO opening fee -> fill, KI-61 entry-bar trim + // - within one lot of slack -> fill, held (KI-61-exempt) + // Evidence: cntvxiao TV 0/556 positive-shortfall gap admissions across + // BOTH sides (70 short / 62 long); rejected shorts open at a + // FAVORABLE price, so the reproducing discriminator is NOTIONAL over- + // equity, not adverse gap sign. All provenance rides on the + // direction-neutral opening_affordability_exemption_candidate flag (set + // at placement, engine_strategy_commands.cpp): it already encodes + // created-true-flat, percent_of_equity==100, direction-appropriate + // margin==100, and finite frozen snapshot. margin_pct is that same + // direction margin (== 100 under the flag, so margin/100 == 1); it is + // retained on both sides for parity with the KI-54 formula and the + // shurben5 margin!=100 controls. The !same_dir/!reversal/type==MARKET + // guards are defensively redundant (FLAT-at-fill implies both + // classifications false, and the candidate flag is only ever set on a + // default-sized high-level MARKET entry) but pin the intent cheaply. + // order.qty is NOT written here (isnan(order.qty) is a live + // discriminator for OCA / reversal-binding / partial-exit). + // + // Scope carve-outs (deliberate, each pending its own TV probe): + // - RAW_ORDER (strategy.order) carries the same frozen snapshot and + // is covered by the KI-54 flat/add/reversal gate, but NOT by this + // reject: its default-sized gap behavior is not yet TV-pinned, so + // the asymmetry is intentional. It never reaches here — the + // candidate flag is only set for high-level strategy.entry, and the + // type==MARKET guard excludes RAW regardless. + // - process_orders_on_close: the signal bar IS the fill bar, so + // slipped_fill == sizing_price and the frozen qty was floored to + // fit sizing_equity — the shortfall is structurally 0 (no-op). + if (order.opening_affordability_exemption_candidate + && position_side_ == PositionSide::FLAT + && !same_dir && !reversal + && order.type == OrderType::MARKET) { + const double slipped_fill = + apply_fill_slippage(fill_price, order.is_long); + if (calc_commission(slipped_fill, order.frozen_default_qty) == 0.0) { + const double gap_notional = std::abs(order.frozen_default_qty) + * slipped_fill * syminfo_.pointvalue + * account_currency_fx_ + * (margin_pct / 100.0); + const double one_lot_slack = qty_step_ * slipped_fill + * syminfo_.pointvalue + * account_currency_fx_ + * (margin_pct / 100.0); + const double float_guard = + std::max(1e-9, std::abs(order.sizing_equity) * 1e-12); + if (gap_notional + > order.sizing_equity + one_lot_slack + float_guard) { + filled_indices.push_back(order_index); + return; + } + } + } // A same-direction add (fractional OR all-in) IS gated, against // MARK-TO-MARKET free margin. This is pinned by a clean-room TV probe // (data/probes/margin-basis-frac: pct=50, pyramiding=2). At pct=50 the diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 1a77352..0f32008 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -651,15 +651,19 @@ void BacktestEngine::enter_market_from_flat(const std::string& id, bool is_long, if (tv_deferred_flip) { consume_tv_carry_from_siblings(id, created_position_side, created_bar); } - // NOTE: margin check is performed at SIGNAL time inside - // strategy_entry / queue_deferred_close_order, NOT here at fill - // time. This matches TV's broker emulator, which rejects entries - // whose qty * SIGNAL_BAR_CLOSE exceeds equity. By the time we - // reach this fill-side helper the order has already been admitted - // (or rejected) at signal time, and the next-bar slippage between - // signal close and fill open should NOT be allowed to flip a TV- - // accepted entry into a reject. See parity-probe-{03..06} + - // ies-probe-08 for the empirical justification. + // NOTE: for EXPLICIT-qty market entries the margin check is performed at + // SIGNAL time inside strategy_entry / queue_deferred_close_order, NOT here + // at fill time. This matches TV's broker emulator, which rejects entries + // whose qty * SIGNAL_BAR_CLOSE exceeds equity. By the time we reach this + // fill-side helper such an order has already been admitted (or rejected) + // at signal time, and the next-bar slippage between signal close and fill + // open should NOT flip a TV-accepted entry into a reject. The empirical + // base — parity-probe-{03..06} + ies-probe-08 — is entirely explicit-qty / + // pct<100 / headroom sizing, so the claim is scoped to it. The one FROZEN + // default-sized carve-out that TV DOES re-check and drop at fill (a + // percent==100, zero-commission, true-flat above-lot gap) is handled by + // the gap-reject gate in apply_filled_order_to_state, upstream of this + // helper — a dropped order never reaches enter_market_from_flat. PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT; open_fresh_position(requested, fill_price, qty, id); } diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index ea12853..b844067 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -117,20 +117,25 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, // pre-fix engine rejected those at fill time while TV accepted // them at signal time — accumulating into community/IES's PnL // drift. Verified empirically by parity-probe-04..06 (all 57/57 - // matched) and parity-anomalies/equity-mirror (full-equity sizing - // right at the 1× boundary, where TV's behaviour is itself - // non-deterministic — see corpus/parity-anomalies/README.md). + // matched) and the equity-mirror anomaly (full-equity sizing right at the + // 1× boundary, where TV's behaviour is itself non-deterministic — see + // corpus/validation/anomaly-equity-mirror-strategy-equity-01). // Only applied to MARKET entries (limit/stop entries have their // own price baked into the order itself). // // Scope: this signal-time gate covers EXPLICIT-qty market entries only // (the empirical base above — parity-probe-04..06 — is all explicit - // ``qty = `` sizing). DEFAULT-sized (qty=na) market entries never - // reach it (qty is NaN here); their quantity is frozen at this bar's - // close further below (see frozen_default_market_qty), AFTER this gate, - // precisely so the freeze cannot accidentally activate a gate whose - // equity basis (realized-only current_equity()) was never validated - // for default sizing. + // ``qty = `` sizing, all with headroom at the boundary). DEFAULT- + // sized (qty=na) market entries never reach it (qty is NaN here); their + // quantity is frozen at this bar's close further below (see + // frozen_default_market_qty), AFTER this gate, precisely so the freeze + // cannot accidentally activate a gate whose equity basis (realized-only + // current_equity()) was never validated for default sizing. Those frozen + // default-sized entries instead get their own, much narrower fill-time + // re-check: the percent==100 zero-commission true-flat above-lot gap-reject + // in apply_filled_order_to_state (design-cntvxiao-gap-reject). It does NOT + // contradict the explicit-qty "signal-time only" rule above — different + // sizing regime, different TV ground truth. if (!std::isnan(qty) && std::isnan(limit_price) && std::isnan(stop_price)) { double margin_pct = is_long ? margin_long_ : margin_short_; if (margin_pct > 0.0 && !std::isnan(current_bar_.close)) { @@ -250,14 +255,26 @@ 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; + // Direction-neutral: two fill-time consumers read this flag. + // 1. KI-61 long entry-bar affordability trim + // (engine_fills.cpp): re-checks order.is_long and margin_long + // via long_full_margin_after_fill, so its long-only semantics + // are invariant to widening this to shorts. + // 2. gap-reject (design-cntvxiao-gap-reject, engine_fills.cpp): + // direction-symmetric — drops a true-flat all-in zero-comm + // entry whose gapped fill notional exceeds equity by >1 lot, + // on EITHER side. + // The margin term is the direction-appropriate one so a short at + // margin_short==100 qualifies exactly as a long at margin_long==100. + const double affordability_margin = + is_long ? margin_long_ : margin_short_; order.opening_affordability_exemption_candidate = - is_long - && order.created_position_side == PositionSide::FLAT + 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(affordability_margin) + && std::abs(affordability_margin / 100.0 - 1.0) < 1e-12 && std::isfinite(order.frozen_default_qty) && std::isfinite(order.sizing_equity) && std::isfinite(order.sizing_price) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f058d95..1addd6e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -65,6 +65,7 @@ set(TEST_SOURCES test_fills_edge test_default_qty_signal_freeze test_margin_admission_gate + test_frozen_flat_gap_reject test_limit_fill_slippage test_strategy_commands_extra test_multi_tier_exit_precedence diff --git a/tests/test_default_qty_signal_freeze.cpp b/tests/test_default_qty_signal_freeze.cpp index 697aa8c..bbfd118 100644 --- a/tests/test_default_qty_signal_freeze.cpp +++ b/tests/test_default_qty_signal_freeze.cpp @@ -13,11 +13,12 @@ * excludes all three. * B. Flat entry with a close→open gap DOWN: qty = equity_S / close(S), not * equity / open(S+1) — pins the divisor with no position in play. - * C. Flat entry with a close→open gap UP: still ADMITTED, and the qty stays - * the frozen equity_S / close(S) — a gap must neither re-size nor - * decline a flat, fully-affordable entry (the FLOOR in apply_qty_step - * guarantees qty*close(S) <= equity_S; the fill price plays no role in - * sizing). + * C. Flat all-in (pct=100) zero-commission entry with a close→open gap UP + * whose frozen-qty notional exceeds the sizing equity by more than one + * lot is REJECTED at fill (design-cntvxiao-gap-reject): the frozen qty is + * never re-sized, and the whole true-flat entry is silently dropped. The + * freeze itself still never RE-SIZES on a gap — pin B (gap DOWN) and the + * affordable cases keep filling the frozen equity_S / close(S) lot. * D. process_orders_on_close=true: signal bar == fill bar and fill price == * close(S), so the frozen qty is identical to the legacy fill-time * computation — POC sizing is unchanged. @@ -177,16 +178,16 @@ void test_flat_gap_down_divisor() { } } -// C. Flat entry, gap UP: still admitted, qty stays frozen at equity_S / -// close(S). TradingView demonstrably takes flat all-in entries on gap-up -// bars (the FLOOR guarantees qty*close(S) <= equity_S, and TV's admission -// is based on the sizing notional, not the fill price) — a close→open gap -// must never decline or re-size a flat entry. +// C. Flat all-in (pct=100) zero-comm entry, gap UP: REJECTED at fill. The +// frozen qty 10000/100 = 100 stays frozen (no re-size), but the fill +// notional 100*102 = 10200 exceeds the 10000 sizing equity by more than +// one lot (qty_step 0), so TV silently drops the entry +// (design-cntvxiao-gap-reject) and the later close_all has nothing to +// close. (Pre-gap-reject this filled 100 @ 102.) // bar0 100/100/100/100 on_bar: long entry — frozen qty 10000/100 = 100 -// bar1 102/103/101/102 fills @102 with qty 100 (legacy fill-time sizing -// would give 10000/102 = 98.04...) -void test_flat_gap_up_admitted() { - std::printf("-- C: flat entry on a gap up stays admitted, qty frozen --\n"); +// bar1 102/103/101/102 fill 102 -> 100*102 = 10200 > 10000 -> DROP +void test_flat_gap_up_rejected() { + std::printf("-- C: flat all-in zero-comm gap-up rejected --\n"); Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, /*poc=*/false); eng.script = "L.C."; std::vector bars = { @@ -196,12 +197,8 @@ void test_flat_gap_up_admitted() { mk_bar(4000, 102, 102, 102, 102), }; eng.run(bars.data(), (int)bars.size()); - CHECK(eng.trade_count() == 1); - if (eng.trade_count() == 1) { - const Trade& t0 = eng.all_trades()[0]; - CHECK_NEAR(t0.entry_price, 102.0, 1e-9); - CHECK_NEAR(t0.qty, 100.0, 1e-9); - } + CHECK(eng.trade_count() == 0); // was 1 (filled 100@102); close_all inert + CHECK(eng.position_side_ == PositionSide::FLAT); } // D. process_orders_on_close=true: unchanged. Signal bar == fill bar, fill @@ -364,7 +361,7 @@ int main() { std::printf("--- default_qty_signal_freeze ---\n"); test_reversal_freeze(); test_flat_gap_down_divisor(); - test_flat_gap_up_admitted(); + test_flat_gap_up_rejected(); test_poc_unchanged(); test_cash_freeze(); test_oca_default_sibling_cancelled(); diff --git a/tests/test_frozen_flat_gap_reject.cpp b/tests/test_frozen_flat_gap_reject.cpp new file mode 100644 index 0000000..a977550 --- /dev/null +++ b/tests/test_frozen_flat_gap_reject.cpp @@ -0,0 +1,280 @@ +/* + * test_frozen_flat_gap_reject.cpp — TradingView's fill-time REJECTION of a + * frozen 100%-of-equity true-flat MARKET entry whose gapped fill price pushes + * the frozen-quantity notional more than one lot past the sizing equity. + * + * Rule (design-cntvxiao-gap-reject, PANEL-CLEARED): a pending MARKET entry + * created by high-level strategy.entry with omitted qty (frozen default sizing, + * percent_of_equity == 100%), direction-appropriate margin == 100, placed + * TRUE-FLAT (created flat, not a same-bar paired close/reentry) and still FLAT + * at fill, carrying ZERO opening commission, is silently dropped (no trade row) + * at fill when: + * + * |frozen_default_qty| * slipped_fill * pv * fx * margin/100 + * > sizing_equity + * + qty_step_ * slipped_fill * pv * fx * margin/100 + * + max(1e-9, |sizing_equity| * 1e-12) + * + * Direction-symmetric (long AND short). Within-one-lot (sub-lot) shortfalls + * still FILL; commissioned or pct<100 or gap-DOWN entries are untouched (they + * keep the KI-61 fill-then-trim / hold path). See the gate in + * engine_fills.cpp apply_filled_order_to_state for the evidence trail. + * + * RED-4 SHORT true-flat zero-comm above-lot gap -> rejected (FLAT, no rows). + * RED-6 rejected SHORT emits NO rows AT ALL, incl. the entry-bar margin-call + * trim rows the pre-fix engine produced. + * GREEN-A within-one-lot gap-up (qty_step>0) STILL FILLS. + * GREEN-B strategy.exit bracket bound to a flat-dropped entry id is inert + * (no phantom exit fill, no crash). + * GREEN-C commissioned twin: fills then takes the KI-61 4-lot Margin-call trim. + * GREEN-D pct=99 twin: fills (rule requires EXACTLY 100). + * GREEN-E gap-DOWN true-flat: fills with the frozen qty. + */ + +#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) + +#define CHECK_NEAR(a, b, tol) \ + do { \ + double _a = (a), _b = (b); \ + if (!(std::fabs(_a - _b) <= (tol))) { \ + std::printf(" FAIL %s:%d %s == %.10f, expected %.10f\n", \ + __FILE__, __LINE__, #a, _a, _b); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk_bar(int64_t ts, double o, double h, double l, double c) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1.0; b.timestamp = ts; + return b; +} + +namespace { + +// Scripted probe. All prices on-tick (mintick 0.01) so the zero-slippage +// directional snap is an identity and fills land exactly at the bar prices. +class Probe : public BacktestEngine { +public: + Probe(double pct, double capital, double qty_step, + double commission_pct, bool enable_mc) { + initial_capital_ = capital; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = pct; + commission_type_ = CommissionType::PERCENT; + commission_value_ = commission_pct; + margin_long_ = 100.0; + margin_short_ = 100.0; + qty_step_ = qty_step; + process_orders_on_close_ = false; + margin_call_enabled_ = enable_mc; + } + // 'L' = default long entry, 'S' = default short entry, + // 'X' = default long entry + a protective strategy.exit bracket bound to + // it (stop below entry), '.' = nothing. + std::string script; + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ < 0 || bar_index_ >= (int)script.size()) return; + switch (script[bar_index_]) { + case 'L': strategy_entry("L", true); break; + case 'S': strategy_entry("S", false); break; + case 'X': + strategy_entry("L", true); + strategy_exit("LX", "L", kNaN, /*stop_price=*/80.0); + break; + default: break; + } + } + using BacktestEngine::position_qty_; + using BacktestEngine::position_side_; + double position_size() const { return signed_position_size(); } + std::string exit_comment(int i) const { + return closed_trade_exit_comment(i); + } + const std::vector& all_trades() const { return trades_; } +}; + +// RED-4. SHORT true-flat, zero commission, gap ABOVE the signal close (open +// 102 > close 100). For a short that price is FAVORABLE, but the frozen-qty +// notional 100*102 = 10200 exceeds equity 10000 by far more than one lot +// (qty_step 0 -> only the float guard). margin_short_ == 100. The entry is +// silently dropped; margin calls are disabled so the pre-fix engine would +// simply HOLD the 100-lot short here. +void test_short_true_flat_above_lot_gap_rejected() { + std::printf("-- RED-4: short true-flat above-lot gap rejected --\n"); + Probe eng(/*pct=*/100.0, /*capital=*/10000.0, /*qty_step=*/0.0, + /*commission_pct=*/0.0, /*enable_mc=*/false); + eng.script = "S.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // S placed: frozen 100, eq 10000 + mk_bar(2000, 102, 103, 101, 102), // gap up: 100*102 = 10200 -> DROP + mk_bar(3000, 102, 102, 102, 102), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::FLAT); // pre-fix: SHORT + CHECK_NEAR(eng.position_size(), 0.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// RED-6. The rejection must emit NO rows AT ALL — including the entry-bar +// "Margin call" trim rows the pre-fix engine produced. Margin calls ENABLED: +// pre-fix the short fills 10@120 and the finite-price cascade trims it on the +// entry bar (high 125), emitting a Margin-call row. Post-fix the entry is +// dropped before the fill, so process_margin_call sees FLAT and does nothing. +void test_rejected_short_emits_no_margin_call_rows() { + std::printf("-- RED-6: rejected short emits no rows incl. margin trim --\n"); + Probe eng(/*pct=*/100.0, /*capital=*/1000.0, /*qty_step=*/1.0, + /*commission_pct=*/0.0, /*enable_mc=*/true); + eng.script = "S."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // S placed: frozen 10, eq 1000 + mk_bar(2000, 120, 125, 80, 110), // gap up: 10*120 = 1200 > 1120 DROP + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 0); // pre-fix: 1 margin call + CHECK(eng.position_side_ == PositionSide::FLAT); + CHECK_NEAR(eng.position_size(), 0.0, 1e-9); +} + +// GREEN-A (was RED-5). Within-one-lot gap-up STILL FILLS. qty_step 1 leaves a +// real lot of slack: frozen 100 @ close 100; fill 100.5 -> notional 10050, a +// shortfall of 50 over equity 10000, but one lot is qty_step*fill = 100.5, so +// the shortfall is inside the slack. The entry must be admitted (and held; the +// zero-comm true-flat all-in fill is KI-61-exempt from the affordability trim). +void test_within_one_lot_gap_up_fills() { + std::printf("-- GREEN-A: within-one-lot gap-up fills --\n"); + Probe eng(/*pct=*/100.0, /*capital=*/10000.0, /*qty_step=*/1.0, + /*commission_pct=*/0.0, /*enable_mc=*/true); + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // frozen floor(100)=100 + mk_bar(2000, 100.5, 101, 100.5, 100.5), // shortfall 50 < lot 100.5 + mk_bar(3000, 100.5, 100.5, 100.5, 100.5), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// GREEN-B. Dangling-exit safety. A strategy.exit bracket ("LX", from_entry +// "L", protective stop 80) is armed on the same bar as the default long "L". +// The long is flat-dropped by the gap-reject rule, so its bracket is bound to +// an id that never opened. A later drop through 80 must NOT manufacture a +// phantom exit fill or crash: the exit legs are inert. +void test_dangling_exit_bracket_is_inert() { + std::printf("-- GREEN-B: dangling exit bracket on a flat-dropped entry --\n"); + Probe eng(/*pct=*/100.0, /*capital=*/10000.0, /*qty_step=*/0.0, + /*commission_pct=*/0.0, /*enable_mc=*/false); + eng.script = "X.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // L + LX(stop 80) armed + mk_bar(2000, 102, 103, 101, 102), // L gap-up -> DROPPED + mk_bar(3000, 79, 79, 79, 79), // through stop 80 -> nothing to hit + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::FLAT); + CHECK_NEAR(eng.position_size(), 0.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// GREEN-C. Commissioned twin of the zero-comm all-in reject. commission 10% +// makes calc_commission(fill, frozen_qty) != 0, so the gap-reject rule does +// NOT apply. Signal sizing reserves the fee: floor(1000/1.1/100) = 9. At the +// 120 fill the position + 108 fee is unaffordable, so the KI-61 entry-bar +// affordability trim fires (restore 1.566 -> floor 1 -> 4x = 4), leaving 5. +// Mirrors test_commissioned_frozen_all_in_true_flat_gap_is_eligible. +void test_commissioned_all_in_gap_fills_then_trims() { + std::printf("-- GREEN-C: commissioned all-in gap fills then trims --\n"); + Probe eng(/*pct=*/100.0, /*capital=*/1000.0, /*qty_step=*/1.0, + /*commission_pct=*/10.0, /*enable_mc=*/true); + eng.script = "L."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // frozen floor(1000/1.1/100)=9 + mk_bar(2000, 120, 125, 80, 110), // fills 9@120, then 4-lot trim + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK_NEAR(eng.position_size(), 5.0, 1e-9); +} + +// GREEN-D. pct=99 twin of RED-3's arithmetic — the flag is set ONLY at exactly +// 100%, so pct=99 is never gap-rejected. frozen floor(1000*0.99/100)=9; the +// 120 fill is over budget but the restore floors sub-lot (qty_step 1), so no +// trim: the position simply holds 9. +void test_pct99_twin_fills() { + std::printf("-- GREEN-D: pct=99 twin fills (rule requires exactly 100) --\n"); + Probe eng(/*pct=*/99.0, /*capital=*/1000.0, /*qty_step=*/1.0, + /*commission_pct=*/0.0, /*enable_mc=*/true); + eng.script = "L."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // frozen floor(9.9)=9 + mk_bar(2000, 120, 125, 80, 110), // over budget but restore sub-lot + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 9.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// GREEN-E. Gap-DOWN true-flat all-in: notional 100*98 = 9800 < equity 10000, +// so the rule never fires — the entry fills with the frozen qty (the divisor +// is close(S), not the lower fill price). +void test_gap_down_true_flat_fills() { + std::printf("-- GREEN-E: gap-down true-flat fills with frozen qty --\n"); + Probe eng(/*pct=*/100.0, /*capital=*/10000.0, /*qty_step=*/0.0, + /*commission_pct=*/0.0, /*enable_mc=*/false); + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // frozen 100 + mk_bar(2000, 98, 98, 98, 98), // gap down: 100*98 = 9800 <= 10000 + mk_bar(3000, 98, 98, 98, 98), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +} // namespace + +int main() { + std::printf("--- frozen_flat_gap_reject ---\n"); + test_short_true_flat_above_lot_gap_rejected(); + test_rejected_short_emits_no_margin_call_rows(); + test_within_one_lot_gap_up_fills(); + test_dangling_exit_bracket_is_inert(); + test_commissioned_all_in_gap_fills_then_trims(); + test_pct99_twin_fills(); + test_gap_down_true_flat_fills(); + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_margin_admission_gate.cpp b/tests/test_margin_admission_gate.cpp index 9cf1947..6493c7e 100644 --- a/tests/test_margin_admission_gate.cpp +++ b/tests/test_margin_admission_gate.cpp @@ -11,9 +11,12 @@ * scope: percent_of_equity default sizing with pct <= 100 ONLY * * Pins (see the gate comment in engine_fills.cpp for the evidence trail): - * A. Flat open on a gap-UP bar is ADMITTED — flat opens price at the - * SIZING notional, which the floor guarantees affordable; the gap - * never enters the comparison. + * A. Flat all-in (pct=100) zero-commission open on a gap-UP bar is REJECTED + * when the frozen-qty notional at the fill exceeds the sizing equity by + * more than one lot (design-cntvxiao-gap-reject). Flat opens still price + * the KI-54 add/reversal gate at the SIZING notional, but this narrower + * true-flat zero-comm all-in carve-out re-checks the FILL notional and + * silently drops the entry. * B. Same-direction add at pct=100 is DECLINED — the held position keeps * its capital committed, free_funds ~= 0. (pyramiding=2, so the * decline comes from the margin gate, not the pyramiding limit — @@ -137,19 +140,23 @@ static void run_constant_100_script(Probe& eng, const std::string& script) { eng.run(bars.data(), static_cast(bars.size())); } -// A. Flat open, gap UP: admitted, qty stays frozen (10000/100 = 100). -void test_flat_gap_up_admitted() { - std::printf("-- A: flat open on gap-up bar admitted --\n"); +// A. Flat all-in (pct=100) zero-comm open, gap UP: REJECTED. Frozen qty +// 10000/100 = 100; the fill notional 100*102 = 10200 exceeds the 10000 +// sizing equity by $200, far past the one-lot slack (qty_step 0 -> only the +// float guard). The entry is silently dropped and the account stays flat. +// (Pre-gap-reject this admitted and opened LONG 100 on the frozen notional.) +void test_flat_gap_up_rejected() { + std::printf("-- A: flat all-in zero-comm gap-up rejected --\n"); Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); eng.script = "L.."; std::vector bars = { mk_bar(1000, 100, 100, 100, 100), - mk_bar(2000, 102, 103, 101, 102), // gap up: fill 102 > close(S) 100 + mk_bar(2000, 102, 103, 101, 102), // gap up: 100*102 = 10200 > 10000 mk_bar(3000, 102, 102, 102, 102), }; eng.run(bars.data(), (int)bars.size()); - CHECK(eng.position_side_ == PositionSide::LONG); - CHECK_NEAR(eng.position_qty_, 100.0, 1e-9); + CHECK(eng.position_side_ == PositionSide::FLAT); // was LONG 100 + CHECK(eng.trade_count() == 0); // no trade row } // B. Same-direction add at pct=100: DECLINED (free_funds ~= 0). @@ -572,7 +579,7 @@ void test_same_side_role_change_slippage_basis() { int main() { std::printf("--- margin_admission_gate ---\n"); - test_flat_gap_up_admitted(); + test_flat_gap_up_rejected(); test_all_in_same_dir_add_declined(); test_fractional_same_dir_add_admitted(); test_reversal_declined_on_adverse_gap(); diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index 782617b..0515678 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -273,10 +273,12 @@ static void test_long_100pct_margin_no_call() { CHECK(std::isnan(eng.liq_price())); } -// 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. +// A default 100%-of-equity MARKET order placed and filled from true flat. +// A ZERO-commission fill is admitted only within one lot of the frozen sizing +// notional: a larger gap-up is REJECTED at fill and silently dropped +// (design-cntvxiao-gap-reject). A COMMISSIONED fill is never gap-rejected (its +// opening fee is nonzero) and instead keeps the KI-61 fill-then-entry-bar +// affordability-trim path. class FrozenAllInFlatLongProbe : public MCEngine { public: explicit FrozenAllInFlatLongProbe(double commission_percent) { @@ -295,8 +297,14 @@ class FrozenAllInFlatLongProbe : public MCEngine { } }; -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"); +// Zero commission + an above-lot gap-up: the frozen 10-lot notional at the +// 120 fill (1200) exceeds the 1000 sizing equity by more than one lot (one lot +// = qty_step*fill = 120), so TV REJECTS the entry at fill and it is silently +// dropped — the account stays FLAT, no trade row. (Pre-gap-reject the engine +// HELD the 10-lot fill exempt from the affordability trim; the rejection is +// design-cntvxiao-gap-reject.) +static void test_zero_cost_frozen_all_in_true_flat_gap_is_rejected() { + std::printf("test_zero_cost_frozen_all_in_true_flat_gap_is_rejected\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), @@ -305,8 +313,8 @@ static void test_zero_cost_frozen_all_in_true_flat_gap_is_exempt() { 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())); + CHECK(near(eng.position_size(), 0.0)); // was 10 (held); now dropped + CHECK(eng.position_size() == 0.0); } static void test_commissioned_frozen_all_in_true_flat_gap_is_eligible() { @@ -1099,7 +1107,7 @@ int main() { test_margin_liquidation_price_formula(); test_short_margin_call_disabled(); test_long_100pct_margin_no_call(); - test_zero_cost_frozen_all_in_true_flat_gap_is_exempt(); + test_zero_cost_frozen_all_in_true_flat_gap_is_rejected(); 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();