Skip to content

engine.backtest

The deterministic event loop and the frozen BacktestResult it returns. One loop, strict time order, no look-ahead.

backtest

The deterministic backtest engine: one loop, strict time order, no look-ahead.

Per bar (the three-phase settle): 1. session boundaries due before the bar are enforced (16:10 ET flatten, 17:00 ET session close -> MLL ratchet, day roll); 2. the SimBroker matches the bar (fills + intrabar rule breaches, in path order) and queued user events (on_order/on_fill/on_position) dispatch; 3. the strategy sees the completed bar (on_bar) and may submit orders, which are accepted at the bar's close timestamp — eligible from the NEXT bar (the accepted_ts firewall).

Determinism: single-threaded, a single monotonic event order, all state transitions driven by feed timestamps through the TestClock. Two runs over the same inputs produce byte-identical results.

RoundTrip

Bases: Struct

One flat-to-flat excursion in a single contract — a "trade" in the colloquial sense, as opposed to the half-turns the gateway reports.

Boundaries are unambiguous and need no new convention: a round trip opens when a contract goes from flat to positioned and closes when it returns to flat. A FLIP (long straight to short in one fill) closes one round trip and opens the next at that instant; the flip's single half-turn and its costs are attributed to the round trip being CLOSED, so no fill is counted twice.

This is a REPORTING grouping over the broker's own FIFO half-turns. It does not re-derive P&L: gross_pnl is the sum of the profit_and_loss figures the broker already computed, so the open question of how the live gateway pairs fills (docs/topstep-rules.md §9) cannot change these numbers without changing the half-turns first.

contract_id instance-attribute

contract_id: str

direction instance-attribute

direction: int

+1 if the excursion was net long, -1 if net short.

opened_ts_ns instance-attribute

opened_ts_ns: int

closed_ts_ns instance-attribute

closed_ts_ns: int

max_qty instance-attribute

max_qty: int

Largest position size held during the excursion (scale-ins included).

half_turns instance-attribute

half_turns: int

gross_pnl instance-attribute

gross_pnl: Decimal

Sum of the closing half-turns' realized P&L. GROSS — no fees.

costs instance-attribute

costs: Decimal

Fees + commissions charged on every half-turn in the excursion.

net_pnl instance-attribute

net_pnl: Decimal

gross_pnl - costs. This is what the excursion actually earned.

initial_risk instance-attribute

initial_risk: Decimal | None

Dollars at risk when the position was opened, from the bracket stop distance at entry (stop_loss_ticks x tick value x size, summed over every opening fill).

None when ANY opening fill carried no bracket stop — a signal-exit strategy has no defined risk, and guessing one would manufacture an R-multiple out of nothing. Captured AT ENTRY: moving or trailing the stop afterwards does not change it, which is the conventional meaning of R.

r_multiple instance-attribute

r_multiple: Decimal | None

net_pnl / initial_risk — the excursion's return in units of what it actually risked. NET basis deliberately: R answers "what did I make against what I put up", and the fees were genuinely paid. None whenever initial_risk is.

BarEquity

Bases: Struct

One bar's equity envelope and the MLL floor in force during it.

high/low are realized + unrealized equity — the same figure the rule kernel breach-checks — sampled over the modelled intrabar path, NOT from real ticks (Tier-0 ships bars only). floor is the trailing MLL floor as of this bar, so low - floor is how close the account came to termination while the bar printed.

ts_ns instance-attribute

ts_ns: int

high instance-attribute

high: Decimal

low instance-attribute

low: Decimal

floor instance-attribute

floor: Decimal

BacktestResult

Bases: Struct

End-of-run outcome (msgspec-serializable -> golden-master friendly).

verdict instance-attribute

verdict: Verdict

reason instance-attribute

reason: str

ending_balance instance-attribute

ending_balance: Decimal

starting_balance instance-attribute

starting_balance: Decimal

profit_target instance-attribute

profit_target: Decimal

floor instance-attribute

floor: Decimal

best_day instance-attribute

best_day: Decimal

total_profit instance-attribute

total_profit: Decimal

days_traded instance-attribute

days_traded: int

day_records instance-attribute

day_records: tuple[DayRecord, ...]

breach instance-attribute

breach: Breach | None

trade_count instance-attribute

trade_count: int

equity_curve instance-attribute

equity_curve: tuple[tuple[int, Decimal], ...]

rejections class-attribute instance-attribute

rejections: tuple[tuple[int, int], ...] = ()

bar_equity class-attribute instance-attribute

bar_equity: tuple[BarEquity, ...] = ()

round_trips class-attribute instance-attribute

round_trips: tuple[RoundTrip, ...] = ()

passed property

passed: bool

BacktestEngine

BacktestEngine(*, feed: DataFeed, broker: SimBroker, strategy: Strategy, clock: TestClock, session: SessionTimes = TOPSTEP_SESSION, recorder: Recorder | None = None)

Drives feed -> broker -> strategy under a TestClock.

Source code in src/topstep_backtest/engine/backtest.py
def __init__(
    self,
    *,
    feed: DataFeed,
    broker: SimBroker,
    strategy: Strategy,
    clock: TestClock,
    session: SessionTimes = TOPSTEP_SESSION,
    recorder: Recorder | None = None,
) -> None:
    self._feed = feed
    self._broker = broker
    self._strategy = strategy
    self._clock = clock
    self._session = session
    # Observation only: with a recorder the engine additionally reports
    # what it is doing (events, session boundaries, per-bar commits) but
    # decides nothing differently — results are byte-identical either way
    # (pinned by a golden test).
    self._recorder = recorder

run async

run() -> BacktestResult
Source code in src/topstep_backtest/engine/backtest.py
async def run(self) -> BacktestResult:
    broker = self._broker
    strategy = self._strategy
    session = self._session
    recorder = self._recorder

    ctx = StrategyContext.from_broker(
        broker,
        clock=self._clock,
        account_id=broker.account_id,
        instruments=broker.instruments,
    )
    if recorder is not None:
        recorder.attach(broker=broker, strategy=strategy)
        ctx = recorder.wrap_context(ctx)
    strategy.bind(ctx)
    self._set_hook("on_start")
    strategy.on_start()
    self._set_hook(None)

    equity_curve: list[tuple[int, Decimal]] = []
    current_day: date | None = None
    flattened_today = False
    last_ts = -1

    try:
        for bar in self._feed:
            # --- no-look-ahead ordering invariant -----------------------
            if bar.ts_init < last_ts:
                raise AssertionError(
                    f"feed violated time order: bar ts_init {bar.ts_init} < {last_ts}"
                )
            last_ts = bar.ts_init

            day = trading_day_of(bar.ts_init)
            if current_day is None:
                current_day = day
            elif day != current_day:
                self._roll_session(current_day, flattened=flattened_today)
                current_day = day
                flattened_today = False
                if broker.dead:
                    break

            # --- 16:10 ET flatten enforcement --------------------------
            # Strictly AFTER the deadline: a bar closing exactly at 16:10
            # still contains tradable prints and must be matched first.
            # The clock lands on the deadline BEFORE the flatten fires so
            # anything a strategy tries from the resulting fill callbacks
            # is correctly rejected as OutsideTradingHours.
            if not flattened_today and bar.ts_init > session.flatten_ns(day):
                flatten_ns = session.flatten_ns(day)
                if flatten_ns > self._clock.now_ns():
                    self._clock.advance_to(flatten_ns)
                if recorder is not None:
                    recorder.on_session("eod_flatten", flatten_ns, "16:10 ET auto-flatten")
                broker.flatten_all(flatten_ns, reason="eod_flatten")
                flattened_today = True
                await self._dispatch()

            self._clock.advance_to(bar.ts_init)

            # --- phase 1: match, then deliver resulting user events ----
            broker.on_bar(bar)
            await self._dispatch()
            if broker.dead:
                equity = broker.equity()
                equity_curve.append((bar.ts_init, equity))
                if recorder is not None:
                    recorder.commit_bar(bar, equity)
                break

            # --- phase 2: strategy acts on the completed bar -----------
            self._set_hook("on_bar")
            await strategy.handle_bar(bar)
            self._set_hook(None)
            await self._dispatch()

            equity = broker.equity()
            equity_curve.append((bar.ts_init, equity))
            if recorder is not None:
                recorder.commit_bar(bar, equity)

        if current_day is not None and not broker.dead:
            self._roll_session(current_day, flattened=flattened_today)
        # Session-roll events after the final bar reach no strategy hook
        # (nothing advances the loop again), but a recording must still
        # carry them: the terminal flatten's fills are real trades, and a
        # replay whose fills do not sum to the run's P&L would be lying.
        if recorder is not None:
            for event in broker.drain_events():
                recorder.on_user_event(event)
    finally:
        self._set_hook("on_stop")
        strategy.on_stop()
        self._set_hook(None)

    kernel = broker.kernel
    reason = self._reason(kernel.verdict, kernel.breach)
    result = BacktestResult(
        verdict=kernel.verdict,
        reason=reason,
        ending_balance=broker.balance,
        starting_balance=kernel.params.starting_balance,
        profit_target=kernel.params.profit_target,
        floor=kernel.floor,
        best_day=kernel.best_day,
        total_profit=kernel.total_profit,
        days_traded=kernel.days_traded,
        day_records=kernel.day_records,
        breach=kernel.breach,
        trade_count=len(broker.trades),
        equity_curve=tuple(equity_curve),
        rejections=tuple(sorted(broker.rejections.items())),
        bar_equity=tuple(
            BarEquity(ts_ns=ts, high=high, low=low, floor=floor)
            for ts, high, low, floor in broker.bar_equity
        ),
        round_trips=broker.round_trips,
    )
    if recorder is not None:
        recorder.finalize(result)
    return result