Skip to content

Commit 15b02e0

Browse files
authored
Merge pull request #73 from pineforge-4pass/fix/ki31-margin-nibble-floor-order
fix(margin): floor restore-qty to lot step BEFORE the 4x over-liquidation (KI-31)
2 parents 725067f + 7738f67 commit 15b02e0

1 file changed

Lines changed: 25 additions & 11 deletions

File tree

src/engine_fills.cpp

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -182,22 +182,36 @@ void BacktestEngine::process_margin_call(const Bar& bar) {
182182
// q units leaves the bar-equity unchanged (realized + open P/L just
183183
// reclassify) while the required margin shrinks: need
184184
// (qty - q) * adverse * pv * m <= equity_adv => q >= qty - equity_adv/(adverse*pv*m).
185-
const double q_min = qty - equity_adv / (adverse * pv * m);
185+
double q_min = qty - equity_adv / (adverse * pv * m);
186186
if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return;
187+
// Per-instrument lot quantization. TradingView floors the minimum-restore
188+
// qty to the instrument's quantity step BEFORE applying the 4x over-
189+
// liquidation — not after. Flooring the 4x PRODUCT instead injects a
190+
// ~qty_step/4 error into the first nibble that compounds ~3x per step
191+
// through the margin-call cascade (row-diff vs the ETHUSDT.P export,
192+
// alpha-wizard-channel percent_of_equity=100: floor-BEFORE reproduces the
193+
// first 14 cascade nibbles bit-exact — 7.7232 / 30.3796 / 35.716 / 19.1516
194+
// / 53.0532 / 59.69 / … ; floor-AFTER matched 0/19 and desynced by step 7).
195+
// qty_step_ == 0 (corpus default; the explicit-leverage p2/5x margin probes
196+
// never set it) leaves both q_min and qty_liq untouched -> byte-identical.
197+
if (qty_step_ > 0.0) {
198+
q_min = std::floor(q_min / qty_step_) * qty_step_;
199+
}
187200
double qty_liq = 4.0 * q_min;
188-
// Per-instrument lot quantization. TradingView floors each forced-
189-
// liquidation lot to the instrument's quantity step (verified row-for-row
190-
// against the p2 ETHUSDT.P export: every margin-call qty is an exact
191-
// multiple of 0.0004). Without this the engine's nibbles are slightly
192-
// larger and drain the position in fewer calls, drifting the per-call exit
193-
// prices. qty_step_ == 0 (corpus default) leaves qty_liq untouched.
194201
if (qty_step_ > 0.0) {
195-
double floored = std::floor(qty_liq / qty_step_) * qty_step_;
202+
// q_min is already a multiple of qty_step_, so 4*q_min is mathematically
203+
// a multiple too — but binary float makes e.g. 4*5.7089 = 22.83559999…,
204+
// which a bare std::floor drops a whole lot (→ 22.8355 vs TV's 22.8356).
205+
// The +1e-6 epsilon (same guard as quantize_qty in engine.hpp) pins it to
206+
// the intended lot. Without it the tail nibbles desync from ~step 14 on;
207+
// with it alpha-wizard-channel cascade-1 matches TV 19/19 bit-exact.
208+
double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_;
196209
if (floored <= kQtyEpsilon) {
197210
// A liquidation IS required (we passed the margin-shortfall gate)
198-
// but the floored lot rounds to zero. Take the smallest step that
199-
// still makes progress — one qty_step_, or the full residual if it
200-
// is smaller — so the per-bar call loop cannot stall forever.
211+
// but the floored lot rounds to zero (sub-lot shortfall). Take the
212+
// smallest step that still makes progress — one qty_step_, or the
213+
// full residual if it is smaller — so the per-bar call loop cannot
214+
// stall forever.
201215
floored = std::min(qty_step_, qty);
202216
}
203217
qty_liq = floored;

0 commit comments

Comments
 (0)