From d3ef7c8ed25f2aba27c76799536cc3540ed6b535 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 11 Jul 2026 08:29:06 +0800 Subject: [PATCH] Decline unaffordable explicit-qty flat market entries at fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TradingView re-checks an explicit-qty MARKET entry at its fill: an entry placed true-flat whose quantity notional at the slipped next-open fill exceeds the placement-time equity is declined outright, with ZERO slack — commission plays no part in the predicate (fee-only overage is admitted and entry-bar-trimmed as before). The engine previously admitted every explicit entry that passed the signal-time gate, so all-in idioms (qty = equity/close, floored to step) opened phantom positions on every adverse one-tick gap. The signal-time gate is unchanged and remains the first line of defense; the new fill-time re-check is a disjoint branch scoped to explicit-qty (non-NaN) MARKET entries created flat with no same-bar paired close, margin-scaled, with the slipped-signal-close notional as a floor so process_orders_on_close and no-gap fills are structural no-ops. The frozen-default path (its own admission rules, one-lot slack) is intentionally not unified — its slack is pinned separately. Evidence: new clean-room probe (floor-quantized all-in, zero commission, 4,740 from-flat attempts across 13 months): TV admits 0 above-slack and 0 in-band overshoots vs 566 in-band rejects — decline iff fill notional exceeds equity, 99.94% concordance; exemplar strategy shows perfect 306/306 admit/decline separation and converges from moderate to excellent (pnl p90 22.65% -> 0.12%). Four legacy synthetic fixtures that asserted fill-then-trim/hold for this exact class are repurposed to the TV-pinned decline; the trim semantics they exercised remain pinned by the frozen-path and default-sized suites. Co-Authored-By: Claude Fable 5 --- include/pineforge/engine.hpp | 30 ++ src/engine_fills.cpp | 59 +++ src/engine_strategy_commands.cpp | 32 ++ tests/CMakeLists.txt | 1 + tests/test_explicit_qty_fill_admission.cpp | 436 +++++++++++++++++++++ tests/test_margin_call.cpp | 112 +++--- 6 files changed, 620 insertions(+), 50 deletions(-) create mode 100644 tests/test_explicit_qty_fill_admission.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 72afec9..9392937 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -304,6 +304,36 @@ struct PendingOrder { // 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; + // design-explicit-qty-fill-admission: fill-time TV admission re-check for an + // EXPLICIT-qty (the caller passed a finite qty) true-flat MARKET entry — the + // explicit-qty sibling of the frozen gap-reject above, which the shipped + // frozen fix deliberately left alone. Set at PLACEMENT in strategy_entry's + // explicit-qty MARKET branch (the branch that does NOT freeze default + // sizing). True only for a high-level MARKET strategy.entry with a finite + // explicit qty, created TRUE-FLAT (created_position_side==FLAT && + // !created_after_position_close_in_bar), direction-appropriate margin_pct>0, + // and finite snapshots. Fill-time consumer: the disjoint explicit-qty + // decline branch in apply_filled_order_to_state silently drops the entry (no + // trade row) when, at a still-FLAT fill, its notional at the SLIPPED FILL + // price overshoots the placement equity snapshot with zero structural slack. + // Commission is EXCLUDED from that predicate. Priced (limit/stop) entries + // carry no snapshot (type==ENTRY, not MARKET); RAW strategy.order never sets + // the flag. Evidence: probe-68 (data/probes/pf-probe-allin-floor-comm0, + // 4,740 from-flat attempts, decline iff fill notional > equity, zero slack, + // 99.94%); mdfe3757 306/306. + bool explicit_flat_admission_candidate = false; + // Placement-time equity snapshot (account ccy) for the explicit-qty gate: + // current_equity() + open_profit(close(S)) == realized equity when flat + // Captured at the explicit-qty MARKET placement point. NaN = no snapshot. + double explicit_placement_equity = std::numeric_limits::quiet_NaN(); + // Slipped signal close at placement (frozen_sizing_price convention: + // close(S) + slippage*mintick*(+1 buy / -1 sell)). Its |qty|-scaled notional + // floors the fill-time decline threshold, so a fill AT/BELOW the slipped + // signal close — POOC (fill == close(S)+slip both sides), a no-gap open, or a + // favorable gap — is a structural no-op even with slippage != 0; only an + // ADVERSE gap beyond the slip can decline. NaN = no snapshot. + double explicit_slipped_signal_close = + std::numeric_limits::quiet_NaN(); std::string comment; // order comment for trade reporting bool requested_partial = false; // true iff caller passed qty_percent < 100 bool created_while_in_position = false; // true if position was open when order was placed diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 8eac397..7fdc4a9 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1070,6 +1070,65 @@ void BacktestEngine::apply_filled_order_to_state( } } + // design-explicit-qty-fill-admission: TV declines an EXPLICIT-qty (caller + // passed a finite qty) true-flat MARKET entry at fill when its notional at + // the SLIPPED FILL price overshoots the placement equity snapshot. This is + // the explicit-qty sibling of the frozen gap-reject family above (those all + // require isnan(qty) via the frozen snapshot / candidate flag); the shipped + // frozen fix deliberately left the explicit path alone. + // + // |qty| * slipped_fill * pv * fx * (margin_pct/100) + // > max(placement_equity, + // |qty| * slipped_signal_close * pv * fx * margin/100) + // + max(1e-9, |placement_equity| * 1e-12) (float guard only) + // + // The slipped-signal-close notional floors the threshold: a fill AT/BELOW + // the slipped signal close (POOC — fill == close(S)+slip on both sides — a + // no-gap open, or a favorable gap) can never decline, so the fill-time gate + // never re-declines what the signal-time gate already admitted; only an + // ADVERSE gap beyond the slip declines. With slippage 0 the floor collapses + // to the pure "fill notional > equity" rule the census pins (probe-68 comm=0 + // decline-iff-notional>equity, zero slack, 99.94%; mdfe3757 306/306). + // + // Scope: created TRUE-FLAT (candidate flag) AND still FLAT at THIS fill, + // MARKET only, margin_pct>0. Commission is EXCLUDED — a fee-only overage + // ADMITS here, then the KI-61-family entry-bar trim may fire downstream. + // Reversals/adds are out of scope (flat-at-fill required — the flag is only + // set from a true-flat placement, and position_side_==FLAT is re-proven + // here). Runs BEFORE the intraday-cap accounting below: a declined order + // never filled, so it must not consume a max_intraday_filled_orders slot. + // order.qty is NOT mutated (isnan(order.qty) stays a live default-sizing + // discriminator for OCA / reversal-binding / partial-exit classification). + if (order.explicit_flat_admission_candidate + && order.type == OrderType::MARKET + && position_side_ == PositionSide::FLAT + && !std::isnan(order.qty) + && !std::isnan(order.explicit_placement_equity) + && !std::isnan(order.explicit_slipped_signal_close)) { + const double margin_pct = order.is_long ? margin_long_ : margin_short_; + if (margin_pct > 0.0) { + const double slipped_fill = + apply_fill_slippage(fill_price, order.is_long); + const double notional_k = syminfo_.pointvalue * account_currency_fx_ + * (margin_pct / 100.0); + const double fill_notional = + std::abs(order.qty) * slipped_fill * notional_k; + // POOC / no-gap admission floor: a fill at the slipped signal close + // was already admitted at signal time. + const double signal_notional = + std::abs(order.qty) * order.explicit_slipped_signal_close + * notional_k; + const double threshold = + std::max(order.explicit_placement_equity, signal_notional); + const double float_guard = + std::max(1e-9, std::abs(order.explicit_placement_equity) * 1e-12); + if (fill_notional > threshold + float_guard) { + filled_indices.push_back(order_index); + return; + } + } + } + // Check max_intraday_filled_orders limit. // // TV's broker emulator (LATCH-TILL-DAY-ROLLOVER semantics): diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 23ec70a..1117f5f 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -292,6 +292,38 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, && std::isfinite(order.sizing_price) && std::isfinite(order.sizing_mark); } + // design-explicit-qty-fill-admission: capture the fill-time admission + // snapshot for an EXPLICIT-qty (the caller passed a finite qty) MARKET + // entry created TRUE-FLAT. The signal-time gate above (~:139-158) + // already rejected orders whose notional at THIS bar's close overshoots + // equity; this snapshot lets the fill-time re-check drop the entry when + // the next-bar fill gaps adversely enough that |qty|*slipped_fill + // exceeds the placement equity — TV's pinned behavior for the all-in + // floor idiom (probe-68 / mdfe3757). Disjoint from the frozen default- + // sizing snapshot above (that path requires isnan(qty)); priced + // (limit/stop) entries never reach here (else-branch) and RAW + // strategy.order builds its order elsewhere, so neither carries the + // candidate flag. Commission is EXCLUDED from the fill predicate. + if (!std::isnan(qty) && !std::isnan(current_bar_.close)) { + const double explicit_margin = is_long ? margin_long_ : margin_short_; + // Equity basis matches the frozen path (KI-54): realized equity plus + // open profit marked at the signal close. == current_equity() when + // flat, which every candidate is (created_position_side == FLAT). + const double placement_equity = + current_equity() + open_profit(current_bar_.close); + const double slipped_signal_close = + frozen_sizing_price(/*is_buy=*/is_long); + order.explicit_flat_admission_candidate = + order.created_position_side == PositionSide::FLAT + && !order.created_after_position_close_in_bar + && std::isfinite(explicit_margin) && explicit_margin > 0.0 + && std::isfinite(placement_equity) + && std::isfinite(slipped_signal_close); + if (order.explicit_flat_admission_candidate) { + order.explicit_placement_equity = placement_equity; + order.explicit_slipped_signal_close = slipped_signal_close; + } + } } else { order.type = OrderType::ENTRY; order.limit_price = limit_price; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5ae10d2..47c6238 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -66,6 +66,7 @@ set(TEST_SOURCES test_default_qty_signal_freeze test_margin_admission_gate test_frozen_flat_gap_reject + test_explicit_qty_fill_admission test_limit_fill_slippage test_strategy_commands_extra test_multi_tier_exit_precedence diff --git a/tests/test_explicit_qty_fill_admission.cpp b/tests/test_explicit_qty_fill_admission.cpp new file mode 100644 index 0000000..291b59e --- /dev/null +++ b/tests/test_explicit_qty_fill_admission.cpp @@ -0,0 +1,436 @@ +/* + * test_explicit_qty_fill_admission.cpp — TradingView's fill-time DECLINE of an + * EXPLICIT-qty (caller passed a finite qty) true-flat MARKET entry whose + * next-bar fill gaps adversely enough that the position notional at the SLIPPED + * FILL price overshoots the placement-time equity snapshot. + * + * Rule (design-explicit-qty-fill-admission, GO — probe-68 pinned): a pending + * MARKET entry from strategy.entry with a FINITE explicit qty, created TRUE-FLAT + * (created_position_side==FLAT && !created_after_position_close_in_bar) and still + * FLAT at fill, is silently DROPPED (no trade row) at fill when + * + * |qty| * slipped_fill * pv * fx * (margin_pct/100) + * > max(placement_equity, |qty| * slipped_signal_close * pv * fx * margin/100) + * + max(1e-9, |placement_equity| * 1e-12) + * + * with margin_pct > 0 and ZERO structural slack (float guard only — probe-68 + * kills the one-lot term). The slipped-signal-close notional floors the + * threshold so a fill AT/BELOW the slipped signal close (POOC, or a no-gap / + * favorable open) is a structural no-op even with slippage != 0; only an ADVERSE + * gap beyond the slip can decline. Commission is EXCLUDED from the predicate. + * + * This is the EXPLICIT-QTY sibling of the shipped frozen-omitted-qty gap-reject + * (test_frozen_flat_gap_reject.cpp); that fix deliberately left this path alone. + * The signal-time gate in strategy_entry (~:139-158) stays the first line of + * defense; this is the fill-time re-check. + * + * Evidence anchors: data/probes/pf-probe-allin-floor-comm0 (4,740 from-flat + * attempts, decline iff fill notional > equity, zero slack, 99.94%); + * mdfe3757-trade-strategy-v8-4-pine-v6-ready (306/306 separation). + * + * RED-1 flat explicit all-in, comm 0, fill +1 mintick above signal -> DECLINED. + * RED-2 same WITH commission > 0 -> STILL DECLINED (commission not in predicate). + * GREEN-A favorable slip (fill below close), zero-headroom -> ADMITS full qty. + * GREEN-B exact tie (fill == close, no slippage) -> ADMITS. + * GREEN-C headroom (qty at 50% equity) + big adverse gap -> ADMITS. + * GREEN-D commissioned all-in favorable-slip -> ADMITS (fill happens). + * GREEN-E margin>100 characterization + margin==0 inertness. + * GREEN-F priced (stop=) entry adverse gap + RAW strategy.order -> unaffected. + * GREEN-G POOC=true with slippage>0 -> no spurious decline. + * GREEN-H same-bar close-then-explicit-reentry (after-close) -> NOT declined. + * GREEN-I strategy.exit bracket bound to a declined entry -> inert, no crash. + */ + +#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 directional +// mintick snap in apply_slippage is an identity and fills land exactly at the +// bar prices. Script chars (indexed by bar_index_): +// 'L' explicit LONG market entry, qty = entry_qty_ +// 'S' explicit SHORT market entry, qty = entry_qty_ +// 'P' explicit LONG STOP entry (priced), stop = stop_, qty = entry_qty_ +// 'R' RAW LONG order (strategy.order), qty = entry_qty_ +// 'B' explicit LONG market entry + protective strategy.exit stop = exit_stop_ +// 'H' immediate close of "E" + explicit LONG reentry "R2" qty = reentry_qty_ +// '.' nothing +class Probe : public BacktestEngine { +public: + Probe(double capital, double comm_pct, int slippage, double margin, + bool pooc, bool enable_mc) { + initial_capital_ = capital; + default_qty_type_ = QtyType::FIXED; // irrelevant: entries pass explicit qty + default_qty_value_ = 1.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = comm_pct; + margin_long_ = margin; + margin_short_ = margin; + slippage_ = slippage; + syminfo_mintick_ = 0.01; + qty_step_ = 0.0; // float guard only (zero slack) + pyramiding_ = 10; // allow the H reentry as a fresh open + process_orders_on_close_ = pooc; + margin_call_enabled_ = enable_mc; + } + std::string script; + double entry_qty_ = 100.0; + double reentry_qty_ = 100.0; + double stop_ = kNaN; + double exit_stop_ = kNaN; + + 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("E", true, kNaN, kNaN, entry_qty_); break; + case 'S': strategy_entry("E", false, kNaN, kNaN, entry_qty_); break; + case 'P': strategy_entry("E", true, kNaN, stop_, entry_qty_); break; + case 'R': strategy_order("E", true, entry_qty_); break; + case 'B': + strategy_entry("E", true, kNaN, kNaN, entry_qty_); + strategy_exit("EX", "E", kNaN, /*stop_price=*/exit_stop_); + break; + case 'H': + strategy_close("E", "", kNaN, kNaN, /*immediately=*/true); + strategy_entry("R2", true, kNaN, kNaN, reentry_qty_); + 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-1. Flat explicit all-in (qty = equity/close = 100), zero commission, no +// slippage. The fill gaps +1 mintick ABOVE the signal close (open 100.01): +// notional 100*100.01 = 10001 > equity 10000 -> DECLINED (flat, 0 trades). +// Pre-fix: fills 100@100.01 and the engine holds LONG 100. +void test_red1_flat_all_in_adverse_gap_declined() { + std::printf("-- RED-1: flat all-in +1mintick adverse gap declined --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // E placed, eq 10000 + mk_bar(2000, 100.01, 100.02, 100.00, 100.01), // 100*100.01=10001 -> DROP + mk_bar(3000, 100.01, 100.01, 100.01, 100.01), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::FLAT); // pre-fix: LONG + CHECK_NEAR(eng.position_size(), 0.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// RED-2. Same shape WITH commission 0.1%. Commission is EXCLUDED from the +// predicate; the overage here is NOTIONAL (fill 100.01), so the entry is STILL +// DECLINED regardless of the fee. Pre-fix: fills. +void test_red2_commissioned_adverse_gap_still_declined() { + std::printf("-- RED-2: commissioned adverse gap still declined --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.1, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100.01, 100.02, 100.00, 100.01), // notional 10001 -> DROP + mk_bar(3000, 100.01, 100.01, 100.01, 100.01), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::FLAT); // pre-fix: LONG + CHECK_NEAR(eng.position_size(), 0.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// GREEN-A. Favorable slip: fill BELOW the signal close (open 99.99) at +// zero-headroom all-in (qty 100). Notional 100*99.99 = 9999 <= equity 10000 +// -> ADMITS with the full qty. +void test_greenA_favorable_slip_admits() { + std::printf("-- GREEN-A: favorable-slip zero-headroom admits --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 99.99, 100.0, 99.98, 99.99), // 100*99.99=9999 <= 10000 + mk_bar(3000, 99.99, 99.99, 99.99, 99.99), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); +} + +// GREEN-B. Exact tie: fill == signal close (open 100, no slippage). Notional +// 100*100 = 10000 is NOT strictly greater than equity 10000 (+ float guard) +// -> ADMITS. +void test_greenB_exact_tie_admits() { + std::printf("-- GREEN-B: exact-tie admits --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), // 100*100 = 10000 == eq + mk_bar(3000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); +} + +// GREEN-C. Headroom: qty sized at 50% of equity (50 lots = 5000). Even a big +// adverse gap (+50%, open 150) leaves notional 50*150 = 7500 <= equity 10000 +// -> ADMITS. probe-05/06 shape: equity dominates the threshold. +void test_greenC_headroom_big_gap_admits() { + std::printf("-- GREEN-C: headroom + big adverse gap admits --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 50.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 150, 151, 149, 150), // 50*150 = 7500 <= 10000 + mk_bar(3000, 150, 150, 150, 150), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 50.0, 1e-9); +} + +// GREEN-D. Commissioned all-in favorable-slip: fill below close (open 99.99), +// commission 0.1% is NOT in the predicate so notional 9999 <= 10000 -> the +// admission itself passes and the position OPENS. Margin call disabled here so +// the KI-61-family entry-bar trim does not perturb the assertion under test +// (the trim machinery is exercised by test_margin_call / the frozen-gap tests). +void test_greenD_commissioned_favorable_admits() { + std::printf("-- GREEN-D: commissioned all-in favorable-slip admits --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.1, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 99.99, 100.0, 99.98, 99.99), // notional 9999 <= 10000 + mk_bar(3000, 99.99, 99.99, 99.99, 99.99), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); // the fill happened + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); +} + +// GREEN-E. margin>100 characterization + margin==0 inertness. +// E1: margin_pct=200 (sub-1x leverage). The signal-time gate caps all-in at +// qty = equity/(close*2) = 50. A favorable/no-gap fill (open 100) keeps +// notional 50*100*2 = 10000 == threshold -> ADMITS (unchanged from +// pre-fix; the gate applies the same margin/100 arithmetic on both sides). +// E2: margin_pct=0 -> the candidate flag is never set (margin>0 required) AND +// the signal-time gate is inert, so a wild adverse gap still ADMITS. +void test_greenE_margin_variants() { + std::printf("-- GREEN-E1: margin=200 favorable/tie admits (char.) --\n"); + { + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/200.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 50.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), // 50*100*2 = 10000 == thr + mk_bar(3000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 50.0, 1e-9); + } + std::printf("-- GREEN-E2: margin=0 gate inert (admits wild gap) --\n"); + { + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/0.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 200, 201, 199, 200), // huge gap, margin 0 -> admit + mk_bar(3000, 200, 200, 200, 200), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); + } +} + +// GREEN-F. Priced (stop=) entry + RAW strategy.order are UNAFFECTED. +// F1: an explicit-qty LONG STOP entry (stop 100) that arms and fills on an +// adverse gap keeps its own price; it carries no admission snapshot +// (type == ENTRY, not MARKET) so the fill gate never fires -> fills. +// F2: a RAW strategy.order all-in adverse gap never sets the candidate flag +// -> fills. +void test_greenF_priced_and_raw_unaffected() { + std::printf("-- GREEN-F1: priced (stop) entry adverse gap unaffected --\n"); + { + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.stop_ = 100.0; // stop trigger at 100 + eng.script = "P.."; + std::vector bars = { + mk_bar(1000, 90, 90, 90, 90), // P armed (stop 100) + mk_bar(2000, 101, 102, 100, 101), // gaps through 100 -> fills + mk_bar(3000, 101, 101, 101, 101), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); + } + std::printf("-- GREEN-F2: RAW strategy.order adverse gap unaffected --\n"); + { + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.script = "R.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100.01, 100.02, 100.00, 100.01), // adverse: RAW unaffected + mk_bar(3000, 100.01, 100.01, 100.01, 100.01), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); + } +} + +// GREEN-G. POOC=true with slippage>0 must be a structural no-op. The order +// fills at the SAME bar close (100) with slippage 1 tick -> slipped_fill +// 100.01, notional 10001. The slipped-signal-close notional (also 100.01 -> +// 10001) floors the threshold, so the fill is admitted (a fill at the slipped +// signal close is never a decline). Without the floor the pure "notional > +// equity 10000" rule would spuriously drop this. +void test_greenG_pooc_slippage_no_op() { + std::printf("-- GREEN-G: POOC + slippage>0 no spurious decline --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/1, /*margin=*/100.0, + /*pooc=*/true, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // E placed AND filled at close (POOC) + mk_bar(2000, 100, 100, 100, 100), + mk_bar(3000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); +} + +// GREEN-H. Same-bar close-then-explicit-reentry. Bar0 opens a small long (qty +// 1); bar1 immediately closes it AND places an all-in explicit reentry "R2" +// from the now-flat position (created_after_position_close_in_bar == true). +// Bar2 gaps +1 mintick adverse. Because the reentry is NOT created true-flat +// (the after-close flag is set), it is EXCLUDED from the fill gate and fills +// despite the adverse gap. A true-flat all-in here would be declined (RED-1). +void test_greenH_close_reentry_not_declined() { + std::printf("-- GREEN-H: same-bar close-then-reentry not declined --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 1.0; // bar0 'L' small long + eng.reentry_qty_ = 100.0; // bar1 'H' all-in reentry + eng.script = "LH."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // L placed (qty 1) + mk_bar(2000, 100, 100, 100, 100), // L fills @100; then close+reentry + mk_bar(3000, 100.01, 100.02, 100.00, 100.01), // R2 fills adverse -> NOT declined + mk_bar(4000, 100.01, 100.01, 100.01, 100.01), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); // reentry filled + CHECK_NEAR(eng.position_size(), 100.0, 1e-9); +} + +// GREEN-I. Dangling-exit safety. A strategy.exit bracket ("EX", from_entry +// "E", protective stop 80) is armed on the same bar as the all-in explicit +// long "E". The entry is fill-declined by the adverse gap, 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_greenI_dangling_exit_inert() { + std::printf("-- GREEN-I: exit bracket on a declined entry is inert --\n"); + Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, + /*pooc=*/false, /*enable_mc=*/false); + eng.entry_qty_ = 100.0; + eng.exit_stop_ = 80.0; + eng.script = "B.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // E + EX(stop 80) armed + mk_bar(2000, 100.01, 100.02, 100.00, 100.01), // E adverse gap -> DECLINED + mk_bar(3000, 79, 79, 79, 79), // through stop 80 -> nothing + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::FLAT); // pre-fix: LONG then exit + CHECK_NEAR(eng.position_size(), 0.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +} // namespace + +int main() { + std::printf("--- explicit_qty_fill_admission ---\n"); + test_red1_flat_all_in_adverse_gap_declined(); + test_red2_commissioned_adverse_gap_still_declined(); + test_greenA_favorable_slip_admits(); + test_greenB_exact_tie_admits(); + test_greenC_headroom_big_gap_admits(); + test_greenD_commissioned_favorable_admits(); + test_greenE_margin_variants(); + test_greenF_priced_and_raw_unaffected(); + test_greenG_pooc_slippage_no_op(); + test_greenH_close_reentry_not_declined(); + test_greenI_dangling_exit_inert(); + 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_call.cpp b/tests/test_margin_call.cpp index 0515678..bb8ed71 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -423,75 +423,93 @@ class LongOverAllocProbe : public MCEngine { double qty_; }; -static void test_long_100pct_margin_sublot_overage_is_held() { - std::printf("test_long_100pct_margin_sublot_overage_is_held\n"); +// Repurposed from the KI-61 "sublot overage held" fixture (design-explicit-qty- +// fill-admission). This is an EXPLICIT-qty all-in true-flat MARKET entry +// (strategy.entry with qty=10 == equity/close) whose next-bar fill gaps +// ADVERSELY to 110: notional 10*110 = 1100 overshoots equity 1000. TV DECLINES +// it outright with ZERO slack — the frozen path's one-lot lot-floor slack does +// NOT apply to explicit qty — so the pre-fix "held 10 via lot-floor dust" +// outcome is dead: no fill, no rows, no margin call. Evidence: +// data/probes/pf-probe-allin-floor-comm0 (4,740 from-flat attempts; decline iff +// fill notional > equity, commission-independent, zero slack). The KI-61 +// lot-floored opening-affordability trim these fixtures once exercised is still +// pinned by the frozen/default-sized path (test_commissioned_frozen_all_in_ +// true_flat_gap_is_eligible for commissioned-adverse admit+trim; the frozen +// exemption tests for the sub-lot held case) plus test_explicit_qty_fill_ +// admission's GREEN-D. +static void test_explicit_all_in_zero_comm_adverse_gap_declined() { + std::printf("test_explicit_all_in_zero_comm_adverse_gap_declined\n"); std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), // 0: signal @ close 100 - mk_bar(2000, 110.0, 112.0, 50.0, 90.0, 1.0), // 1: restore=.909 lot - mk_bar(3000, 90.0, 91.0, 1.0, 2.0, 1.0), // 2: later crash + mk_bar(2000, 110.0, 112.0, 50.0, 90.0, 1.0), // 1: gap 110 -> 1100 > 1000 DECLINE + mk_bar(3000, 90.0, 91.0, 1.0, 2.0, 1.0), // 2: later crash: nothing held }; LongOverAllocProbe eng(/*qty_step=*/1.0); eng.run(bars.data(), (int)bars.size()); - // Restoring affordability at the 110 fill needs only 0.909 lot. Flooring - // to qty_step=1 produces zero, so TV does not trim. Later lows cannot turn - // that dust overage into an adverse-price liquidation. CHECK(eng.trade_count() == 0); - CHECK(near(eng.position_size(), 10.0)); + CHECK(near(eng.position_size(), 0.0)); // pre-fix: held 10 CHECK(std::isnan(eng.liq_price())); } -static void test_long_100pct_margin_lot_trim_uses_entry_affordability() { - std::printf("test_long_100pct_margin_lot_trim_uses_entry_affordability\n"); +// Repurposed from the KI-61 "lot trim uses entry affordability" fixture. An +// EXPLICIT-qty all-in true-flat MARKET entry (qty=10 == equity/close), commission +// 4%, fill gaps ADVERSELY to 120: the NOTIONAL 10*120 = 1200 alone overshoots +// equity 1000 (the fee is irrelevant to the predicate). Commission-scoping is +// DEAD (data/probes/pf-probe-allin-floor-comm0 is comm=0 and still declines), so +// TV DECLINES this too — the pre-fix "fill 10@120 then 4x entry-bar trim to hold +// 2" outcome is dead. No fill, no Margin-call rows. The commissioned admit+trim +// KI-61 semantics remain pinned by the FROZEN path +// (test_commissioned_frozen_all_in_true_flat_gap_is_eligible). +static void test_explicit_all_in_commissioned_adverse_gap_declined() { + std::printf("test_explicit_all_in_commissioned_adverse_gap_declined\n"); std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), - mk_bar(2000, 120.0, 122.0, 100.0, 110.0, 1.0), + mk_bar(2000, 120.0, 122.0, 100.0, 110.0, 1.0), // gap 120 -> 1200 > 1000 DECLINE mk_bar(3000, 110.0, 111.0, 10.0, 20.0, 1.0), }; LongOverAllocProbe eng(/*qty_step=*/1.0, /*commission_percent=*/4.0); eng.run(bars.data(), (int)bars.size()); - // Entry fee is 48, so q_restore = 10 - (1000-48)/120 = 2.066..., floors - // to two lots and the 4x rule trims eight. Omitting the entry commission - // would floor q_restore to one lot. The action is entry-bar/entry-priced, - // never based on that bar's later low of 100. - CHECK(eng.trade_count() == 1); - CHECK(eng.exit_comment(0) == std::string("Margin call")); - CHECK(near(eng.entry_price(0), 120.0)); - CHECK(near(eng.exit_price(0), 120.0)); - CHECK(near(eng.trade_size(0), 8.0)); - CHECK(eng.entry_bar(0) == 1); - CHECK(eng.exit_bar(0) == 1); - CHECK(near(eng.position_size(), 2.0)); + CHECK(eng.trade_count() == 0); // pre-fix: 1 (fill + Margin-call trim) + CHECK(near(eng.position_size(), 0.0)); // pre-fix: held 2 CHECK(std::isnan(eng.liq_price())); } -static void test_long_100pct_margin_trim_without_qty_step() { - std::printf("test_long_100pct_margin_trim_without_qty_step\n"); +// Repurposed from the KI-61 "trim without qty_step" fixture. EXPLICIT-qty all-in +// true-flat MARKET entry (qty=10 == equity/close), zero commission, qty_step=0 +// (continuous mode), fill gaps ADVERSELY to 110: notional 1100 > equity 1000. +// This is exactly test_explicit_qty_fill_admission RED-1's class (zero comm, +// zero slack, adverse gap), so TV DECLINES — the pre-fix fractional entry-bar +// trim is dead. No fill, no rows. +static void test_explicit_all_in_zero_comm_no_qty_step_declined() { + std::printf("test_explicit_all_in_zero_comm_no_qty_step_declined\n"); std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), - mk_bar(2000, 110.0, 112.0, 50.0, 90.0, 1.0), + mk_bar(2000, 110.0, 112.0, 50.0, 90.0, 1.0), // gap 110 -> 1100 > 1000 DECLINE mk_bar(3000, 90.0, 91.0, 1.0, 2.0, 1.0), }; LongOverAllocProbe eng(/*qty_step=*/0.0); eng.run(bars.data(), (int)bars.size()); - // qty_step==0 is the engine's continuous-quantity mode: there is no lot - // floor, so the positive restore quantity remains a fractional trim. - const double expected = 4.0 * (10.0 - 1000.0 / 110.0); - CHECK(eng.trade_count() == 1); - CHECK(near(eng.trade_size(0), expected)); - CHECK(near(eng.entry_price(0), 110.0)); - CHECK(near(eng.exit_price(0), 110.0)); - CHECK(near(eng.position_size(), 10.0 - expected)); + CHECK(eng.trade_count() == 0); // pre-fix: 1 (fill + fractional trim) + CHECK(near(eng.position_size(), 0.0)); // pre-fix: held 10 - trim + CHECK(std::isnan(eng.liq_price())); } -static void test_long_100pct_margin_combines_fx_pointvalue_and_commission() { - std::printf("test_long_100pct_margin_combines_fx_pointvalue_and_commission\n"); +// Repurposed from the KI-61 "combines fx/pointvalue/commission" fixture. The +// explicit-qty fill-admission predicate carries the SAME pv/fx/margin factors as +// KI-61 (|qty|*slipped_fill*pv*fx*margin/100). Signal-time admission is exact +// (5*10*pv10*fx2 == 1000 == equity); the ADVERSE fill at 12 makes the notional +// 5*12*10*2 = 1200 > equity 1000, so TV DECLINES (commission 10% excluded from +// the predicate). Pins that the decline arithmetic combines pv, fx, and margin +// exactly like the KI-61 trim it replaces here. No fill, no rows. +static void test_explicit_all_in_fx_pointvalue_commission_declined() { + std::printf("test_explicit_all_in_fx_pointvalue_commission_declined\n"); std::vector bars = { // Signal-time admission is exact: 5 * 10 * pv10 * fx2 == 1000. mk_bar(1000, 10.0, 10.0, 9.0, 10.0, 1.0), - mk_bar(2000, 12.0, 13.0, 8.0, 11.0, 1.0), + mk_bar(2000, 12.0, 13.0, 8.0, 11.0, 1.0), // 5*12*10*2 = 1200 > 1000 DECLINE mk_bar(3000, 11.0, 12.0, 1.0, 2.0, 1.0), }; LongOverAllocProbe eng(/*qty_step=*/1.0, /*commission_percent=*/10.0, @@ -500,15 +518,9 @@ static void test_long_100pct_margin_combines_fx_pointvalue_and_commission() { /*pointvalue=*/10.0, /*qty=*/5.0); eng.run(bars.data(), (int)bars.size()); - // Fill notional is 5*12*10*2=1200 and entry fee is 120, so - // q_restore = 5 - (1000-120)/(12*10*2) = 1.333..., floors to one lot, - // then 4x -> four. Omitting commission, FX, or pointvalue misses this - // deliberately chosen lot boundary. - CHECK(eng.trade_count() == 1); - CHECK(near(eng.trade_size(0), 4.0)); - CHECK(near(eng.entry_price(0), 12.0)); - CHECK(near(eng.exit_price(0), 12.0)); - CHECK(near(eng.position_size(), 1.0)); + CHECK(eng.trade_count() == 0); // pre-fix: 1 (fill + trim to hold 1) + CHECK(near(eng.position_size(), 0.0)); // pre-fix: held 1 + CHECK(std::isnan(eng.liq_price())); } static void test_long_100pct_margin_trim_process_orders_on_close() { @@ -1110,10 +1122,10 @@ int main() { 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(); - test_long_100pct_margin_lot_trim_uses_entry_affordability(); - test_long_100pct_margin_trim_without_qty_step(); - test_long_100pct_margin_combines_fx_pointvalue_and_commission(); + test_explicit_all_in_zero_comm_adverse_gap_declined(); + test_explicit_all_in_commissioned_adverse_gap_declined(); + test_explicit_all_in_zero_comm_no_qty_step_declined(); + test_explicit_all_in_fx_pointvalue_commission_declined(); test_long_100pct_margin_trim_process_orders_on_close(); test_long_100pct_margin_stop_trim_uses_raw_base_and_exit_slip(); test_long_100pct_margin_limit_trim_uses_raw_base_and_exit_slip();