diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index b910e5b..7bcf5e6 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -865,6 +865,48 @@ void BacktestEngine::apply_filled_order_to_state( } } + // KI-62 STAGE 3: margin fill-time admission for STOP-ENTRY fills. Under + // margin simulation (margin_long_/margin_short_ > 0) TV gates every stop + // entry AT THE FILL MOMENT against the fill bar's OPEN price, + // side-symmetrically — decline iff qty*open*margin% > available (realized) + // equity. Admission ignores intrabar extremes: it costs the bar OPEN, NOT + // the touched level / the fill price / the bar high. So a marginal all-in + // stop touched INTRABAR (open past the level in the adverse direction: + // short open>stop, long open equity -> DECLINE; only a bar that OPENS THROUGH the level + // (short open<=stop, long open>=stop) costs qty*open <= equity and fills at + // that open — reproducing the waranyutrkm 369/369 "fill at first open<=stop" + // census + the 13/13 long re-touch fills (pf-probe-ki62-margin-deferral, + // pinned 99.17%). A declined stop is CANCELLED (consumed here, removed by + // compaction); an arm-once entry silently dies (SAO NOFILL), a Pine-level + // reissue re-posts next bar and fills at the first admissible bar. An + // under-margined ADMITTED fill still nibbles at bar end via the existing + // KI-31 4x cascade (unchanged). Scope: an ENTRY with a stop trigger and no + // limit; margin_pct>0; positive fill qty. The available equity is + // realized-only (current_equity()) — the engine's validated stop-entry + // sizing basis (3,160/3,160). Does NOT touch the :443 created_bar + // eligibility, the signal-time MARKET-only gate, or any margin=0 path (all + // byte-identical when margin_pct==0). The qty is exactly the fill kernel's + // (calc_qty_for_type at the fill price) so the gate cannot diverge from the + // fill it guards. + if (order.type == OrderType::ENTRY + && !std::isnan(order.stop_price) + && std::isnan(order.limit_price)) { + const double margin_pct = order.is_long ? margin_long_ : margin_short_; + if (margin_pct > 0.0 && std::isfinite(bar.open) && bar.open > 0.0) { + const double fill_qty = + std::abs(calc_qty_for_type(fill_price, order.qty, order.qty_type)); + const double required = fill_qty * bar.open * syminfo_.pointvalue + * account_currency_fx_ * (margin_pct / 100.0); + const double available = current_equity(); + const double eps = std::max(1e-9, std::abs(available) * 1e-12); + if (fill_qty > 0.0 && required > available + eps) { + filled_indices.push_back(order_index); // decline + cancel + return; + } + } + } + // A fixed-default MARKET entry can change role between placement and fill: // it was a same-direction order when the script emitted it, but an earlier // sibling at the shared next tick can flip the live position first, making @@ -988,13 +1030,49 @@ void BacktestEngine::apply_filled_order_to_state( // priced (limit/stop) entries carry no snapshot. Runs BEFORE the // intraday-cap accounting below: a dropped order was never filled, so // it must not consume a max_intraday_filled_orders slot. + // KI-72: a default-sized percent_of_equity MARKET/RAW order whose FROZEN + // sizing produced a NON-POSITIVE quantity is DECLINED CLEANLY (no fill, no + // trade row) instead of opening a corrupt position. apply_qty_step returns + // the quantity UNFLOORED for qty <= 0 (engine.hpp), so sizing_equity <= 0 — + // realized + open PnL underwater past the whole account, reachable when a + // held SHORT's unbounded adverse excursion drives equity negative while its + // reversal keeps getting declined — yields a NEGATIVE frozen_default_qty. + // Admitting it (the legacy path below runs only for sizing_equity > 0, so a + // negative-equity order fell straight through to the fill kernel) opens a + // negative-qty position via open_fresh_position, and every subsequent close + // then emits emit_close_trade(pe, pe.qty<0, ...): a NEGATIVE-qty trade row + // that flips the exported PnL sign and blows the cumulative-PnL column, + // while the realized net_profit_sum_ stays healthy — the emission/accounting + // split (PARK-DOSSIER D1a; surfaced by symmetric-scope KI-57 on almesned, + // every exported qty negative, cumulative -122k). A negative-equity account + // can afford nothing, so the clean decline is the symmetric, corruption-free + // behavior on BOTH sides — the exact counterpart of a declined long. It + // fires ONLY in the bankrupt regime (solvent equity always sizes qty > 0), + // so every gate below is byte-untouched. For a MARKET reversal, suppress the + // co-queued close legs exactly like the KI-54 reversal decline so the flip + // is refused atomically and the underwater position rides on (to be margin- + // called or re-flipped later), never seeding a corrupt negative-qty leg. + if (!std::isnan(order.frozen_default_qty) + && order.frozen_default_qty <= 0.0 + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && (order.type == OrderType::MARKET + || order.type == OrderType::RAW_ORDER)) { + const bool same_dir = position_side_ != PositionSide::FLAT + && ((position_side_ == PositionSide::LONG) == order.is_long); + const bool reversal = position_side_ != PositionSide::FLAT && !same_dir; + if (reversal && order.type == OrderType::MARKET) { + suppress_declined_reversal_close_legs(order); + } + filled_indices.push_back(order_index); + return; + } // sizing_equity > 0 and frozen_default_qty > 0 are part of the invariant, // not paranoia: apply_qty_step returns qty UNFLOORED for qty <= 0 // (engine.hpp), so on a bankrupt account the frozen quantity is negative, // |qty|*sizing_price == |sizing_equity|, and free_funds < 0 — every order, - // including a flat open, would be declined forever. There is no TV ground - // truth for what a negative-equity account may open; leave it to the - // legacy path. + // including a flat open, would be declined forever. The KI-72 branch above + // now catches that non-positive-qty case explicitly (clean decline); this + // gate keeps its own > 0 guards so the solvent-path arithmetic is unchanged. if (!std::isnan(order.sizing_equity) && !std::isnan(order.sizing_price) && !std::isnan(order.frozen_default_qty) && order.sizing_equity > 0.0 && order.frozen_default_qty > 0.0 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index efbb636..d1145ef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -66,6 +66,8 @@ set(TEST_SOURCES test_fills_edge test_default_qty_signal_freeze test_margin_admission_gate + test_short_reversal_emission + test_margin_stop_admission test_frozen_flat_gap_reject test_explicit_qty_fill_admission test_limit_fill_slippage diff --git a/tests/test_explicit_qty_fill_admission.cpp b/tests/test_explicit_qty_fill_admission.cpp index 291b59e..308cae3 100644 --- a/tests/test_explicit_qty_fill_admission.cpp +++ b/tests/test_explicit_qty_fill_admission.cpp @@ -306,14 +306,16 @@ void test_greenE_margin_variants() { } } -// 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. +// GREEN-F. The EXPLICIT-QTY MARKET admission gate this file pins is stop-agnostic +// and RAW-agnostic. F1 is now dominated by the SEPARATE KI-62 stage-3 margin gate; +// F2 (RAW) remains unaffected. +// F1: an explicit-qty all-in LONG STOP entry (stop 100) gapping through on an +// adverse open is DECLINED by the KI-62 stage-3 margin fill-time gate +// (required at the fill-bar open > equity) — NOT by this file's MARKET gate. // 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"); + std::printf("-- GREEN-F1: priced (stop) entry adverse gap -> KI-62 margin decline --\n"); { Probe eng(/*capital=*/10000.0, /*comm=*/0.0, /*slip=*/0, /*margin=*/100.0, /*pooc=*/false, /*enable_mc=*/false); @@ -322,12 +324,18 @@ void test_greenF_priced_and_raw_unaffected() { 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(2000, 101, 102, 100, 101), // gaps through: fill-bar open 101 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); + // KI-62 STAGE 3: all-in stop (qty 100, cap 10000) gapping through 100 costs + // required = 100*open(101)*100% = 10100 > equity 10000 -> the margin + // fill-time gate DECLINES it (side-symmetric; TV declines all-in stops on + // an adverse gap-open). This is the stage-3 STOP gate, not the explicit-qty + // MARKET gate (which stays stop-agnostic). ki65 cross-confirms over-alloc + // stop declines (canonical TV match 93.8% -> 100.0%). + CHECK(eng.position_side_ == PositionSide::FLAT); // stage-3 decline (was LONG 100) + CHECK_NEAR(eng.position_size(), 0.0, 1e-9); } std::printf("-- GREEN-F2: RAW strategy.order adverse gap unaffected --\n"); { diff --git a/tests/test_margin_admission_gate.cpp b/tests/test_margin_admission_gate.cpp index 6493c7e..575d56b 100644 --- a/tests/test_margin_admission_gate.cpp +++ b/tests/test_margin_admission_gate.cpp @@ -360,18 +360,18 @@ void test_fractional_add_marked_to_market() { } // J. Bankrupt account: sizing_equity <= 0 makes the frozen qty NEGATIVE, and -// apply_qty_step returns it unfloored, so |qty|*sizing_price == -// |sizing_equity| while free_funds < 0. Without the guard the gate would -// decline every order forever — flat opens included — silently zeroing out -// a strategy that the pre-gate engine still traded. +// apply_qty_step returns it unfloored. The LEGACY path opened a negative-qty +// position; every close of it then emitted a negative-qty trade row that +// flipped the exported PnL sign (the KI-72 emission/accounting split). // -// Pinned behaviour is the LEGACY path: the engine opens a negative-qty -// position. That is a separate, pre-existing pathology (an order sized off -// negative equity should arguably not be placed at all) and it is NOT this -// gate's job to hide it. If the guard is removed, position_qty_ becomes 0 -// and this pin fails. -void test_negative_equity_gate_does_not_run() { - std::printf("-- J: gate does not run on a bankrupt account --\n"); +// KI-72 FIX: a default-sized percent_of_equity MARKET/RAW order whose frozen +// sizing is NON-POSITIVE is now DECLINED CLEANLY (no fill, no trade row) — +// a bankrupt account can afford nothing, symmetric on both sides. So the +// order does not open and position_qty_ stays 0 (was -50 under the legacy +// path). This is the exact behaviour test_short_reversal_emission pins from +// the fill side; here it is pinned at the gate. +void test_negative_equity_reversal_declined_clean() { + std::printf("-- J: bankrupt-account order declined cleanly (KI-72) --\n"); Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); eng.initial_capital_ = -5000.0; eng.script = "L.."; @@ -381,7 +381,9 @@ void test_negative_equity_gate_does_not_run() { mk_bar(3000, 100, 100, 100, 100), }; eng.run(bars.data(), (int)bars.size()); - CHECK_NEAR(eng.position_qty_, -50.0, 1e-9); // legacy path, gate inert + CHECK_NEAR(eng.position_qty_, 0.0, 1e-9); // clean decline, no neg-qty open + CHECK(eng.position_side_ == PositionSide::FLAT); + CHECK(eng.trade_count() == 0); // no corrupt trade row emitted } // K. margin > 100 (sub-1x leverage) breaks the flat-open invariant: @@ -588,7 +590,7 @@ int main() { test_slipped_short_reversal_zero_gap_admitted(); test_reversal_lot_step_slack(); test_fractional_add_marked_to_market(); - test_negative_equity_gate_does_not_run(); + test_negative_equity_reversal_declined_clean(); test_margin_above_100_flat_open_admitted(); test_same_side_market_becomes_reversal_free_margin_gate(); test_same_side_role_change_scope_controls(); diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index bb8ed71..ff97781 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -584,15 +584,26 @@ static void test_long_100pct_margin_stop_trim_uses_raw_base_and_exit_slip() { LongPricedOverAllocProbe eng(LongPricedOverAllocProbe::Kind::Stop); eng.run(bars.data(), (int)bars.size()); - // stop 120.2 first snaps upward to raw matched base 121, then buy-side - // slippage reports entry 123. The broker trim independently applies - // sell-side slippage to raw 121, reporting 119. It must not use bar.low. - CHECK(eng.trade_count() == 1); - CHECK(near(eng.trade_size(0), 4.0)); - CHECK(near(eng.entry_price(0), 123.0)); - CHECK(near(eng.exit_price(0), 119.0)); - CHECK(eng.entry_bar(0) == 1); - CHECK(eng.exit_bar(0) == 1); + // KI-62 STAGE 3 (margin fill-time admission): this buy-stop 120.2 is + // OVER-ALLOCATED — qty 10 on a $1,000 account, and the margin gate costs it + // at the FILL BAR'S OPEN (110): required 10*110*100% = 1100 > equity 1000 -> + // DECLINE. The order never fills, so the old KI-61 1x-long dust-trim (which + // used to report fill@123 then trim to size 4) does NOT fire — the + // "declined fills must not fire the dust-trim" reconciliation. + // + // TV declines over-allocated stops too: cross-confirmed by + // pf-probe-ki65-dual-entry-precedence, whose UQ=1,000,000 (>=1000x + // over-notional) stop cells decline under the identical rule, lifting its + // canonical TV match 93.8% -> 100.0%. The LIMIT sibling below is UNAFFECTED + // (the gate is stop-entry-only) and still fills + trims. + // + // CAVEAT (register): the OVER-ALLOCATED FIXED-QTY class is UNPINNED by the + // ki62 probe itself (which used all-in / marginal / fixed-small sizing). It + // is a candidate future-probe cell; if any tier ever regresses tracing to a + // strategy relying on the old admit-and-trim vs TV, this scopes back to + // admit-then-nibble and the cell becomes a probe requirement. + CHECK(eng.trade_count() == 0); // declined at the fill-bar open + CHECK(near(eng.position_size(), 0.0)); // nothing opened CHECK(!eng.opening_pending()); CHECK(!eng.opening_eligible()); CHECK(std::isnan(eng.opening_raw_base())); diff --git a/tests/test_margin_stop_admission.cpp b/tests/test_margin_stop_admission.cpp new file mode 100644 index 0000000..47e7a44 --- /dev/null +++ b/tests/test_margin_stop_admission.cpp @@ -0,0 +1,199 @@ +/* + * test_margin_stop_admission.cpp — STAGE 3: margin fill-time admission gate for + * STOP-ENTRY fills (pf-probe-ki62-margin-deferral, pinned 99.17%). + * + * Under margin simulation (margin_long_/margin_short_ > 0) TV gates every + * stop-entry fill AT THE FILL MOMENT against the fill bar's OPEN price, + * side-symmetrically: decline iff qty * open * margin% > available (realized) + * equity. Admission ignores intrabar extremes (it costs the OPEN, not the fill + * price / the touched level / the bar high). A declined stop is CANCELLED (not + * parked) — an arm-once entry silently dies; a Pine-level reissue re-posts and + * fills at the first admissible bar (a short at the first open<=stop = a + * price-improved OPEN fill; a long at the first re-touch with open +#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 == %.6f, expected %.6f\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(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 { + +// All-in stop-entry probe. Places a stop entry at a fixed level with EXPLICIT +// qty (mirrors the probe's `qty = equity/lvl`), reissued every bar from bar 0 +// unless arm_once (place once at bar 0). margin_call OFF so admission is +// isolated from the KI-31 entry-bar nibble. +class StopProbe : public BacktestEngine { +public: + StopProbe(double capital, double ml, double ms, bool mc) { + initial_capital_ = capital; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + pyramiding_ = 1; + margin_long_ = ml; margin_short_ = ms; + qty_step_ = 0.0; + set_margin_call_enabled(mc); + } + bool is_long = false; // stop direction + double level = 100.0; // stop price + double qty = 100.0; // explicit qty + bool arm_once = false; + void on_bar(const Bar& /*b*/) override { + if (arm_once && bar_index_ != 0) return; + if (bar_index_ < 0) return; + strategy_entry("BO", is_long, kNaN, level, qty); + } + using BacktestEngine::position_side_; + using BacktestEngine::position_qty_; + using BacktestEngine::position_entry_price_; +}; + +// A1. Marginal SHORT stop, INTRABAR trigger -> DECLINED + CANCELLED; the +// reissue fills at the first OPEN<=stop (price-improved gap fill). +// short qty 100 @ stop 100 (all-in on $10k, margin_short=100). +// bar1 open 105 (>100), low 99 (<=100): intrabar touch. required 100*105 = +// 10500 > 10000 -> DECLINE (baseline fills SHORT@100 here). bar2 open 98 +// (<=100 gap-down): required 100*98 = 9800 <= 10000 -> ADMIT, fill @98. +void test_marginal_short_stop_declined_then_gap_fill() { + std::printf("-- A1: marginal short stop intrabar-declined, gap-open admitted --\n"); + StopProbe eng(10000.0, /*ml*/100.0, /*ms*/100.0, /*mc*/false); + eng.is_long=false; eng.level=100.0; eng.qty=100.0; + std::vector bars = { + mk(1000, 110,110,110,110), // bar0: place (price above stop, pending) + mk(2000, 105,106, 99,101), // bar1: intrabar touch (open 105>100) -> DECLINE + mk(3000, 98, 99, 97, 98), // bar2: gap-down open 98<=100 -> ADMIT @98 + mk(4000, 98, 98, 98, 98), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); // eventually fills + CHECK_NEAR(eng.position_qty_, 100.0, 1e-9); + CHECK_NEAR(eng.position_entry_price_, 98.0, 1e-9); // GAP OPEN, not the level 100 +} + +// A2. ARM-ONCE marginal short stop: declined at the intrabar touch and CANCELLED +// -> never reissued -> stays FLAT forever (the SAO NOFILL signature). +void test_arm_once_declined_stop_nofill() { + std::printf("-- A2: arm-once declined stop is cancelled (NOFILL) --\n"); + StopProbe eng(10000.0, 100.0, 100.0, false); + eng.is_long=false; eng.level=100.0; eng.qty=100.0; eng.arm_once=true; + std::vector bars = { + mk(1000, 110,110,110,110), // place once + mk(2000, 105,106, 99,101), // intrabar touch -> DECLINE + CANCEL + mk(3000, 98, 99, 97, 98), // open<=stop but NO reissue -> stays flat + mk(4000, 98, 98, 98, 98), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::FLAT); // armed-once order died + CHECK(eng.trade_count() == 0); +} + +// A3. SIDE-SYMMETRIC long stop: a gap-UP open past the buy-stop is DECLINED +// (required at the high open > equity); the re-touch bar whose open is below the +// level admits at the level. +// long qty 100 @ stop 100. bar1 open 95 low? no touch. Make bar1 gap up: +// open 105 (>=100) -> required 100*105=10500 > 10000 -> DECLINE. bar2 opens 99 +// (<100) and highs to 100 -> intrabar touch, required 100*99=9900 <=10000 -> +// ADMIT at the level 100. +void test_marginal_long_stop_gap_declined_then_level_fill() { + std::printf("-- A3: marginal long stop gap-declined, re-touch admitted (symmetric) --\n"); + StopProbe eng(10000.0, 100.0, 100.0, false); + eng.is_long=true; eng.level=100.0; eng.qty=100.0; + std::vector bars = { + mk(1000, 90, 90, 90, 90), // bar0: place (price below buy-stop, pending) + mk(2000, 105,106,104,105), // bar1: gap-up open 105>=100 -> DECLINE (base fills @105) + mk(3000, 99,100.5, 98, 99), // bar2: open 99<100, high 100.5>=100 -> ADMIT @100 + mk(4000, 100,100,100,100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_qty_, 100.0, 1e-9); + CHECK_NEAR(eng.position_entry_price_, 100.0, 1e-9); // re-touch level, not the gap @105 +} + +// C1. CONTROL — margin=0: NO fill-time gate. The intrabar touch fills at the +// level exactly as baseline (KI-34 safety: margin-sim-off paths byte-identical). +void test_margin_zero_fills_at_level() { + std::printf("-- C1: margin=0 stop fills intrabar at level (control) --\n"); + StopProbe eng(10000.0, /*ml*/0.0, /*ms*/0.0, /*mc*/false); + eng.is_long=false; eng.level=100.0; eng.qty=100.0; + std::vector bars = { + mk(1000, 110,110,110,110), + mk(2000, 105,106, 99,101), // intrabar touch -> fills @100 (no gate) + mk(3000, 101,101,101,101), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_entry_price_, 100.0, 1e-9); +} + +// C2. CONTROL — well-funded stop: required << equity, so the intrabar touch +// admits at the level under margin sim (the gate only bites the marginal case). +// short qty 1 @ stop 100, margin_short=100: required 1*105 = 105 << 10000. +void test_well_funded_stop_admitted_at_level() { + std::printf("-- C2: well-funded stop admitted at level under margin sim --\n"); + StopProbe eng(10000.0, 100.0, 100.0, false); + eng.is_long=false; eng.level=100.0; eng.qty=1.0; + std::vector bars = { + mk(1000, 110,110,110,110), + mk(2000, 105,106, 99,101), // intrabar touch, required 105 << 10000 -> ADMIT @100 + mk(3000, 101,101,101,101), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_entry_price_, 100.0, 1e-9); +} + +} // namespace + +int main() { + std::printf("--- margin_stop_admission (KI-62 stage 3) ---\n"); + test_marginal_short_stop_declined_then_gap_fill(); + test_arm_once_declined_stop_nofill(); + test_marginal_long_stop_gap_declined_then_level_fill(); + test_margin_zero_fills_at_level(); + test_well_funded_stop_admitted_at_level(); + std::printf("\n=== Results: %d passed, %d failed ===\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_short_reversal_emission.cpp b/tests/test_short_reversal_emission.cpp new file mode 100644 index 0000000..1fa5a50 --- /dev/null +++ b/tests/test_short_reversal_emission.cpp @@ -0,0 +1,223 @@ +/* + * test_short_reversal_emission.cpp — KI-72 short-decline emission corruption. + * + * Pins the fix for the emission/accounting split found during the KI-57 fix + * cycle (PARK-DOSSIER D1a): a default-sized percent_of_equity reversal frozen + * at NON-POSITIVE sizing equity (realized + open PnL underwater past the whole + * account — reachable when a held SHORT's unbounded adverse excursion drives + * equity negative while its reversal keeps being declined) produced a NEGATIVE + * frozen_default_qty (apply_qty_step returns qty UNFLOORED for qty <= 0). The + * legacy gate ran only for sizing_equity > 0, so the negative-equity order fell + * through to the fill kernel and opened a NEGATIVE-qty position; every close of + * it then emitted a negative-qty trade row that flipped the exported PnL sign + * and blew the cumulative-PnL column, while the realized net_profit_sum_ stayed + * healthy — the emission/accounting split. On almesned with symmetric-scope + * KI-57 zero-tolerance this collapsed match 90.8% -> 53.4%, EVERY exported qty + * negative, cumulative -122k. + * + * The fix declines such a non-positive-qty open CLEANLY (no fill, no trade row), + * symmetric on both sides — the exact counterpart of a declined long. It fires + * ONLY in the bankrupt regime, so every solvent path is byte-unchanged. + * + * These tests are the stash-cycle REDs base: A1/A2 (the negative-equity reversal + * emits corrupt rows) are RED on baseline 6abebad and GREEN with the fix; the + * controls C1/C2 (solvent reversal opens normally; solvent flat open unchanged) + * pass in BOTH states. + */ + +#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 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 all-in reversal probe. margin_call OFF so a held short can ride +// deeply underwater (its unbounded adverse excursion drives realized + open +// equity NEGATIVE) — the exact state that used to size a reversal negative. +class RevProbe : public BacktestEngine { +public: + RevProbe(double capital, double qty_step, bool mc) { + initial_capital_ = capital; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + pyramiding_ = 1; + margin_long_ = 100.0; + margin_short_ = 100.0; + qty_step_ = qty_step; + set_margin_call_enabled(mc); + } + std::string script; // 'S' short, 'L' long, '.' nothing (one char per bar) + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ < 0 || bar_index_ >= (int)script.size()) return; + switch (script[bar_index_]) { + case 'S': strategy_entry("S", false); break; + case 'L': strategy_entry("L", true); break; + default: break; + } + } + double trade_size(int i) const { return closed_trade_size(i); } + double trade_pnl(int i) const { return closed_trade_profit(i); } + int worst_negative_qty_rows() const { + int n = 0; + for (int i = 0; i < trade_count(); ++i) + if (closed_trade_size(i) < 0.0) ++n; + return n; + } + using BacktestEngine::position_side_; + using BacktestEngine::position_qty_; + using BacktestEngine::net_profit_sum_; + using BacktestEngine::initial_capital_; +}; + +// A1. NEGATIVE-EQUITY REVERSAL declined cleanly — no corrupt rows. A short rides +// underwater until realized+openPnL equity is negative; the short->long reversal +// signalled there froze a NEGATIVE default qty. Baseline OPENS a negative-qty +// long, then the next flip emits a negative-qty trade row (RED). The fix DECLINES +// the non-positive-qty open, leaving the short in place: zero negative rows. +// short 100@100; ramp 150/200/260 -> eq 10000+(100-260)*100 = -6000 (NEG); +// L reversal freezes qty = calc_qty(260) on -6000 = -23.08 <= 0 -> DECLINE. +void test_negative_equity_reversal_declined_clean() { + std::printf("-- A1: negative-equity reversal declined, no negative-qty rows --\n"); + RevProbe eng(10000.0, /*qty_step=*/0.0001, /*mc=*/false); + eng.script = "S....L.S."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // S placed + mk_bar(2000, 100, 100, 100, 100), // short fills @100 + mk_bar(3000, 150, 150, 150, 150), // eq 5000 + mk_bar(4000, 200, 200, 200, 200), // eq 0 + mk_bar(5000, 260, 260, 260, 260), // eq -6000 (NEG) + mk_bar(6000, 260, 260, 260, 260), // L reversal signalled at neg equity + mk_bar(7000, 260, 260, 260, 260), // reversal would fill here + mk_bar(8000, 260, 260, 260, 260), // S re-signalled + mk_bar(9000, 260, 260, 260, 260), // would flip the neg-qty long -> emit + }; + eng.run(bars.data(), (int)bars.size()); + // The whole cluster of reversals is refused; the underwater short rides on. + CHECK(eng.worst_negative_qty_rows() == 0); // RED on baseline (1 neg row) + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK(eng.position_qty_ > 0.0); // never a negative-qty position + // Every emitted trade row carries a POSITIVE size (Pine/TV magnitude column). + for (int i = 0; i < eng.trade_count(); ++i) CHECK(eng.trade_size(i) >= 0.0); +} + +// A2. EMISSION/ACCOUNTING split closed: the exported per-trade PnL (sum) agrees +// with the realized net_profit_sum_. Under the corruption the negative-qty rows +// flipped the exported PnL while net_profit_sum_ stayed healthy; after the fix +// there are no such rows, so the two agree. +void test_exported_pnl_matches_realized() { + std::printf("-- A2: exported per-trade PnL sum == realized net_profit_sum_ --\n"); + RevProbe eng(10000.0, /*qty_step=*/0.0001, /*mc=*/false); + eng.script = "S....L.S.L."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), // short fills @100 + mk_bar(3000, 150, 150, 150, 150), + mk_bar(4000, 200, 200, 200, 200), + mk_bar(5000, 260, 260, 260, 260), // eq negative + mk_bar(6000, 260, 260, 260, 260), // L reversal (declined) + mk_bar(7000, 260, 260, 260, 260), + mk_bar(8000, 260, 260, 260, 260), // S (same dir, noop) + mk_bar(9000, 100, 100, 100, 100), // price recovers + mk_bar(10000, 100, 100, 100, 100), // L reversal (now solvent) fills + mk_bar(11000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + double exported_sum = 0.0; + for (int i = 0; i < eng.trade_count(); ++i) exported_sum += eng.trade_pnl(i); + CHECK(eng.worst_negative_qty_rows() == 0); + CHECK_NEAR(exported_sum, eng.net_profit_sum_, 1e-6); +} + +// C1. CONTROL — a SOLVENT reversal is unaffected: at positive equity the +// short->long flip opens the full all-in long and emits a clean positive-qty +// close of the short. (Passes in BOTH stash states — the fix is a no-op when +// solvent.) +void test_solvent_reversal_opens_normally() { + std::printf("-- C1: solvent reversal opens normally (control) --\n"); + RevProbe eng(10000.0, /*qty_step=*/1.0, /*mc=*/false); + eng.script = "S..L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // S placed + mk_bar(2000, 100, 100, 100, 100), // short fills @100 (eq 10000) + mk_bar(3000, 99, 99, 99, 99), // small favorable move, still solvent + mk_bar(4000, 99, 99, 99, 99), // L reversal signalled + mk_bar(5000, 99, 99, 99, 99), // reversal fills -> LONG opens + mk_bar(6000, 99, 99, 99, 99), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); // the flip happened + CHECK(eng.position_qty_ > 0.0); + CHECK(eng.trade_count() >= 1); // short closed cleanly + CHECK(eng.worst_negative_qty_rows() == 0); +} + +// C2. CONTROL — a SOLVENT true-flat all-in long open is unchanged: opens the +// full floor lot, one entry, no spurious decline. Pins that the KI-72 branch +// does not touch ordinary flat opens. +void test_solvent_flat_open_unchanged() { + std::printf("-- C2: solvent flat all-in open unchanged (control) --\n"); + RevProbe eng(10000.0, /*qty_step=*/1.0, /*mc=*/false); + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // L placed + mk_bar(2000, 100, 100, 100, 100), // long fills @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_qty_, 100.0, 1e-9); // full all-in floor lot + CHECK(eng.trade_count() == 0); +} + +} // namespace + +int main() { + std::printf("--- short_reversal_emission (KI-72) ---\n"); + test_negative_equity_reversal_declined_clean(); + test_exported_pnl_matches_realized(); + test_solvent_reversal_opens_normally(); + test_solvent_flat_open_unchanged(); + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}