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
26 changes: 19 additions & 7 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==
Expand All @@ -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<double>::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
Expand Down
84 changes: 79 additions & 5 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions src/engine_orders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
43 changes: 30 additions & 13 deletions src/engine_strategy_commands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <expr>`` 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 = <expr>`` 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)) {
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 18 additions & 21 deletions tests/test_default_qty_signal_freeze.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Bar> bars = {
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading