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
78 changes: 74 additions & 4 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,36 @@ struct PendingOrder {
// flips every one of them. Keep ``qty`` NaN; read this field only where a
// quantity is actually computed.
double frozen_default_qty = std::numeric_limits<double>::quiet_NaN();
// TV margin-admission snapshot for a FROZEN default-sized market order
// (KI-54). Captured at the same placement point as frozen_default_qty:
// sizing_equity = current_equity() + open_profit(close(S)) [account ccy]
// sizing_price = close(S) + slippage*mintick*(+1 buy/-1 sell)
// At fill time the broker re-checks (see the gate in
// apply_filled_order_to_state for the full evidence trail)
// |qty| * admit_price * pointvalue * fx * margin_pct/100
// <= free_funds = sizing_equity - (same-direction held margin)
// where admit_price is the SIZING price for flat opens and adds but the
// FILL price for a true reversal (opposite position still open when the
// order processes), and silently drops the order when it fails (no
// 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.
// 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 ==
// |sizing_equity| while free_funds < 0 — every order, flat opens
// included, would be declined forever). The re-check is restricted
// accordingly; orders outside it carry the snapshot and are admitted.
// NaN = no snapshot, no re-check.
double sizing_equity = std::numeric_limits<double>::quiet_NaN();
double sizing_price = std::numeric_limits<double>::quiet_NaN();
// The bar close sizing_equity was marked at. free_funds subtracts the
// margin the OPEN position ties up, and that must be marked at the same
// price the equity was, or the two sides of the comparison mix a
// 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();
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
Expand Down Expand Up @@ -930,12 +960,48 @@ class BacktestEngine {
// Priced (limit/stop) entries are NOT frozen: TV's sizing basis for an
// order armed one or more bars before its fill is not empirically
// established, so they conservatively keep the legacy fill-time sizing.
double frozen_default_market_qty(bool is_buy) const {
// The sizing price of the frozen rule above, exposed separately so the
// placement sites can persist it on the order (PendingOrder::sizing_price)
// for the fill-time margin-admission re-check.
double frozen_sizing_price(bool is_buy) const {
double sizing_price = current_bar_.close;
if (slippage_ != 0 && syminfo_mintick_ > 0.0) {
sizing_price += (is_buy ? 1.0 : -1.0) * slippage_ * syminfo_mintick_;
}
return calc_qty(sizing_price);
return sizing_price;
}

double frozen_default_market_qty(bool is_buy) const {
return calc_qty(frozen_sizing_price(is_buy));
}

// KI-54 defect fix: the frozen sizing snapshot must see POST-liquidation
// equity. TradingView liquidates intrabar, BEFORE the bar-close script
// body runs; the engine's process_margin_call runs at the END of
// dispatch_bar, AFTER on_bar placed (and froze) this bar's default-sized
// market orders. When a margin call fires on the placement bar, the
// frozen qty was computed on pre-liquidation equity — over-sized, so the
// next bar's fill opens a position whose notional exceeds equity and the
// long_full_margin branch of process_margin_call then emits a phantom
// LONG margin call TV does not have. Rather than moving process_margin_call
// (which would change what strategy.equity reads inside on_bar for every
// strategy), the dispatch loop calls this refresh right after a margin
// call actually liquidated something: every still-pending frozen
// default-sized market order placed on THIS bar is re-frozen on the
// post-liquidation state. Strict no-op on bars without a margin call
// (the caller checks), and bit-identical recompute for untouched state.
void refresh_frozen_default_sizing_after_margin_call() {
for (auto& o : pending_orders_) {
if (std::isnan(o.frozen_default_qty)) continue;
if (o.type != OrderType::MARKET && o.type != OrderType::RAW_ORDER)
continue;
if (o.created_bar != bar_index_) continue;
o.frozen_default_qty = calc_qty(o.sizing_price);
if (!std::isnan(o.sizing_equity)) {
o.sizing_equity =
current_equity() + open_profit(current_bar_.close);
}
}
}

// --- Strategy variable accessors ---
Expand Down Expand Up @@ -997,8 +1063,12 @@ class BacktestEngine {
// strategy.margin_liquidation_price — the price at which TradingView's
// broker emulator force-liquidates the current open position. Returns na
// when flat, when the instrument has no valid size/point-value, or when
// ``margin/100 - direction == 0`` (a long at 100% margin can never be
// liquidated). See compute_liquidation_price for the derivation.
// ``margin/100 - direction == 0`` (a 1x long has no leverage-derived
// liquidation PRICE — but it CAN still be force-liquidated when its
// notional exceeds equity: process_margin_call's long_full_margin
// branch deliberately bypasses the na bail for exactly that case, and
// TV emits such long margin calls too). See compute_liquidation_price
// for the derivation.
double margin_liquidation_price() const { return compute_liquidation_price(); }

// Shared liquidation-price formula (TradingView docs, validated against the
Expand Down
156 changes: 156 additions & 0 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,162 @@ void BacktestEngine::apply_filled_order_to_state(
}
}

// KI-54: TradingView fill-time margin admission for FROZEN default-sized
// market orders (the snapshot fields are captured at placement — see
// PendingOrder::sizing_equity/sizing_price, engine.hpp):
//
// same_dir = position open AND order direction matches it
// reversal = position open AND order direction opposes it
// free_funds = same_dir ? sizing_equity - held_margin : sizing_equity
// admit_price = reversal ? slipped(fill_price) : sizing_price
// required = |qty| * admit_price * pointvalue * fx * margin_pct/100
// drop iff required > free_funds + eps (silently: no trade row)
//
// eps absorbs double rounding AND one whole lot of notional: the quantity
// was floored to the lot step, so a decline whose margin is under one
// lot's worth of budget is decided by where the floor landed, not by
// affordability.
//
// Admission price, by position state at the fill:
// - FLAT open (incl. close-then-reenter, where the strategy.close leg
// 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
// gap-up entries that exact-count close-then-reenter strategies
// demonstrably take.
// - 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
// replicas of all-in flip strategies: an all-in flip's sizing
// notional sits within lot-floor slack of equity, so once the
// fill gap pushes the requirement past equity TV silently drops
// the flip. Exports of such strategies contain no gap-up flip
// fill at all, on a feed where roughly half the bars gap; the
// ungated engine took every one.
// - SAME-direction add: the sizing notional, against free funds —
// the held position keeps its capital committed, so an all-in add
// sees free_funds ~= 0 and declines (TV performs no such adds even
// where pyramiding permits them), while a fractional add
// (pct=10, held ~= 0.1*equity) still fills.
//
// Scope: the re-check runs ONLY for percent_of_equity default sizing
// with pct <= 100 — the one regime where the floor invariant above
// exists AND TV ground truth pins the behavior. CASH default sizing
// has NO equity term (cash/(price*pv)), so required is unbounded by
// sizing_equity and the gate would decline perfectly ordinary flat
// opens whenever cash_value > equity (a real transpiled cash-20k-on-
// 10k-capital probe lost all 73 of its trades to it). The same
// applies to pct > 100 (leveraged sizing). Neither regime has TV
// ground truth; frozen CASH / pct>100 orders keep their freeze but
// are admitted unconditionally here.
//
// Frozen MARKET entries and frozen RAW market orders are checked; an
// opposite-direction RAW fill only CLOSES the position
// (apply_raw_order_fill's exit branch) and is never dropped.
// Explicit-qty entries keep the signal-time gate in strategy_entry;
// 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.
// 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.
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
&& default_qty_type_ == QtyType::PERCENT_OF_EQUITY
&& default_qty_value_ <= 100.0
&& (order.type == OrderType::MARKET
|| order.type == OrderType::RAW_ORDER)) {
bool same_dir = position_side_ != PositionSide::FLAT
&& ((position_side_ == PositionSide::LONG) == order.is_long);
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_;
// 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
// two candidate rules give OPPOSITE verdicts on the add — mark-to-
// market admits it only when the open lot is UNDERWATER, cost basis
// only when it is IN PROFIT — and TV admitted 1535/1538 adds while
// underwater (2 in profit, float-noise), i.e. mark-to-market. The
// held side below uses that basis. (An earlier revision exempted the
// fractional add for lack of ground truth; the probe removes the
// ambiguity and TV declines the in-profit adds the exemption let
// through.)
//
// margin_pct > 100 breaks the flat-open invariant outright
// (required = equity * pct/100 * margin/100 > equity), which would
// silently drop every flat open. Leverage below 1x has no TV pin.
bool leverage_below_1x = margin_pct > 100.0;
if (!raw_opposite_close && !leverage_below_1x && margin_pct > 0.0) {
// The margin the OPEN position ties up, marked at the SAME price
// sizing_equity was marked at (the signal bar's close). Only the
// all-in add reaches this (see unpinned_fractional_add), where
// every convention agrees; marking it at cost basis instead —
// |qty * entry_price| — would leave
// free_funds = cash + open_profit rather than free margin, so the
// admission threshold would drift with unrealized PnL in the wrong
// direction: an underwater add gets declined while a profitable one
// gets admitted and then immediately margin-called. This also keeps
// the gate consistent with process_margin_call, which marks the
// required margin to the current price. Scaled by the same
// margin_pct/100 the required side carries; at margin 100 (every
// specimen we have) the scaling is a no-op.
double held = same_dir
? std::abs(position_qty_) * order.sizing_mark
* syminfo_.pointvalue * account_currency_fx_
* (margin_pct / 100.0)
: 0.0;
double free_funds = order.sizing_equity - held;
// Price the reversal at the price the fill kernel will actually
// book. ``fill_price`` here is still unslipped, while
// ``sizing_price`` already carries the slippage adjustment (see
// frozen_default_market_qty), so comparing the raw fill price
// against a slipped budget mixes two conventions and declines
// even a zero-gap reversal whenever slippage_ != 0.
double admit_price = reversal
? apply_fill_slippage(fill_price, order.is_long)
: order.sizing_price;
double required_margin = std::abs(order.frozen_default_qty)
* admit_price
* syminfo_.pointvalue
* account_currency_fx_
* (margin_pct / 100.0);
// The epsilon absorbs double rounding AND one whole lot of
// notional.
//
// The quantity was floored to the lot step, so the budget it left
// unspent is an unobservable remainder anywhere in
// [0, qty_step * price). A decline whose margin is smaller than
// that remainder is not a decision about affordability at all —
// it is a coin flip on where the floor happened to land. On a
// continuous feed nearly half of all bars gap by exactly one
// mintick, so without this term the reversal branch resolves such
// bars by lot-floor luck: deterministic at large equity, arbitrary
// at small. Widening by one lot keeps every decline that TV's
// exports actually confirm (their margins exceed a lot of
// notional) and drops the ones no ground truth supports.
double epsilon =
std::max(1e-9, std::abs(free_funds) * 1e-12);
epsilon = std::max(epsilon, qty_step_ * admit_price
* syminfo_.pointvalue
* account_currency_fx_
* (margin_pct / 100.0));
if (required_margin > free_funds + epsilon) {
filled_indices.push_back(order_index);
return;
}
}
}

// Check max_intraday_filled_orders limit.
//
// TV's broker emulator (LATCH-TILL-DAY-ROLLOVER semantics):
Expand Down
23 changes: 21 additions & 2 deletions src/engine_run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,19 @@ void BacktestEngine::dispatch_bar() {
}
// TradingView forced-liquidation check, once per script bar after all order
// processing, using this bar's full adverse extreme (high/low).
process_margin_call(current_bar_);
//
// TV liquidates INTRABAR — before the close-time script body — so any
// default-sized market order frozen by this bar's on_bar was sized on
// pre-liquidation equity. When (and only when) the margin call actually
// liquidated something, re-freeze those orders on the post-liquidation
// state (see refresh_frozen_default_sizing_after_margin_call).
{
size_t trades_before_mc = trades_.size();
process_margin_call(current_bar_);
if (trades_.size() != trades_before_mc) {
refresh_frozen_default_sizing_after_margin_call();
}
}
}


Expand Down Expand Up @@ -316,7 +328,14 @@ void BacktestEngine::run_magnified_bar(const std::vector<Bar>& sub_bars, int64_t
// TradingView forced-liquidation check, once per script bar. By the final
// sub-bar current_bar_.high/.low hold the full script-bar adverse extreme,
// and current_bar_.timestamp was restored to the script-bar open ts above.
process_margin_call(current_bar_);
// Same post-liquidation re-freeze as the non-magnifier path (dispatch_bar).
{
size_t trades_before_mc = trades_.size();
process_margin_call(current_bar_);
if (trades_.size() != trades_before_mc) {
refresh_frozen_default_sizing_after_margin_call();
}
}
finalize_bar();
}

Expand Down
15 changes: 15 additions & 0 deletions src/engine_strategy_commands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,13 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
|| default_qty_type_ == QtyType::CASH)
&& !std::isnan(current_bar_.close)) {
order.frozen_default_qty = frozen_default_market_qty(/*is_buy=*/is_long);
// KI-54: persist the sizing basis for the fill-time TV margin
// admission re-check (see PendingOrder::sizing_equity in
// engine.hpp and the gate in apply_filled_order_to_state).
order.sizing_price = frozen_sizing_price(/*is_buy=*/is_long);
order.sizing_equity =
current_equity() + open_profit(current_bar_.close);
order.sizing_mark = current_bar_.close;
}
} else {
order.type = OrderType::ENTRY;
Expand Down Expand Up @@ -711,6 +718,14 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double
|| default_qty_type_ == QtyType::CASH)
&& !std::isnan(current_bar_.close)) {
order.frozen_default_qty = frozen_default_market_qty(/*is_buy=*/is_long);
// KI-54: same admission snapshot as strategy_entry's MARKET
// branch. The fill-time gate skips opposite-direction RAW fills
// (they only close the position) — see
// apply_filled_order_to_state.
order.sizing_price = frozen_sizing_price(/*is_buy=*/is_long);
order.sizing_equity =
current_equity() + open_profit(current_bar_.close);
order.sizing_mark = current_bar_.close;
}
} else {
order.type = OrderType::RAW_ORDER;
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ set(TEST_SOURCES
test_c_abi_setters
test_fills_edge
test_default_qty_signal_freeze
test_margin_admission_gate
test_limit_fill_slippage
test_strategy_commands_extra
test_multi_tier_exit_precedence
Expand Down
Loading
Loading