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
59 changes: 58 additions & 1 deletion include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,15 @@ class BacktestEngine {
double margin_long_ = 100.0;
double margin_short_ = 100.0;

// TradingView force-liquidation (margin call) toggle. TV runs the broker
// margin-call emulator by default, so this defaults ON to match TV. It is
// a no-op for the validation corpus (long-only positions at the default
// 100% margin can never be liquidated — the formula denominator
// ``margin/100 - direction`` is 0 — and no corpus short is sized at full
// equity), and can be switched off via ``set_margin_call_enabled`` for
// callers that want the legacy hold-to-infinity behaviour.
bool margin_call_enabled_ = true;

// True iff the user's strategy.pine contains at least one
// ``strategy.close`` or ``strategy.close_all`` call (compile-time
// determined by the codegen, set in the generated class's
Expand Down Expand Up @@ -591,6 +600,12 @@ class BacktestEngine {

void process_pending_orders(const Bar& bar);

// TradingView forced-liquidation (margin call). Run once per script bar
// after order processing: if the bar's adverse extreme (high for shorts,
// low for longs) breaches the open position's required margin, force-close
// 4x the minimum qty needed to restore margin at the liquidation price.
void process_margin_call(const Bar& bar);

// --- Slippage helper ---
double round_to_mintick(double price) const {
if (std::isnan(price) || syminfo_mintick_ <= 0.0) return price;
Expand Down Expand Up @@ -769,7 +784,44 @@ class BacktestEngine {
}
return (c > 0) ? (s / (double)c) : 0.0;
}
double margin_liquidation_price() const { return na<double>(); }
// 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.
double margin_liquidation_price() const { return compute_liquidation_price(); }

// Shared liquidation-price formula (TradingView docs, validated against the
// p2 margin-call probe and the leverage-margin-call-perp-5x corpus probe):
//
// liqPrice = ((initial_capital + net_profit) / (pointvalue * |size|)
// - direction * entry) / (margin_pct/100 - direction)
//
// direction = +1 long / -1 short; net_profit = realized closed-trade PnL;
// entry = current average entry price; size = open position size.
//
// Rounded UP to mintick for shorts, DOWN for longs (TV convention).
double compute_liquidation_price() const {
if (position_side_ == PositionSide::FLAT) return na<double>();
const double pv = syminfo_.pointvalue;
const double qty = position_qty_;
if (!(qty > 0.0) || !(pv > 0.0)) return na<double>();
const double direction = (position_side_ == PositionSide::LONG) ? 1.0 : -1.0;
const double margin_pct = (position_side_ == PositionSide::LONG)
? margin_long_ : margin_short_;
const double denom = (margin_pct / 100.0) - direction;
// A long at 100% margin (denom == 0) cannot be liquidated.
if (std::abs(denom) < 1e-12) return na<double>();
const double equity_basis = initial_capital_ + net_profit_sum_;
double liq = (equity_basis / (qty * pv) - direction * position_entry_price_)
/ denom;
if (syminfo_mintick_ > 0.0) {
liq = (position_side_ == PositionSide::SHORT)
? std::ceil(liq / syminfo_mintick_) * syminfo_mintick_
: std::floor(liq / syminfo_mintick_) * syminfo_mintick_;
}
return liq;
}
double open_trades_capital_held() const {
if (position_side_ == PositionSide::FLAT) return 0.0;
return std::abs(position_qty_ * position_entry_price_) * syminfo_.pointvalue;
Expand Down Expand Up @@ -1468,6 +1520,11 @@ class BacktestEngine {
// harness sets a non-default instrument.
void set_syminfo_mintick(double m) { if (m > 0.0) { syminfo_.mintick = m; syminfo_mintick_ = m; } }
void set_syminfo_pointvalue(double pv) { if (pv > 0.0) { syminfo_.pointvalue = pv; } }

// Toggle TradingView's forced-liquidation (margin call) emulation. Defaults
// ON to match TV; set false for the legacy hold-the-position behaviour.
void set_margin_call_enabled(bool enabled) { margin_call_enabled_ = enabled; }
bool margin_call_enabled() const { return margin_call_enabled_; }
void set_syminfo_metadata(const std::string& key, double value) {
syminfo_metadata_[key] = value;
}
Expand Down
99 changes: 99 additions & 0 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,105 @@ void BacktestEngine::process_pending_orders(const Bar& bar) {
}
}

// TradingView force-liquidation (margin call).
//
// Run once per script bar (end of dispatch_bar / magnifier bar) after all
// order processing. While a position is open, TV's broker emulator checks
// whether strategy equity still covers the position's required margin using
// the bar's ADVERSE extreme (bar HIGH for shorts, bar LOW for longs). When the
// adverse extreme reaches/passes the liquidation price the emulator force-
// closes part of the position:
//
// - fill price = the bar's ADVERSE extreme (high for shorts, low for longs),
// which is what TV's exported margin-call rows report — not the bare
// liquidation level (the liq price only gates whether a call happens).
// - quantity = 4x the minimum amount needed to restore margin at the adverse
// extreme, capped at the full position. The documented 4x over-liquidation
// prevents a margin call recurring on every subsequent bar and produces
// TV's iterative "nibble" pattern (a deep-underwater position closes in
// several 4x chunks across bars).
// - the resulting trade rows are tagged with the "Margin call" exit comment.
//
// Validated against the p2 margin-call short probe (TV: 68 margin calls, first
// at ~1798.26) and the leverage-margin-call-perp-5x long probe.
void BacktestEngine::process_margin_call(const Bar& bar) {
if (!margin_call_enabled_) return;
if (position_side_ == PositionSide::FLAT) return;

// No post-entry adverse path exists on a bar where the position opened at
// the bar CLOSE: with process_orders_on_close, market entries fill at the
// close, so there is no remaining intrabar movement for price to breach
// margin on the entry bar itself (TV emits the first margin call on the
// NEXT bar — p2 probe: entry 15:30, first call 15:45). When entries fill at
// the bar OPEN (process_orders_on_close=false) the full bar path IS
// post-entry, so a same-bar liquidation is allowed (leverage probe: entry
// and first margin call land on the same bar).
if (process_orders_on_close_ && position_open_bar_ == bar_index_) return;

const double liq = compute_liquidation_price();
if (std::isnan(liq)) return; // flat / denom==0 / invalid size already filtered

const double pv = syminfo_.pointvalue;
const double qty = position_qty_;
const double direction = (position_side_ == PositionSide::LONG) ? 1.0 : -1.0;
const double margin_pct = (position_side_ == PositionSide::LONG)
? margin_long_ : margin_short_;
const double m = margin_pct / 100.0;
if (!(m > 0.0)) return;
// Adversarial / degenerate feeds (NaN/Inf prices, non-finite state) must
// never let a non-finite value escape into a trade record.
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;
}

// Adverse extreme: shorts lose as price rises (bar high); longs lose as
// price falls (bar low).
const double adverse = (position_side_ == PositionSide::LONG) ? bar.low : bar.high;
if (!std::isfinite(adverse) || !(adverse > 0.0)) return;

// Equity at the adverse extreme = capital + realized PnL + open P/L on the
// FULL position. Required margin = position value at the adverse price.
const double equity_adv = initial_capital_ + net_profit_sum_
+ direction * (adverse - position_entry_price_) * qty * pv;
const double req_margin_adv = qty * adverse * pv * m;
if (equity_adv >= req_margin_adv) return; // margin still covered — no call

// Minimum qty whose removal restores margin at the adverse extreme. Closing
// q units leaves the bar-equity unchanged (realized + open P/L just
// reclassify) while the required margin shrinks: need
// (qty - q) * adverse * pv * m <= equity_adv => q >= qty - equity_adv/(adverse*pv*m).
const double q_min = qty - equity_adv / (adverse * pv * m);
if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return;
double qty_liq = 4.0 * q_min;
if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty; // cap at the whole position
if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return;

// Fill at the bar's adverse extreme. TradingView's exported margin-call
// rows fill at the bar HIGH (shorts) / LOW (longs) — the worst price the
// bar reached after the liquidation level was crossed — NOT at the bare
// liquidation price. Verified row-for-row against the p2 probe TV export
// (first short call fills at 1798.26 = that bar's high, with
// liq=1793.12). ``liq`` above is used only to gate na/denominator cases.
// ``adverse`` already lies within [low, high]. current_fill_is_limit_
// stays false so execute_market_exit / execute_partial_exit_qty apply
// market (slipped) economics.
const double fill = adverse;

const size_t trades_before = trades_.size();
if (qty_liq >= qty - kQtyEpsilon) {
execute_market_exit(fill);
} else {
execute_partial_exit_qty(fill, qty_liq);
}
// Tag every trade row this liquidation produced with TV's "Margin call".
for (size_t ti = trades_before; ti < trades_.size(); ++ti) {
trades_[ti].exit_comment = "Margin call";
trades_[ti].exit_id = "__margin_call__";
}
}

// ────────────────────────────────────────────────────────────────────
// process_pending_orders helpers
// ────────────────────────────────────────────────────────────────────
Expand Down
7 changes: 7 additions & 0 deletions src/engine_run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ void BacktestEngine::dispatch_bar() {
update_per_trade_extremes();
on_bar(current_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_);
}


Expand Down Expand Up @@ -301,6 +304,10 @@ 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_);
finalize_bar();
}

Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ set(TEST_SOURCES
test_pointvalue
test_metrics
test_drawing
test_margin_call
)

find_package(Threads REQUIRED)
Expand Down
Loading
Loading