Skip to content

bug(fills): double-slippage on close-then-enter (close_only_opposite) path #27

Closed

Description

@luisleo526

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 #2

The 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·m for a long-close sell), NOT raw − 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_override slippage = N (note: must use a harness that wires strategy_set_override, or bake slippage=N in the strategy() decl).
  • Export TV trade list; verify. Pre-fix: close-leg exit prices diverge by exactly N·mintick. Post-fix: parity.
  • Also add a slippage>0 variant 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_fill

Then 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

  1. luisleo526 commented on May 31, 2026

    @luisleo526
    CollaboratorAuthor

    Fixed in a0535f0 (main): close_opposite_then_enter now passes the raw
    fill_price to execute_partial_exit_qty (which slips internally), removing the
    double-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 idempotent
    and 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions