From 983031824d6fd1d8fcb2cd8c0b3e8b33b154064c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 30 Jun 2026 19:07:26 +0800 Subject: [PATCH] feat(orders): quantize margin-call liquidation lots to the symbol qty-step TradingView floors each margin-call liquidation to the instrument's quantity step (verified: every TV margin-call lot in the probe data is an exact multiple of 0.0004 for ETHUSDT.P). The engine previously liquidated unquantized lots, so its nibbles were slightly mis-sized and the per-call exit prices drifted from TV. - syminfo gains qty_step (default 0.0 = disabled -> corpus no-op). Plumbed via the existing strategy_set_syminfo_metadata channel ("qty_step"), so NO new PF_API / C-ABI export is added (C-ABI CI gate unaffected). - process_margin_call(): when qty_step_ > 0, floor the liquidation quantity down to the nearest qty_step multiple; if it floors to 0 while a call is required, liquidate min(qty_step, residual) to guarantee progress; cap at the full position. - scripts/run_strategy.py reads runtime_overrides.qty_step into the metadata channel. Validated: corpus verify_corpus --all = 251 excellent / 1 anomaly, byte-identical to HEAD with default qty_step=0 (proven: regenerated all 1628 corpus trade files, 0 lines differ). ctest 76/76 incl. a new quantization test (teeth: mutating the guard -> 4 failures). With qty_step=0.0004 the margin-call lots become exact 0.0004 multiples and abhivish strict match 63.7->66.4%, pridarasx 79.9->80.8% (price-exactness held), confirming alignment with TV's per-instrument lot rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/pineforge/engine.hpp | 21 +++++++++++++ scripts/run_strategy.py | 13 ++++++++ src/engine_fills.cpp | 17 +++++++++++ src/engine_run.cpp | 5 ++++ tests/test_margin_call.cpp | 58 +++++++++++++++++++++++++++++++++++- 5 files changed, 113 insertions(+), 1 deletion(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index f009043..9334e6d 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -226,6 +226,13 @@ struct SymInfo { std::string description = ""; double mintick = 0.01; double pointvalue = 1.0; + // Per-instrument quantity step (syminfo.* "qty_step" — the smallest + // tradable lot increment, e.g. 0.0004 for BINANCE:ETHUSDT.P). 0 = disabled + // (the engine default), so no quantity quantization is applied — corpus + // instruments leave this 0 and are byte-identical. Only the forced- + // liquidation (margin call) path floors its computed lot to this step to + // mirror TradingView, which nibbles the position in exact lot multiples. + double qty_step = 0.0; }; struct StrategyOverrides { @@ -271,6 +278,12 @@ class BacktestEngine { double commission_value_ = 0.0; int slippage_ = 0; // slippage in ticks double syminfo_mintick_ = 0.01; // tick size for slippage calculation + // Per-instrument lot-size step for forced-liquidation quantization. + // 0 = disabled (default; corpus no-op). Fed via the syminfo_metadata + // channel ("qty_step") or the SymInfo struct on the explicit run() path. + // process_margin_call floors each liquidation lot DOWN to a multiple of + // this, matching TradingView's per-instrument margin-call lot sizing. + double qty_step_ = 0.0; int max_intraday_filled_orders_ = 0; // 0 = unlimited bool close_entries_rule_any_ = false; // true = "ANY", false = "FIFO" (default) // Percentage of margin required to open a long/short position. Default @@ -1527,6 +1540,14 @@ class BacktestEngine { bool margin_call_enabled() const { return margin_call_enabled_; } void set_syminfo_metadata(const std::string& key, double value) { syminfo_metadata_[key] = value; + // "qty_step" is the per-instrument lot increment used by the forced- + // liquidation quantizer. Route it onto the dedicated member so the + // codegen run(const Bar*, int) path (which never overwrites it) keeps + // the value the data feed injected. A non-positive value disables it. + if (key == "qty_step") { + qty_step_ = (std::isfinite(value) && value > 0.0) ? value : 0.0; + syminfo_.qty_step = qty_step_; + } } // Returns the script's active timeframe string (e.g. "15" for 15-minute, diff --git a/scripts/run_strategy.py b/scripts/run_strategy.py index f7cbed5..d0e0b81 100644 --- a/scripts/run_strategy.py +++ b/scripts/run_strategy.py @@ -534,6 +534,19 @@ def _num(v): except (TypeError, ValueError): return None + # Per-instrument forced-liquidation lot step. Accept it either as a + # top-level runtime_overrides.qty_step or inside syminfo_metadata, and + # route it through the existing syminfo_metadata channel (key "qty_step") + # so the engine quantizes margin-call lots without a new C-ABI export. + qty_step = _num(runtime_overrides.get("qty_step")) + if qty_step is None and isinstance(syminfo_metadata, dict): + qty_step = _num(syminfo_metadata.get("qty_step")) + if qty_step is not None and qty_step > 0.0: + if not isinstance(syminfo_metadata, dict): + syminfo_metadata = {} + syminfo_metadata = dict(syminfo_metadata) + syminfo_metadata["qty_step"] = qty_step + kwargs = dict( strategy_overrides=strategy_overrides or None, chart_timezone=chart_tz, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index ab23641..355b560 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -164,6 +164,23 @@ void BacktestEngine::process_margin_call(const Bar& bar) { 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; + // Per-instrument lot quantization. TradingView floors each forced- + // liquidation lot to the instrument's quantity step (verified row-for-row + // against the p2 ETHUSDT.P export: every margin-call qty is an exact + // multiple of 0.0004). Without this the engine's nibbles are slightly + // larger and drain the position in fewer calls, drifting the per-call exit + // prices. qty_step_ == 0 (corpus default) leaves qty_liq untouched. + if (qty_step_ > 0.0) { + double floored = std::floor(qty_liq / qty_step_) * qty_step_; + if (floored <= kQtyEpsilon) { + // A liquidation IS required (we passed the margin-shortfall gate) + // but the floored lot rounds to zero. Take the smallest step that + // still makes progress — one qty_step_, or the full residual if it + // is smaller — so the per-bar call loop cannot stall forever. + floored = std::min(qty_step_, qty); + } + qty_liq = floored; + } if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty; // cap at the whole position if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return; diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 5528f8f..fa2b10e 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -687,6 +687,11 @@ void BacktestEngine::run(const Bar* input_bars, int n_input, // Store syminfo and inputs syminfo_ = syminfo; syminfo_mintick_ = syminfo.mintick; + // Forced-liquidation lot step (0 = disabled). On the codegen run(Bar*,n) + // path this member is fed via set_syminfo_metadata("qty_step", …) and is + // never reset; on this explicit-SymInfo path the struct is authoritative. + if (std::isfinite(syminfo.qty_step) && syminfo.qty_step > 0.0) + qty_step_ = syminfo.qty_step; inputs_ = inputs; // Apply overrides diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index 95ae045..7ce177e 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -80,7 +80,7 @@ class MCEngine : public BacktestEngine { class ShortLiqProbe : public MCEngine { public: bool disable_mc_ = false; - explicit ShortLiqProbe(bool disable_mc = false) { + explicit ShortLiqProbe(bool disable_mc = false, double qty_step = 0.0) { initial_capital_ = 1000.0; default_qty_type_ = QtyType::PERCENT_OF_EQUITY; default_qty_value_ = 100.0; // size the short at 100% of equity @@ -89,6 +89,7 @@ class ShortLiqProbe : public MCEngine { margin_short_ = 100.0; // 1x, default TV margin process_orders_on_close_ = true; // market entry fills at bar close disable_mc_ = disable_mc; + qty_step_ = qty_step; // 0 = no lot quantization if (disable_mc_) set_margin_call_enabled(false); } void on_bar(const Bar& /*bar*/) override { @@ -165,6 +166,60 @@ static void test_short_margin_call_disabled() { CHECK(eng.trade_count() == 0); } +// ---- A': lot quantization floors each forced-liquidation lot to qty_step ---- + +// Returns true when |x| is an integer multiple of step (within tol). +static bool is_multiple_of(double x, double step, double tol = 1e-9) { + if (step <= 0.0) return false; + double n = std::round(x / step); + return std::fabs(x - n * step) <= tol; +} + +static void test_short_margin_call_qty_step() { + std::printf("test_short_margin_call_qty_step\n"); + + // Same scenario as test_short_margin_call. Unquantized, the first forced + // lot is 4x the shortfall = 3.80952381 contracts. With a 0.5 lot step it + // must floor DOWN to floor(3.80952381 / 0.5) * 0.5 = 3.5 (an exact step + // multiple), and the exit price is unchanged (bar1 high = 105). + 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 + }; + + const double step = 0.5; + ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/step); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() >= 1); + // First quantized lot: floored to 3.5, an exact multiple of the 0.5 step. + CHECK(near(eng.trade_size(0), 3.5)); + CHECK(is_multiple_of(eng.trade_size(0), step)); + // Quantization never enlarges the lot: floored <= unquantized 3.80952381. + CHECK(eng.trade_size(0) <= 3.80952381 + 1e-9); + CHECK(near(eng.exit_price(0), 105.0)); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + + // Every partial (non-final) forced lot must be a step multiple. The final + // exit closes whatever residual remains (the position size itself is not a + // step multiple, so only the intermediate nibbles are checked). + int partial_checked = 0; + for (int i = 0; i + 1 < eng.trade_count(); ++i) { + CHECK(is_multiple_of(eng.trade_size(i), step)); + ++partial_checked; + } + CHECK(partial_checked >= 1); + + // Teeth: with qty_step = 0 the same first lot is the UNQUANTIZED 3.80952381, + // which is NOT a multiple of 0.5 — proving the assertion above can fail. + ShortLiqProbe raw(/*disable_mc=*/false, /*qty_step=*/0.0); + raw.run(bars.data(), (int)bars.size()); + CHECK(near(raw.trade_size(0), 3.80952381, 1e-4)); + CHECK(!is_multiple_of(raw.trade_size(0), step)); +} + // ---- B: long at 100% margin is never liquidated ---------------------------- class LongNoLiqProbe : public MCEngine { @@ -237,6 +292,7 @@ static void test_long_leveraged_margin_call() { int main() { test_short_margin_call(); + test_short_margin_call_qty_step(); test_margin_liquidation_price_formula(); test_short_margin_call_disabled(); test_long_100pct_margin_no_call();