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
2 changes: 1 addition & 1 deletion corpus
35 changes: 35 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,22 @@ class BacktestEngine {
// callers that want the legacy hold-to-infinity behaviour.
bool margin_call_enabled_ = true;

// finding-308 margin-call intrabar chronology state. TV places the
// forced-liquidation event chronologically on the synthesized intrabar
// path, so a priced exit that fills strictly AFTER the bar's adverse
// extreme (on the engine's own OHLC path) must let a pre-fill deficit
// slice first. ``last_margin_call_event_bar_`` records the last
// bar_index_ on which ANY margin-call trade row was booked (FX broker-
// open rollover, end-of-bar cascade, or the pre-exit slice); the
// pre-exit hook consults it so at most one forced-liquidation event
// fires per bar. ``intrabar_exit_margin_call_bar_`` is set ONLY by the
// pre-exit slice and tells the end-of-bar process_margin_call that this
// bar's adverse-extreme event was already consumed chronologically (the
// surviving remainder is re-checked from the next bar on, preserving
// TV's one-nibble-per-bar cascade).
int last_margin_call_event_bar_ = -1;
int intrabar_exit_margin_call_bar_ = -1;

// Legacy codegen compatibility bit. Older and current generated classes
// set this when the Pine source contains ``strategy.close`` or
// ``strategy.close_all``. Runtime behavior must not depend on it: a
Expand Down Expand Up @@ -1295,6 +1311,19 @@ class BacktestEngine {
// adverse-price liquidation; only an eligible one-shot post-fill
// affordability event can trim it.
void process_margin_call(const Bar& bar);
// finding-308: chronological pre-exit forced-liquidation slice. Called
// from the process_pending_orders fill loop immediately BEFORE a priced
// exit of the live position is applied. Fires only when (a) no margin
// call was booked on this bar yet, (b) the bar's adverse extreme comes
// STRICTLY earlier on the synthesized intrabar path than the exit's
// fill (a tie — the exit filling exactly at the extreme — keeps the
// exit first), and (c) the pre-fill position is already in margin
// deficit at that extreme. The slice mirrors the adverse-cascade
// trigger/slice arithmetic of process_margin_call byte-for-byte.
// Returns true when a "Margin call" row was booked; the triggering exit
// then fills the reduced remainder.
bool margin_call_slice_before_priced_exit(const Bar& bar,
double exit_fill_price);
// A timestamped FX rollover is a broker-open event, not an end-of-bar
// adverse-price check. Cell A1 supports carried 1x full-margin long and
// short in ordinary historical dispatch; leveraged shapes stay fail-closed.
Expand Down Expand Up @@ -2460,6 +2489,12 @@ class BacktestEngine {
// limit, or the limit leg of an entry stop-limit) — routes the
// fill onto the unslipped limit-or-better price path.
bool is_limit_fill = false;
// True when the fill came from resolve_exit_path_fill's stop/limit
// walk of the intrabar path for an exit-style order (not a TRAIL
// fill, and not a market / same-bar-close-priced exit). Only such
// fills carry a meaningful chronological path position, which the
// finding-308 pre-exit margin-call slice requires.
bool exit_path_fill = false;
};
FillEvaluation evaluate_fill_price(
PendingOrder& order, size_t order_index, const Bar& bar,
Expand Down
188 changes: 187 additions & 1 deletion src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ void BacktestEngine::process_pending_orders(const Bar& bar) {
continue;
}

// finding-308: TV books forced liquidation chronologically on the
// intrabar path. If this priced exit fills strictly AFTER the bar's
// adverse extreme and the pre-fill position is already in deficit
// there, the margin-call slice happens first and the exit below
// closes the reduced remainder. The slice is a broker fill on
// pre-on_bar equity, so re-freeze default-sized market orders
// exactly like the end-of-bar call sites do.
if (fill.exit_path_fill
&& margin_call_slice_before_priced_exit(bar, fill.fill_price)) {
refresh_frozen_default_sizing_after_margin_call();
}

const bool path_winner_stop_margin_decline =
continue_after_stop_margin_decline_scope
&& ((dual_entry_path_ == DualEntryStopPathWinner::LongFirst
Expand Down Expand Up @@ -681,6 +693,7 @@ bool BacktestEngine::process_carried_position_fx_rollover(const Bar& bar) {
if (trades_.size() == trades_before) return false;

++broker_fill_event_seq_;
last_margin_call_event_bar_ = bar_index_; // finding-308: one MC event/bar
for (std::size_t ti = trades_before; ti < trades_.size(); ++ti) {
trades_[ti].exit_comment = "Margin call";
trades_[ti].exit_id = "__margin_call__";
Expand Down Expand Up @@ -870,6 +883,12 @@ void BacktestEngine::process_margin_call(const Bar& bar) {
q_min = qty - opening_equity / margin_per_unit;
raw_exit_fill_base = opening_event_raw_fill_base;
} else {
// finding-308: a chronological pre-exit slice already consumed this
// bar's adverse-extreme forced-liquidation event (the exit that
// triggered it fills the reduced remainder inside the order loop).
// The surviving position is re-checked from the next bar on — TV's
// one-nibble-per-bar cascade.
if (intrabar_exit_margin_call_bar_ == bar_index_) return;
// Shorts and leveraged longs without a fresh opening event keep the
// established adverse-extreme cascade. Equity and required margin are
// account-currency values, so quote-currency price PnL/notional must
Expand Down Expand Up @@ -1034,6 +1053,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) {
}
if (trades_.size() != trades_before) {
++broker_fill_event_seq_;
last_margin_call_event_bar_ = bar_index_; // finding-308: one MC/bar
}
// Tag every trade row this liquidation produced with TV's "Margin call".
for (size_t ti = trades_before; ti < trades_.size(); ++ti) {
Expand All @@ -1047,6 +1067,165 @@ void BacktestEngine::process_margin_call(const Bar& bar) {
run_default_short_adverse_retry();
}

// finding-308 (margin-call intrabar chronology). TradingView places the
// forced-liquidation event chronologically on the synthesized intrabar path.
// When a priced exit of the live position fills on a bar whose adverse
// extreme comes STRICTLY earlier on that path than the exit's fill, and the
// position is already in margin deficit at the extreme, TV slices FIRST (the
// ordinary floor-before-4x nibble, filled at the extreme, tagged
// "Margin call") and the exit then closes the reduced remainder. The
// engine's once-per-bar check at the end of dispatch_bar ran AFTER all order
// processing, so a same-bar full exit hid the deficit (the FLAT early-return
// above) and the event was lost.
//
// The trigger and slice arithmetic below mirror process_margin_call's
// adverse-extreme (non-opening) branch byte-for-byte — the confirmed
// trigger/slice rules themselves are untouched. The derivation (Lab finding
// 308, rhyme17 whole-tape per-position replay) confirmed 3/3 TP-exit
// adverse-first deficit bars produce TV's slices bit-exact through this
// arithmetic (0.0084 / 0.0044 / 0.0384), while both LOW-first large-deficit
// bars (extreme AFTER the exit fill on the path) and 157/158 SL-stop deficit
// bars (stop fills at-or-before the extreme -> tie or earlier -> exit first)
// stay quiet under the chronology condition.
//
// One margin-call event per bar: a prior event this bar (FX broker-open
// rollover, an earlier hook firing, or a stream-tick cascade) blocks the
// hook, and a hook firing marks the bar so the end-of-bar
// process_margin_call does not double-liquidate the survivor. The magnifier
// and the COOF scheduler own finer-grained tick/recalc chronology models and
// keep the established once-per-script-bar placement (no exemplar there).
bool BacktestEngine::margin_call_slice_before_priced_exit(
const Bar& bar, double exit_fill_price) {
if (!margin_call_enabled_) return false;
if (position_side_ == PositionSide::FLAT) return false;
if (last_margin_call_event_bar_ == bar_index_) return false;
if (bar_magnifier_enabled_ || coof_scheduler_active_) return false;

// Eligibility gates, mirroring process_margin_call's finite-price path.
// A 1x long has no adverse-price liquidation (its only broker action is
// the one-shot post-fill affordability event, which stays end-of-bar);
// a POOC position filled at this bar's close has no post-fill adverse
// path on the bar.
const bool opened_this_bar = position_open_bar_ == bar_index_;
const bool long_full_margin =
(position_side_ == PositionSide::LONG)
&& std::isfinite(margin_long_)
&& std::abs(margin_long_ / 100.0 - 1.0) < 1e-12;
if (long_full_margin) return false;
if (process_orders_on_close_ && opened_this_bar) return false;
const double liq = compute_liquidation_price();
if (std::isnan(liq)) return false;

const double pv = syminfo_.pointvalue;
const double qty = position_qty_;
const double margin_pct = (position_side_ == PositionSide::LONG)
? margin_long_ : margin_short_;
const double m = margin_pct / 100.0;
if (!(m > 0.0)) return false;
if (!std::isfinite(qty) || !(qty > 0.0)
|| !std::isfinite(position_entry_price_)
|| !std::isfinite(pv) || !std::isfinite(initial_capital_)
|| !std::isfinite(net_profit_sum_)) {
return false;
}

// (b) Chronology: the adverse extreme must come STRICTLY earlier on the
// engine's own synthesized OHLC path (bar_path_uses_high_first proximity
// rule) than the exit's fill. An off-path level fails closed. A tie —
// the exit filling exactly at the extreme, e.g. a stop-loss riding the
// adverse leg — keeps the exit first.
const double adverse =
(position_side_ == PositionSide::LONG) ? bar.low : bar.high;
if (!std::isfinite(adverse) || !(adverse > 0.0)) return false;
double adverse_pos = 0.0;
double exit_pos = 0.0;
if (!internal::first_touch_position(bar, adverse, &adverse_pos)) {
return false;
}
if (!internal::first_touch_position(bar, exit_fill_price, &exit_pos)) {
return false;
}
if (!(adverse_pos < exit_pos - kPathPosEps)) return false;

// (c) Pre-fill deficit at the extreme: the same fee-net eq/req
// arithmetic as the adverse cascade (the position state is pre-fill
// because the triggering exit has not been applied yet).
const double fx = active_account_currency_fx();
if (!std::isfinite(fx) || !(fx > 0.0)) return false;
const double equity_adv = percent_commission_live_equity(adverse);
if (!std::isfinite(equity_adv)) return false;
const double margin_per_unit_adv = adverse * pv * fx * m;
const double req_margin_adv = qty * margin_per_unit_adv;
if (equity_adv >= req_margin_adv) return false;
double q_min = qty - equity_adv / margin_per_unit_adv;
if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return false;

// Slice quantity: floor-before-4x, representation guards, and the
// floor-zero fallbacks — identical to the cascade (see
// process_margin_call for the fitted evidence on each rule).
const double raw_q_min = q_min;
if (qty_step_ > 0.0) {
double step_count = q_min / qty_step_;
if (margin_zero_cover_full_liquidation_) {
const double nearest_step = std::round(step_count);
if (std::abs(step_count - nearest_step) < 1e-6) {
step_count = nearest_step;
}
}
q_min = std::floor(step_count) * qty_step_;
}
double qty_liq = 4.0 * q_min;
if (qty_step_ > 0.0) {
double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_;
if (floored <= kQtyEpsilon) {
double one_contract_fallback =
std::numeric_limits<double>::quiet_NaN();
if (qty_step_ <= 1.0
&& raw_q_min > kQtyEpsilon
&& raw_q_min < 1.0) {
const double candidate = std::min(1.0, qty);
const bool full_position_cap =
candidate >= qty - kQtyEpsilon;
const double gridded = apply_exit_qty_step(candidate);
const double grid_guard = std::max(
1e-12, std::abs(candidate) * 1e-12);
if (full_position_cap
|| std::abs(gridded - candidate) <= grid_guard) {
one_contract_fallback = candidate;
}
}
if (margin_zero_cover_full_liquidation_) {
floored = qty;
} else if (std::isfinite(one_contract_fallback)) {
floored = one_contract_fallback;
} else {
return false;
}
}
qty_liq = floored;
}
if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty;
if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return false;

const size_t trades_before = trades_.size();
if (qty_liq >= qty - kQtyEpsilon) {
execute_market_exit(adverse);
} else {
execute_partial_exit_qty(
adverse, qty_liq, PositionReductionCause::MARGIN_CALL);
}
if (trades_.size() == trades_before) return false;

++broker_fill_event_seq_;
for (size_t ti = trades_before; ti < trades_.size(); ++ti) {
trades_[ti].exit_comment = "Margin call";
trades_[ti].exit_id = "__margin_call__";
}
last_margin_call_event_bar_ = bar_index_;
intrabar_exit_margin_call_bar_ = bar_index_;
return true;
}

// ────────────────────────────────────────────────────────────────────
// process_pending_orders helpers
// ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -4979,6 +5158,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price(
double fill_price = 0.0;
bool should_fill = false;
bool is_limit_fill = false;
bool exit_path_fill = false;

// A valid child that was armed with its pending MARKET parent and whose
// stop is already breached — or whose limit is already marketable — at
Expand Down Expand Up @@ -5103,6 +5283,12 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price(
should_fill = true;
last_exit_fill_was_trail_ = exit_fill.is_trail;
is_limit_fill = exit_fill.is_limit;
// finding-308: only a stop/limit fill resolved on the intrabar
// path carries a chronological position the pre-exit margin-call
// slice can compare against the adverse extreme. A TRAIL fill
// price is not a resting level (its first path touch is not its
// fill moment), so it stays outside the hook — fail closed.
exit_path_fill = !exit_fill.is_trail;
}
} else if (!should_fill && (order.type == OrderType::MARKET ||
(!has_stop && !has_limit && !has_trail))) {
Expand Down Expand Up @@ -5189,7 +5375,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price(
}

return {should_fill ? FillEvaluation::Kind::Fill : FillEvaluation::Kind::NoFill,
fill_price, is_limit_fill};
fill_price, is_limit_fill, exit_path_fill};
}

} // namespace pineforge
2 changes: 2 additions & 0 deletions src/engine_run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,8 @@ void BacktestEngine::reset_run_state() {
intraday_cap_deferred_close_pending_ = false;
intraday_cap_pooc_close_inheritor_incarnation_ = 0;
broker_fill_event_seq_ = 0;
last_margin_call_event_bar_ = -1; // finding-308: bar-keyed one-shot
intrabar_exit_margin_call_bar_ = -1; // markers must not survive a rerun
coof_scheduler_active_ = false;
coof_fill_recalc_active_ = false;
coof_recalc_at_bar_open_ = false;
Expand Down
14 changes: 10 additions & 4 deletions src/ta_misc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ double Linreg::compute(double src, double offset) {
PercentRank::PercentRank(int length) : length_(length) {}

double PercentRank::compute(double src) {
// Pine parity: keep one slot per bar (na advances the window). Rank uses only non-na
// values in the lookback; denominator is that count, not length (PineTS ta.percentrank).
// Pine parity: keep one slot per bar (na advances the window). Rank counts only
// non-na values in the lookback, but the denominator is always LENGTH, never the
// non-na count — even in the partial (na-lead) warmup window. TV tapes prove
// ta.percentrank emits count/length there (Lab finding 315; trendmatrix pair
// verified byte-exact 100.0/0/0). Dividing by the valid count (the PineTS model)
// mismatches TV as soon as count > 0.
buffer_.push_back(src);
while ((int)buffer_.size() > length_ + 1) {
buffer_.pop_front();
Expand Down Expand Up @@ -109,7 +113,7 @@ double PercentRank::compute(double src) {
if (valid == 0) {
return na<double>();
}
return ((double)count / (double)valid) * 100.0;
return ((double)count / (double)length_) * 100.0;
}

// ============================================================================
Expand Down Expand Up @@ -258,7 +262,9 @@ double PercentRank::recompute(double src) {
if (percentrank_less_equal(v, current)) count++;
}
if (valid == 0) return na<double>();
return ((double)count / (double)valid) * 100.0;
// Denominator is LENGTH, not the non-na count — same TV rule as compute()
// (Lab finding 315).
return ((double)count / (double)length_) * 100.0;
}

// --- BarsSince ---
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ set(TEST_SOURCES
test_metrics
test_drawing
test_margin_call
test_margin_call_intrabar_chronology
test_percent_equity_open_entry_fee
test_streaming
test_calc_on_order_fills
Expand Down
Loading
Loading