Repository navigation
Description
Summary
close_opposite_then_enter applies slippage twice to the close leg when a flat-issued opposite-direction entry closes an existing position. Latent: masked by the corpus (all probes run slippage=0, where directional mintick rounding is idempotent).
Root cause
src/engine_orders.cpp:
// close_opposite_then_enter (~:539)
double exit_fill = apply_slippage(fill_price, position_side_ == PositionSide::SHORT); // slip #1
execute_partial_exit_qty(exit_fill, close_qty);But execute_partial_exit_qty slips again internally (~:155):
void BacktestEngine::execute_partial_exit_qty(double fill_price, double qty_to_close) {
...
fill_price = apply_slippage(fill_price, is_buy); // slip #2The helper's contract is "receives an un-slipped fill_price and slips internally" — confirmed by its other caller execute_partial_exit_by_percent (~:168) which passes the raw fill_price. close_opposite_then_enter pre-slips, so the close fills at price ± 2·slippage·mintick.
Trigger (the close_only_opposite path)
Set in apply_entry_order_fill (engine_fills.cpp): created_position_side == FLAT && position_side_ != FLAT && entry_req != position_side_ — i.e. a flat-issued (bracket-style) entry in the opposite direction to the open position fires and closes it (probe 80–87 family). Only bites when slippage > 0.
Impact
- Close leg of close-then-enter exits is off by
slippage·mintick(one extra tick of adverse slip). Wrong exit price → wrong PnL on any strategy using flat-issued opposite brackets with non-zero slippage. - Entry remainder leg (
:546) is correct (single slip). - Not caught today: every validation probe runs
slippage=0.
Probe / test design
1. Engine unit test (deterministic, no TV needed) — fastest confirmation.
- Subclass harness; enter a LONG at known price; arm a flat-issued SHORT bracket entry that fires next bar; set
slippage_ = K,mintick = m. - Assert the emitted close trade's
exit_price==apply_slippage(raw, is_buy)once (i.e.raw − K·mfor a long-close sell), NOTraw − 2·K·m. - Add as
tests/test_close_opposite_slippage.cpp.
2. Corpus parity probe (TV ground truth).
- Strategy that holds a position and uses a flat-issued opposite bracket entry to close (close_only_opposite path),
slippage = N(>0), on ETH-USDT 15m. inputs.json:strategy_set_overrideslippage= N (note: must use a harness that wiresstrategy_set_override, or bakeslippage=Nin thestrategy()decl).- Export TV trade list; verify. Pre-fix: close-leg exit prices diverge by exactly
N·mintick. Post-fix: parity. - Also add a
slippage>0variant of an existing close_only_opposite probe (80–87) to guard the regression generally.
Suggested fix
Honor the helper contract — pass the raw fill_price (drop the pre-slip):
// close_opposite_then_enter
execute_partial_exit_qty(fill_price, close_qty); // was: apply_slippage(...) then pass exit_fillThen re-run unit test + a slippage>0 corpus probe to confirm single-slip parity. Verify no other caller relies on the pre-slipped value.
Note
Flagged in docs/cheatsheet-runtime-and-execution.md Part 3 ("Possible bug").
Activity
fix(fills): remove double-slippage on close-then-enter path (#27)
luisleo526 commentedon May 31, 2026
Fixed in a0535f0 (main):
close_opposite_then_enternow passes the rawfill_price to
execute_partial_exit_qty(which slips internally), removing thedouble-slip on the close leg.
Verification: ctest 45/45; corpus 233 excellent + 1 known anomaly, no
regression. Caveat — the corpus runs
slippage=0(where the bug was idempotentand invisible), so this confirms no regression but not the slippage>0
correction. The dedicated slippage>0 parity probe (engine self-consistency:
same close_only_opposite strategy at slippage 0 vs K → close leg must differ by
exactly K·mintick, not 2K; or a TV capture) remains a nice-to-have follow-up.
Closing since the root cause is fixed and verified by inspection + no-regression.
docs: render the active scoreboard from the facts tokens at pineforge…