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();