Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<stop) sizes qty ~= equity/level and
// qty*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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions tests/test_explicit_qty_fill_admission.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -322,12 +324,18 @@ void test_greenF_priced_and_raw_unaffected() {
eng.script = "P..";
std::vector<Bar> 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");
{
Expand Down
28 changes: 15 additions & 13 deletions tests/test_margin_admission_gate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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..";
Expand All @@ -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:
Expand Down Expand Up @@ -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();
Expand Down
29 changes: 20 additions & 9 deletions tests/test_margin_call.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
Loading
Loading