diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 300f5aa..f009043 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -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 @@ -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; @@ -769,7 +784,44 @@ class BacktestEngine { } return (c > 0) ? (s / (double)c) : 0.0; } - double margin_liquidation_price() const { return na(); } + // 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(); + const double pv = syminfo_.pointvalue; + const double qty = position_qty_; + if (!(qty > 0.0) || !(pv > 0.0)) return na(); + 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(); + 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; @@ -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; } diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index d10c953..ab23641 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -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 // ──────────────────────────────────────────────────────────────────── diff --git a/src/engine_run.cpp b/src/engine_run.cpp index cd4e060..5528f8f 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -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_); } @@ -301,6 +304,10 @@ void BacktestEngine::run_magnified_bar(const std::vector& 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(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9633e9c..216b85c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -73,6 +73,7 @@ set(TEST_SOURCES test_pointvalue test_metrics test_drawing + test_margin_call ) find_package(Threads REQUIRED) diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp new file mode 100644 index 0000000..95ae045 --- /dev/null +++ b/tests/test_margin_call.cpp @@ -0,0 +1,247 @@ +/* + * test_margin_call.cpp — verify TradingView forced-liquidation (margin call). + * + * Covers the three behaviours of process_margin_call / margin_liquidation_price: + * + * A. A 100%-equity SHORT held through an adverse (rising) move is force- + * liquidated. At least one "Margin call" exit is produced; the first one + * fills at the bar's adverse extreme (HIGH) and closes the documented 4x + * of the margin shortfall (capped at the full position). The reported + * margin_liquidation_price equals the closed-form formula while open. + * + * B. A LONG at the default 100% margin can NEVER be liquidated (the formula + * denominator margin/100 - direction = 0). Even a catastrophic price + * crash produces NO margin call and margin_liquidation_price() == na. + * + * C. A LEVERAGED long (margin_long = 20 => 5x) IS liquidated when price falls + * far enough; the forced exit fills at the bar's adverse extreme (LOW). + * + * D. The margin-call emulator can be switched off (set_margin_call_enabled + * false); the underwater short is then held with no forced exit. + * + * Engine fill timing here uses process_orders_on_close = true so the market + * entry fills at the signal bar's close, mirroring the p2 probe. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) < tol; +} + +namespace { + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk_bar(int64_t ts, double o, double h, double l, double c, double v) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; b.volume = v; b.timestamp = ts; + return b; +} + +// Thin base exposing the protected closed-trade accessors / liquidation price +// for the test harness (these are protected on BacktestEngine, accessible only +// from subclasses). +class MCEngine : public BacktestEngine { +public: + std::string exit_comment(int i) const { return closed_trade_exit_comment(i); } + double exit_price(int i) const { return closed_trade_exit_price(i); } + double entry_price(int i) const { return closed_trade_entry_price(i); } + double trade_size(int i) const { return closed_trade_size(i); } + double liq_price() const { return margin_liquidation_price(); } +}; + +// ---- A: 100%-equity short force-liquidated by a rising market -------------- + +class ShortLiqProbe : public MCEngine { +public: + bool disable_mc_ = false; + explicit ShortLiqProbe(bool disable_mc = false) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; // size the short at 100% of equity + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_short_ = 100.0; // 1x, default TV margin + process_orders_on_close_ = true; // market entry fills at bar close + disable_mc_ = disable_mc; + if (disable_mc_) set_margin_call_enabled(false); + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + // Short the whole account; never exit. Fills at bar0 close = 100. + strategy_entry("S", false, kNaN, kNaN, kNaN); + } + } +}; + +static void test_short_margin_call() { + std::printf("test_short_margin_call\n"); + + // bar0 entry @ close=100 (qty = 1000/100 = 10, notional 1000 = equity). + // liqPrice (short, 100% margin) = ((1000/10) + 100) / 2 = 100. + // bar1 small rise: high=105 > liq=100 -> partial 4x liquidation @ high=105. + // equity@105 = 1000 - (105-100)*10 = 950; reqMargin@105 = 10*105 = 1050. + // qmin = 10 - 950/105 = 0.952381; 4x = 3.809524 (< 10) -> partial fill. + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), // 0: short fills @100 + mk_bar(2000, 101.0, 105.0, 100.5, 104.0, 1.0), // 1: high 105 -> margin call + mk_bar(3000, 104.0, 130.0, 103.0, 128.0, 1.0), // 2: high 130 -> further call + mk_bar(4000, 128.0, 140.0, 127.0, 139.0, 1.0), // 3: keep rising + }; + + ShortLiqProbe eng; + // Margin-call price while the full 10@100 short is open. + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() >= 1); + // Every closed trade on this no-exit strategy must be a forced liquidation. + bool all_margin = true; + for (int i = 0; i < eng.trade_count(); ++i) { + if (eng.exit_comment(i) != std::string("Margin call")) + all_margin = false; + } + CHECK(all_margin); + + // First liquidation: fills at bar1's adverse extreme (high = 105) and + // closes ~3.8095 contracts (4x the shortfall), leaving the position open. + CHECK(near(eng.exit_price(0), 105.0)); + CHECK(near(eng.entry_price(0), 100.0)); + CHECK(near(eng.trade_size(0), 3.80952381, 1e-4)); + CHECK(eng.exit_comment(0) == std::string("Margin call")); +} + +static void test_margin_liquidation_price_formula() { + std::printf("test_margin_liquidation_price_formula\n"); + + // Re-run only the entry bar (no adverse move yet) and read the formula + // before any liquidation: short 10 @ 100, equity 1000, margin 100% -> + // liqPrice = ((1000/10) + 100) / 2 = 100. + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), // 0: short fills @100 + mk_bar(2000, 100.0, 100.0, 99.5, 100.0, 1.0), // 1: no breach (high == liq) + }; + ShortLiqProbe eng; + eng.run(bars.data(), (int)bars.size()); + // No adverse move above 100 -> no margin call, position still open. + CHECK(eng.trade_count() == 0); + CHECK(near(eng.liq_price(), 100.0)); +} + +static void test_short_margin_call_disabled() { + std::printf("test_short_margin_call_disabled\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 101.0, 105.0, 100.5, 104.0, 1.0), + mk_bar(3000, 104.0, 200.0, 103.0, 199.0, 1.0), // huge adverse move + }; + ShortLiqProbe eng(/*disable_mc=*/true); + eng.run(bars.data(), (int)bars.size()); + // With the emulator off the underwater short is simply held: no exits. + CHECK(eng.trade_count() == 0); +} + +// ---- B: long at 100% margin is never liquidated ---------------------------- + +class LongNoLiqProbe : public MCEngine { +public: + LongNoLiqProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + margin_long_ = 100.0; // 1x -> denominator (1 - 1) = 0 -> na + process_orders_on_close_ = true; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) strategy_entry("L", true, kNaN, kNaN, kNaN); + } +}; + +static void test_long_100pct_margin_no_call() { + std::printf("test_long_100pct_margin_no_call\n"); + std::vector bars = { + mk_bar(1000, 100.0, 101.0, 99.0, 100.0, 1.0), // 0: long fills @100 + mk_bar(2000, 100.0, 100.0, 10.0, 20.0, 1.0), // 1: -90% crash + mk_bar(3000, 20.0, 21.0, 1.0, 2.0, 1.0), // 2: keeps crashing + }; + LongNoLiqProbe eng; + eng.run(bars.data(), (int)bars.size()); + // A long at 100% margin can never be margin-called: position is held. + CHECK(eng.trade_count() == 0); + // The accessor must report na (no liquidation price exists). + CHECK(std::isnan(eng.liq_price())); +} + +// ---- C: leveraged long (5x) is liquidated by a falling market -------------- + +class LongLevLiqProbe : public MCEngine { +public: + LongLevLiqProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 20.0; // 20 @ 100 = 2000 notional = 2x equity + commission_value_ = 0.0; + margin_long_ = 50.0; // 50% margin -> 2x limit; at the edge + process_orders_on_close_ = true; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) strategy_entry("L", true, kNaN, kNaN, 20.0); + } +}; + +static void test_long_leveraged_margin_call() { + std::printf("test_long_leveraged_margin_call\n"); + // long 20 @ 100, equity 1000, margin 50% -> notional 2000 at the 2x limit. + // liqPrice = ((1000/20) - 100) / (0.5 - 1) = (50 - 100)/(-0.5) = 100. + // A fall below 100 triggers a forced exit at the bar's LOW. + std::vector bars = { + mk_bar(1000, 100.0, 101.0, 99.5, 100.0, 1.0), // 0: long fills @100 + mk_bar(2000, 100.0, 100.0, 95.0, 96.0, 1.0), // 1: low 95 < liq 100 + mk_bar(3000, 96.0, 97.0, 80.0, 82.0, 1.0), // 2: deeper fall + }; + LongLevLiqProbe eng; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() >= 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + // First forced exit fills at bar1's adverse extreme (low = 95). + CHECK(near(eng.exit_price(0), 95.0)); + CHECK(near(eng.entry_price(0), 100.0)); +} + +} // namespace + +int main() { + test_short_margin_call(); + test_margin_liquidation_price_formula(); + test_short_margin_call_disabled(); + test_long_100pct_margin_no_call(); + test_long_leveraged_margin_call(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return (tests_failed > 0) ? 1 : 0; +}