@@ -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