execution.sim_broker¶
The simulated broker: full order lifecycle, FIFO lots, OCO brackets, and flat-to-flat RoundTrip records.
sim_broker
¶
SimBroker: deterministic execution simulation behind the Broker protocol.
Implements the SAME structural protocol AsyncTopstepClient satisfies, so a
strategy wired to a SimBroker runs unchanged live. The broker owns order
lifecycle, OCO/bracket linkage, trailing-stop recomputation, position netting,
P&L (exact Decimal), and wires every equity change through the Combine rule
kernel — including intrabar breach detection along the deterministic price
path, and forced liquidation with its own (worse) slippage plus Topstep's
$10/contract automatic-liquidation fee.
Accounting: positions are FIFO lots with an exact Decimal COST BASIS. Every
stored lot price is on the tick grid; unrealized P&L is division-free
(core.money.position_unrealized), so scale-ins can never produce an
off-grid average or rounding dust. Per-half-turn profit_and_loss uses FIFO
lot attribution (documented assumption pending gateway calibration —
docs/DESIGN.md §15).
Key correctness properties
- No look-ahead: orders participate in a bar only if accepted at/before its
open (the fill model's
accepted_tsfirewall); trailing stops likewise only ratchet from bars the order actually lived through; bracket children created on an intrabar fill first participate the NEXT bar. - Deterministic intrabar resolution: fills and rule-breach liquidations are ordered along ONE shared pessimistic price path by TRIGGER level (never by slippage-adjusted fill prices), and equity is re-checked AT each fill point after it applies (slippage + fees can themselves breach).
- Every rejection is the SDK's
APIErrorwith the gateway's error code.
Documented Tier-0 divergences from live (see docs/DESIGN.md §13.8): market
orders — including positions.close/partial_close — fill at the NEXT
bar's open (not instantly); stored bars are not live tape-built bars;
wait_for_fill raises UnsupportedInBacktestError; STOP_LIMIT and
JOIN_BID/JOIN_ASK are rejected (they need quote data, Tier-1+).
UserEvent
module-attribute
¶
Events queued for strategy dispatch (mirrors the SDK user hub payloads).
SimBrokerConfig
¶
SimBrokerConfig(*, forced_liq_slippage_ticks: int = 2, liquidation_fee_per_contract: Decimal = Decimal('10'), max_trail_ticks: int = 1000, history_depth: int = 20000)
Tunables that are broker-level (not fill-model-level).
Source code in src/topstep_backtest/execution/sim_broker.py
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.
max_qty
instance-attribute
¶
Largest position size held during the excursion (scale-ins included).
gross_pnl
instance-attribute
¶
Sum of the closing half-turns' realized P&L. GROSS — no fees.
costs
instance-attribute
¶
Fees + commissions charged on every half-turn in the excursion.
net_pnl
instance-attribute
¶
gross_pnl - costs. This is what the excursion actually earned.
initial_risk
instance-attribute
¶
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
¶
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.
SimBroker
¶
SimBroker(*, account_id: int, instruments: Mapping[str, InstrumentSpec], fill_model: FillModel, fee_model: FeeModel, kernel: CombineKernel, clock: Clock, ids: IdGenerator | None = None, config: SimBrokerConfig | None = None, session: SessionTimes = TOPSTEP_SESSION)
Deterministic simulated venue satisfying the Broker protocol.
Source code in src/topstep_backtest/execution/sim_broker.py
rejections
property
¶
Count of rejected placements, keyed by gateway error_code.
round_trips
property
¶
round_trips: tuple[RoundTrip, ...]
Completed flat-to-flat excursions, in close order.
Only FINALIZED trips appear: a position still open has no close price and no realized P&L, so reporting it would invent both. The engine flattens at the session roll, so a completed run leaves none in flight.
bar_equity
property
¶
Per-bar (ts_init, equity_high, equity_low, mll_floor).
Equity is realized + unrealized (the same figure the rule kernel
breach-checks), sampled at the four points of the modelled intrabar
path. This is what lets analytics reconstruct intraday-trailing
drawdown and distance-to-floor over time; the engine copies it onto
BacktestResult.bar_equity.
last_bar_equity
property
¶
The most recent bar_equity sample, or None before any bar.
O(1), unlike bar_equity (which copies the whole capture): per-bar
consumers — the replay recorder reads this every frame — must not pay
a full-run copy per bar.
equity
¶
Realized balance + open P&L marked at each contract's last close.
Source code in src/topstep_backtest/execution/sim_broker.py
on_bar
¶
on_bar(bar: Bar) -> None
Phase-1 matching: trailing ratchet, path walk with interleaved
fill/breach resolution, then the end-of-bar equity check.
Source code in src/topstep_backtest/execution/sim_broker.py
flatten_all
¶
Topstep auto-flatten enforcement (16:10 ET / day roll): market-dump
every position with forced slippage AND the automatic-liquidation fee,
then cancel every working order. Strategy-initiated exits should
happen earlier via positions.close (which does NOT pay this fee).
Source code in src/topstep_backtest/execution/sim_broker.py
session_close
¶
EOD hook: report the closed balance to the rule kernel (MLL ratchet).
SimOrderApi
¶
SimOrderApi(broker: SimBroker)
Source code in src/topstep_backtest/execution/sim_broker.py
place
async
¶
place(account_id: int, contract_id: str, *, side: OrderSide | int, type: OrderType | int, size: int, limit_price: float | Decimal | None = None, stop_price: float | Decimal | None = None, trail_price: float | Decimal | None = None, custom_tag: str | None = None, stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None, take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None, stop_loss_ticks: int | None = None, take_profit_ticks: int | None = None) -> int
Source code in src/topstep_backtest/execution/sim_broker.py
buy
async
¶
buy(account_id: int, contract_id: str, size: int, *, type: OrderType | int = MARKET, limit_price: float | Decimal | None = None, stop_price: float | Decimal | None = None, trail_price: float | Decimal | None = None, custom_tag: str | None = None, stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None, take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None, stop_loss_ticks: int | None = None, take_profit_ticks: int | None = None) -> int
Source code in src/topstep_backtest/execution/sim_broker.py
sell
async
¶
sell(account_id: int, contract_id: str, size: int, *, type: OrderType | int = MARKET, limit_price: float | Decimal | None = None, stop_price: float | Decimal | None = None, trail_price: float | Decimal | None = None, custom_tag: str | None = None, stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None, take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None, stop_loss_ticks: int | None = None, take_profit_ticks: int | None = None) -> int
Source code in src/topstep_backtest/execution/sim_broker.py
modify
async
¶
modify(account_id: int, order_id: int, *, size: int | None = None, limit_price: float | Decimal | None = None, stop_price: float | Decimal | None = None, trail_price: float | Decimal | None = None) -> None
Source code in src/topstep_backtest/execution/sim_broker.py
cancel
async
¶
cancel_all
async
¶
search_open
async
¶
Source code in src/topstep_backtest/execution/sim_broker.py
get
async
¶
Source code in src/topstep_backtest/execution/sim_broker.py
wait_for_fill
async
¶
wait_for_fill(account_id: int, order_id: int, *, timeout: float = 30.0, poll_interval: float = 1.0) -> OrderModel
Source code in src/topstep_backtest/execution/sim_broker.py
SimPositionApi
¶
SimPositionApi(broker: SimBroker)
Source code in src/topstep_backtest/execution/sim_broker.py
search_open
async
¶
Source code in src/topstep_backtest/execution/sim_broker.py
close
async
¶
Source code in src/topstep_backtest/execution/sim_broker.py
partial_close
async
¶
Source code in src/topstep_backtest/execution/sim_broker.py
close_all
async
¶
Source code in src/topstep_backtest/execution/sim_broker.py
SimHistoryApi
¶
SimHistoryApi(broker: SimBroker)
Source code in src/topstep_backtest/execution/sim_broker.py
retrieve_bars
async
¶
retrieve_bars(contract_id: str, *, unit: AggregateBarUnit | int, unit_number: int, start_time: datetime | str, end_time: datetime | str, limit: int = 1000, live: bool = False, include_partial_bar: bool = False) -> list[AggregateBarModel]
Serve ONLY already-seen bars (zero look-ahead), newest-first like the
gateway (bars stamped at open time, matching the SDK model). The
requested unit/unit_number must match the feed's native bar
spec — Tier-0 does no resampling and refuses to silently serve wrong
aggregation. start_time/end_time filter when passed as
datetimes (ISO strings accepted for parity but treated as unbounded).