Tutorial — a simple EMA crossover strategy, end to end¶
A hands-on walk through the whole framework using one small strategy: go long when a fast EMA crosses above a slow EMA, exit on the opposite cross or a tick-defined bracket. By the end you will understand every stage the data passes through, every input you supply, and every number that comes back.
This is the learn-by-example companion to the reference docs:
INDICATORS.md (the TA-Lib surface),
topstep-rules.md (the rulebook being enforced), and
../AGENTS.md (using the framework day to day). The finished
code is ../examples/ema_cross.py —
uv run python examples/ema_cross.py runs exactly what this page dissects.
Every number on this page was produced by running code, not transcribed — the report block, the indicator values, the hand-checked fill, and the variant runs in §2. All of it is seed 7 and deterministic: run it yourself and you get these numbers to the penny.
0. Setup¶
cd topstep-backtesting
uv sync --extra dev # creates .venv, installs topstep-sdk (editable)
uv run python examples/ema_cross.py
If that prints a "Topstep Combine 50K" block, you're ready.
1. The mental model: what actually happens when you press run¶
The framework is an event-driven backtester with live parity. "Event-driven" means there is one time-ordered loop that replays bars the way a live session would receive them; "live parity" means the strategy you write here runs unchanged against the real Topstep gateway — you only swap the wiring.
your OHLCV data
│ (synthetic, or your CSV/Parquet/DataFrame)
▼
┌──────────┐ declare stamp="open"/"close", unit, tick grid
│ wrangler │ → validated, on-grid Bar objects
└────┬─────┘
▼
┌──────────┐ time-ordered, one merged stream
│ ListBarFeed
└────┬─────┘
▼
┌───────────────── BacktestEngine (one loop, one TestClock) ─────────────────┐
│ for each Bar, in strict order: │
│ PHASE 0 session boundaries: day roll, 16:10 ET flatten backstop │
│ PHASE 1 SimBroker.on_bar(bar) → match resting orders (fills) AND run │
│ the rule kernel's intrabar breach check along ONE price path; │
│ deliver resulting events to on_order / on_fill / on_position │
│ PHASE 2 your Strategy.on_bar(bar) runs → you may place orders │
│ (eligible only from the NEXT bar — the accepted_ts firewall) │
│ append (bar close time, equity) to the equity curve │
└───────────────────────────┬────────────────────────────────────────────────┘
▼
CombineKernel watches every equity tick and every EOD close
(trailing MLL · optional DLL · consistency · position cap)
▼
BacktestResult → Report (verdict + day trail + stats)
Four load-bearing facts fall out of that diagram; the rest of the page elaborates them:
- A
Baris a completed candle stamped at its close. Your strategy cannot see or act on an unfinished bar. (§4) - The broker settles a bar before your strategy sees it. Resting orders — your stops and targets, and the rule engine's forced liquidations — resolve in phase 1; your new decisions happen in phase 2 and take effect next bar. That ordering is the no-look-ahead guarantee. (§3, §7)
- You talk only to
self.ctx— protocol surfaces that both the backtestSimBrokerand the liveAsyncTopstepClientsatisfy. Write once. (§5) - The rule engine is real-time, not a scorecard. A Maximum Loss Limit breach forcibly liquidates you mid-bar with slippage and a $10/contract fee, exactly like Topstep's risk engine. (§8)
2. The strategy, in full¶
from topstep_backtest.indicators import Cross, Ema
from topstep_backtest.protocols import Bar
from topstep_backtest.strategy import SymbolStrategy
class EmaCross(SymbolStrategy):
"""Go long on a fast/slow EMA cross-up; exit via the bracket or cross-down."""
def __init__(
self,
contract_id: str,
*,
fast: int = 12,
slow: int = 26,
size: int = 2,
stop_loss_ticks: int = 40,
take_profit_ticks: int = 80,
) -> None:
super().__init__(contract_id)
self.fast = self.use(Ema(fast)) # (1) TA-Lib EMA
self.slow = self.use(Ema(slow)) # (2) TA-Lib EMA
self.cross = self.use(Cross(self.fast, self.slow)) # (3) NOT TA-Lib
self.size = size
self.stop_loss_ticks = stop_loss_ticks
self.take_profit_ticks = take_profit_ticks
async def on_bar(self, bar: Bar) -> None: # (4)
if self.cross.up and self.position.flat and not self.working_orders:
await self.buy( # (5)
self.size,
stop_loss_ticks=self.stop_loss_ticks,
take_profit_ticks=self.take_profit_ticks,
)
elif self.cross.down and self.position.is_long:
await self.close() # (6)
That is the entire strategy. The rest of this section explains every numbered line; §3–§8 explain the machinery underneath.
SymbolStrategy: the ergonomic front door¶
EmaCross subclasses SymbolStrategy, the single-contract base modelled on
backtesting.py but made causal (no
look-ahead is structurally possible). It gives you, for free, the bookkeeping a
raw strategy would hand-roll:
use()-registered indicators, auto-updated once per matching bar.- A warmup ready-gate so your logic never runs on half-formed indicators.
self.position— a signed net-position view (flat/is_long/is_short).self.working_orders— your live (non-terminal) orders on this contract.buy/sell/closesugar with tick-defined OCO brackets.
The constructor calls super().__init__(contract_id):
contract_id is the one contract this strategy trades — events for any other
contract never reach your hooks. require_ready=True gates on_bar until every
use()d indicator is ready. warmup=None means "auto = the largest indicator
lookback"; pin it to an integer when sweeping parameters so every parameter set
gets the same number of warmup bars (an explicit warmup gates even under
require_ready=False).
(1)(2) The EMA indicators¶
self.use(Ema(12)) registers an indicator and returns it. Registration does
two things: the indicator is now auto-update()d once for every bar on this
contract, and it counts toward the warmup gate.
Ema(period) is TA-Lib's EMA, driven bar by bar — Ema(12) is exactly
TalibIndicator("EMA", timeperiod=12) with a typed constructor. Every indicator
here is TA-Lib; nothing in this repo re-implements a formula, so there is no
second implementation that can drift from the reference one.
- Seeded with the simple average of the first
periodcloses, then the standard recursionema += k * (close − ema)withk = 2 / (period + 1)— TA-Lib's convention. On this tutorial's feed the 12th bar'sEma(12)reads17993.395833333332, which is exactlymean(first twelve closes). lookback == period— it isreadyexactly on itsperiod-th update. Other families differ;INDICATORS.mdhas the table.update(bar)appends to a boundedfloat64buffer, hands that window to TA-Lib, and reads the LAST output element. The buffer holds only already-closed bars, so the indicator structurally cannot see bar t+1. No look-ahead by construction, not by convention.
Three properties of that arrangement are load-bearing here; all are asserted in
the test suite, and INDICATORS.md covers the general case.
Streaming == batch, bit for bit. TA-Lib recomputes from index 0 on every
call and every function it exposes is causal, so FUNC(bars[:t+1])[-1] ==
FUNC(bars)[t] exactly. Over this tutorial's 720 bars the largest difference
between the bar-by-bar Ema(26) and a single talib.EMA(closes, 26) over the
whole array is 0.0. Feeding a prefix cannot produce a number a full-series
batch run would not.
ready is not warm — and parity begins at warm. Each indicator keeps
its last history_bars bars and no more, so its value is a pure function of a
fixed window rather than of wherever the series happened to start; that is what
lets a live session reproduce a backtest at all. ready says a value exists
and flips at lookback — that is the gate use() waits for. warm says the
buffer is full, and only from there does sim match live bit for bit. For
Ema(26): ready on bar 26, warm on bar 1,664. On this tutorial's 720-bar feed
nothing is ever warm, which is exactly why its numbers are a deterministic
demonstration and not a live parity claim (§10).
Values are float-precise, not Decimal-exact — and deliberately not
tick-snapped. TA-Lib computes in float64; the Decimal you get back is that
float, off the tick grid on purpose, because an indicator level is not a
tradeable price. (This run's first cross-up happens at
fast = 17986.968615299105 vs slow = 17986.62283658429 — neither is a price
anyone could trade.) Money stays exact Decimal on the grid everywhere else, so
the rule is: never route an indicator value into grid math without an explicit
core.money.round_to_tick.
Why not precompute the whole EMA array up front (backtesting.py's
self.I)? Because there is no full array at the live edge, and a non-causal function over one silently reads the future. And you give up nothing: bar-by-bar and full-array are the same TA-Lib call, equal bit for bit. What the dialect refuses is not the accuracy — it is the ability to accidentally read the future.
(3) The crossover detector¶
Cross(a, b) fires only on the bar that resolves the crossing:
self.cross.upisTrueon the bar wherea − bgoes from ≤ 0 to > 0.self.cross.downisTruewherea − bgoes from ≥ 0 to < 0.- An exact
a == btouch fires on the bar that resolves it, not the touch. (With float64 indicator values an exact tie is vanishingly rare; that branch is a correctness guarantee, not a path you will hit.) lookback == max(a.lookback, b.lookback) + 1— it needs a previous and a current comparison, with both inputs ready.
Cross is the one indicator here that is not a TA-Lib function: it is a
pure-Python comparator over two value series, so anything exposing
lookback/ready/value can feed it — including a single line of a
multi-output indicator (see below). It is also the one indicator with no
value, no history_bars and no warm; it offers up and down instead.
Registration order matters. Cross reads a.value and b.value on
update, so both EMAs must be use()d before the Cross that reads them.
SymbolStrategy.use() enforces this: constructing the Cross always succeeds,
and it is self.use(cross) that raises ValueError when the inputs aren't
already registered — because otherwise the Cross would silently read
one-bar-stale values.
For fast=12, slow=26: slow needs 26 bars, and Cross needs
max(12, 26) + 1 = 27. So the strategy's first decision is on bar 27, and
26 bars are gated (you'll see warmup 26 bars gated in the report — §6).
(4) on_bar: your per-bar decision¶
async def on_bar(self, bar: Bar) is called:
- only for
self.contract_id, - only once every
use()d indicator is ready, and - with every indicator already updated for this bar.
So inside on_bar you can read self.cross.up / self.fast.value and trust
they reflect the bar you were handed. The hook is async because order
placement is async — the awaits are the live contract, not boilerplate.
The entry guard is self.cross.up and self.position.flat and not
self.working_orders. self.position is a signed NetPosition view
(flat/is_long/is_short properties, truthy when a position exists), folded
from the same fill/position events the live hub emits. self.working_orders is
a tuple of your non-terminal orders on this contract; guarding on it prevents
stacking a second entry while a bracket entry is still pending. Crucially,
buy/sell latch the returned order id into working_orders at submit
time, so the guard holds even when a live hub confirmation lags the REST return
past the next bar.
(5) buy — market entry with a native OCO bracket¶
The example calls await self.buy(size, stop_loss_ticks=40,
take_profit_ticks=80). Full signature:
async def buy(self, size: int, *, stop_loss_ticks: int | None = None,
take_profit_ticks: int | None = None,
limit_price: Decimal | None = None, stop_price: Decimal | None = None,
custom_tag: str | None = None) -> int | None
- With no price kwargs it is a market order. Passing
limit_pricemakes it a LIMIT;stop_pricemakes it a STOP. (Passing both is refused — that would be a STOP_LIMIT, which the Tier-0 sim can't fill.) stop_loss_ticks/take_profit_ticksare magnitudes (positive tick offsets). The framework signs them per side and attaches a server-side OCO bracket: for a long, the stop sits 40 ticks below the entry fill and the target 80 ticks above. On MNQ (tick = 0.25), that's a 10.00-point stop and 20.00-point target. Because the bracket is server-side, it survives even if your bot dies — the same as on the real gateway.- Returns the order id (latched into
working_orders), orNoneon rejection. A rejection (position cap hit, outside the trading window) is normal combine control flow, not an exception — the sugar swallows theAPIErrorand routes it toon_reject(overridable; default: ignore). The raw raising path is still there asself.ctx.orders.buy(...)if you want it.
sell(...) is the mirror image. size is a positional int — contracts,
under a hard cap. There is no fractional or equity-fraction sizing.
(6) close — flatten via the position API¶
await self.close() submits a reduce-only market order to flatten this
contract. Like buy, a rejection routes to on_reject; it fills at the next
bar's open (§7). cancel_working() is the companion that cancels every working
order on the contract.
Beyond the listing: managing the trade you are already in¶
EmaCross enters and then waits — the bracket or a cross-down ends the trade.
It does not have to be that way. The bracket from step (5) became two real
reduce-only orders the moment the entry filled, so a strategy can amend it
without closing anything:
entry = self.position.avg_price # the venue's average; None when flat
if self.position.is_long and entry is not None:
if bar.close - entry >= 20 * self.spec.tick_size:
await self.move_stop(ticks=0) # stop to breakeven
ticks= is measured from the average entry and signed in the position's
favour, so ticks=0 is breakeven and ticks=10 is ten ticks of locked
profit whether the trade is long or short; price= sets an absolute level
instead. Both return how many orders moved — every bracket child, since a
scale-in has more than one — and route rejections to on_reject. They touch
bracket children only, so an entry order of your own resting in the market is
never mistaken for protection. The new level is live from the next bar, for
the same reason a market order fills at the next bar's open.
Swapping the EMAs for any other TA-Lib indicator¶
Because Ema is only a typed spelling of a TA-Lib call, changing the signal
is a constructor edit. The chassis — gate, ordering, position views, orders,
report — does not move, which is exactly what makes comparing signals honest.
A different moving average. Any single-output function reads through
.value like Ema does, so self.use(TalibIndicator("KAMA", timeperiod=12))
drops straight into the same Cross, and warmup follows automatically from
TA-Lib's own lookbacks. report.bars_gated always tells you the truth — which
matters, because a "12-period" fast line can cost five times the warmup you
assumed (TEMA(12) needs 34 bars). The parity window is a separate number that
moves independently; both are tabulated in INDICATORS.md.
A MACD-line cross. One indicator, three outputs, two of them crossed:
self.macd = self.use(Macd(12, 26, 9)) # register the OWNER
self.cross = self.use(Cross(self.macd.line("macd"), self.macd.line("macdsignal")))
Register the owner, never a line: a TalibLine has no state of its own and
would never advance, so use(macd.line("macd")) raises rather than gating your
strategy forever. use() resolves the Cross's inputs through each line's
owner, so registering macd first satisfies the ordering rule.
Dropping that into EmaCross on the same seed-7 feed is a genuinely different
strategy: Macd(12,26,9)'s lookback is 34, so the Cross needs 35 bars and
34 are gated (up from 26); the run takes 31 closing trades instead of 8
and ends at 49,732.12, net −267.88 against the EMA version's −4.84.
Faster signal, more churn, more fees. That is the framework doing its job.
An RSI filter. Register an extra indicator, read it as a condition —
self.rsi = self.use(Rsi(14)), then if self.cross.up and self.rsi.value < 70
and .... Reading .value inside on_bar is always safe; the ready-gate
guarantees a value before your first decision. On this feed the filter changes
nothing: the highest RSI(14) on any of the run's eight cross-up bars is 63.36,
so the condition never blocks an entry and msgspec.json.encode(report.result)
comes back byte-identical to the unfiltered run. That is a common outcome and
worth internalising — check that a filter actually bites before you believe it
improved anything. (Rsi(14) needs 15 bars against the Cross's 27, so
bars_gated also stays 26.)
Anything else in the library. The named wrappers are a convenience, not the
boundary — TalibIndicator("ULTOSC", timeperiod1=7, timeperiod2=14,
timeperiod3=28) and TalibIndicator("CDLENGULFING") work the same way, with
TA-Lib's own parameter names validated at construction so a typo raises at
__init__ instead of silently running the default. INDICATORS.md
lists every reachable function, the handful that are refused and why, the
price= redirect, and the lookback and history_bars tables.
3. How the engine runs a bar (the loop in depth)¶
BacktestEngine.run() is a single async loop over the feed. For each bar it
executes three phases in a strict, deterministic order. Understanding this order
is understanding the framework.
Phase 0 — session boundaries (before the bar is matched).
- Day roll. The trading day is derived from the bar's close timestamp. When
it changes, the previous day is rolled: any open position is flattened at
16:10 ET (if not already), then the 17:00 ET close is reported to the rule
kernel, which ratchets the MLL floor. The Globex day resets at 18:00 ET.
- 16:10 ET flatten backstop. If a bar closes strictly after 16:10 ET and
the day hasn't been flattened, the clock is advanced to 16:10 and every
position is market-dumped (reason="eod_flatten") with slippage and the
liquidation fee — Topstep's end-of-session flatten. Anything a fill callback
then tries is rejected as outside the trading window.
Phase 1 — the broker settles the bar (SimBroker.on_bar). This happens
before your strategy sees the bar. The broker:
1. Ratchets any trailing stops from the previous bar's extremes.
2. Walks one deterministic intrabar price path —
open → adverse extreme → other extreme → close — resolving both order
fills and the rule kernel's real-time equity/breach checks in sequence
along that single path. (Pessimistic ordering: with a position on, the
adverse extreme is visited first, so a bar holding both your stop and your
target resolves as the stop.)
3. Marks equity and checks the MLL/DLL. A breach here forcibly liquidates
mid-bar and can end the run.
4. Delivers the resulting events — OrderModel → on_order, HalfTradeModel →
on_fill, PositionModel → on_position.
Phase 2 — your strategy decides (Strategy.handle_bar → on_bar). Now your
on_bar runs on the completed bar. Orders you place are stamped
accepted_ts = bar.ts_init (the close), so the participation firewall
(accepted_ts <= bar.ts_event, the open) makes them ineligible for the current
bar — they first participate next bar. Any events your orders generate are
dispatched immediately after.
After phase 2 the engine appends (bar close time, broker.equity()) to the
equity curve and moves on. When the feed is exhausted, a final session roll
closes the last day. on_start() fires once before the loop; on_stop() fires
in a finally regardless of outcome. The engine also asserts the feed is
time-ordered (ts_init non-decreasing) and raises rather than silently
reordering. Everything is single-threaded with one TestClock, which is why
two runs over the same inputs are byte-identical.
4. Input #1 — the data (bars)¶
What a Bar is¶
A Bar (topstep_backtest.protocols.Bar, a frozen msgspec.Struct) has:
| field | type | meaning |
|---|---|---|
bar_type |
BarType |
(contract_id, unit, unit_number) — identifies the stream |
ts_event |
int (ns) |
the bar's open time (venue window start) |
ts_init |
int (ns) |
the bar's close time — the no-look-ahead anchor |
open high low close |
Decimal |
OHLC, on the instrument's tick grid |
volume |
int |
contracts traded |
The one thing to burn in: ts_init is the close. Dispatch order is by
ts_init, so a strategy is handed a bar only once it has fully formed. (This is
the reverse of the naive "event = close" guess — ts_event is the open.) Times
are int nanoseconds since the UTC epoch; the canonical wall-clock timezone for
sessions is ET.
Getting bars in — three sources¶
A. Synthetic (what this tutorial uses). synthetic_bars(...) is a
deterministic, seeded random walk in integer ticks, so every price is exactly
on-grid and reproducible. Our call:
bars = synthetic_bars(
contract_id="CON.F.US.MNQ.U26",
spec=spec_for_symbol("MNQ"),
start_day=date(2026, 5, 4),
days=6, # 6 weekday sessions (weekends skipped)
seed=7, # same seed → identical bars, forever
start_price=Decimal("18000.00"),
bars_per_day=120, # ≤ 450 (09:30 ET + 450 min = 17:00 ET halt)
vol_ticks=12, # per-bar random move bound, in ticks
)
days counts weekdays emitted (it scans forward, skipping Sat/Sun — but not
exchange holidays, §11). drift_ticks_per_day adds a directional bias;
vol_ticks sets the noise amplitude. start_price must be on the tick grid and
comfortably above zero for the chosen volatility, or it raises.
B. Your own OHLCV — a pandas DataFrame (e.g. normalized Databento candles):
bars = bars_from_dataframe(
df, # cols (any case): timestamp,open,high,low,close,volume
contract_id="CON.F.US.MNQ.U26",
spec=spec_for_symbol("MNQ"),
unit=AggregateBarUnit.MINUTE,
unit_number=1,
stamp="open", # ← REQUIRED: what your timestamp MEANS
)
stamp has no default and this is deliberate. stamp="open" sets
ts_event = ts, ts_init = ts + one bar span; stamp="close" sets
ts_init = ts. Declaring it wrong shifts every signal by a full bar — the
classic silent look-ahead. (Databento OHLCV stamps at the open.) The wrangler
also requires tz-aware timestamps, converts prices via str → Decimal (never
through float), and rejects any price off the tick grid, each with row context
and the fix named. A bare integer index with no timestamp column is refused, so
0,1,2… can never be read as epoch nanoseconds.
Only SECOND, MINUTE and HOUR units are accepted. These are the units
with a fixed nanosecond span, which is what lets the wrangler derive the missing
edge of the bar from stamp. TICK has no time span at all, and DAY/WEEK/
MONTH are rejected because a Globex day is 23 hours running 18:00 ET →
17:00 ET: resampling to one honestly needs a session-aware resampler, and this
package does not have one. Resample to daily upstream if you need daily.
C. Plain tuples — no pandas: bars_from_records(rows, ...) takes an iterable
of (ts, open, high, low, close, volume) 6-tuples with the same required
stamp/unit/unit_number.
Validation — read this before trusting a verdict¶
validate_bars(bars, spec) → ValidationReport never raises; it accumulates
findings. report.ok is True only if there are no ERROR-severity issues
(the sole INFO code is session_gap). It flags: OHLC inconsistency, off-grid
prices (one issue per offending field), stamp inversion, duplicate/non-monotonic
timestamps, negative volume, weekend bars, and bars inside the 17:00–18:00 ET
maintenance halt. The Backtest facade (§5) runs this for you and refuses to
run on any ERROR unless you pass validate=False. There is deliberately no
exchange-holiday check — §11.
5. Input #2 — the instrument, and running it¶
The instrument spec (tick economics)¶
spec_for_symbol("MNQ") returns a frozen InstrumentSpec. For MNQ:
| field | value |
|---|---|
symbol |
"MNQ" |
tick_size |
Decimal("0.25") — minimum price increment |
tick_value |
Decimal("0.50") — dollars per tick |
point_value |
Decimal("2") — dollars per 1.00 move (= tick_value / tick_size) |
is_micro |
True |
venue / session_class |
CME / EQUITY (RTH 09:30–16:15 ET) |
cap_units |
1 (a micro weighs 1 toward the position cap; a mini weighs 10) |
Fifteen products ship built in: ES, MES, NQ, MNQ, YM, MYM, RTY, M2K, CL, MCL,
NG, GC, MGC, SI, SIL. Getting the symbol right is not cosmetic — it sets the
tick economics, and running an ES file as MNQ is exactly 25× wrong P&L. The
facade derives the spec from your bars' contract ids automatically.
The symbol cross-check. Because that error is silent — 25×-wrong P&L reads
as a plausible verdict — ../examples/run_real_data.py
guards it three ways:
--symbolis required, no default.- It is cross-checked against the file's basename. If the basename carries a
known product as its own token (
mnq_1m.csv) or as a contract code — symbol + delivery-month letter + year (MNQZ25.csv,esu6.parquet) — and that contradicts--symbol, the run refuses.--force-symbolskips this check, for a genuinely misleading filename or one naming several products. --contractis cross-checked too, and that check has no override: a contract id naming a different product than--symbolis refused outright, because the id is stamped on every bar.
The Backtest facade — the two-line runner¶
from topstep_backtest import AccountSize, Backtest
report = Backtest(bars, EmaCross("CON.F.US.MNQ.U26"), account=AccountSize.S50K).run()
print(report)
Full constructor:
Backtest(
data: Sequence[Bar],
strategy: Strategy, # an INSTANCE, never the class
*,
account: AccountSize = AccountSize.S50K,
dll_enabled: bool = False, # model a Personal Daily Loss Limit
validate: bool = True, # strict data validation; False to skip
account_id: int = 1,
fill_config: BarFillConfig | None = None, # §7
broker_config: SimBrokerConfig | None = None,
fee_model: TopstepFees | None = None, # None = the real schedule (§8)
)
It is exactly the hand-wired stack with zero semantic change: it derives
instruments from the feed's contract ids, validates each contract's bars against
its spec, and wires one TestClock shared by broker and engine. That last
one is the single most important assembly invariant — a second clock stuck at 0
would stamp every order accepted_ts=0, i.e. eligible for the current bar:
silent look-ahead. Economics are always the real ones (combine_params(account)
plus the real Topstep fee schedule) unless you pass a corrected fee_model (§8).
Three things it refuses, loudly: passing the strategy class rather than an
instance (TypeError, naming the fix); any backtesting.py economics knob
(cash=, commission=, margin=, spread=, trade_on_close=, hedging=,
exclusive_orders=, finalize_trades= each raise ValueError naming this
project's alternative — you pick account= rather than cash, fees are the real
schedule rather than a commission, and trade_on_close is the exact look-ahead
the firewall forbids); and running twice — engine, broker, kernel and
strategy state are single-use, so build a fresh Backtest with a fresh strategy
instance per run.
Backtest.from_dataframe(df, strategy, *, contract_id=, stamp=, unit=,
unit_number=, ...) runs the §4 wrangle-and-validate path first, then assembles
identically and takes the same keywords. run() is sync (it wraps
asyncio.run); inside a running event loop, await bt.arun() instead.
6. Output — reading the result¶
report = ...run() returns a Report. print(report) renders this (the actual
seed-7 output):
== Topstep Combine 50K: IN_PROGRESS ==
combine still in progress at end of data
window 2026-05-04 09:31 ET -> 2026-05-11 11:30 ET (7d 01:59:00 calendar)
balance 50000.00 -> 49995.16 (net -4.84)
total profit -4.84 (target 3000.00)
best day 84.04 (cap -2.42 = 50% of total)
MLL floor 48000.00 (distance 1995.16)
days traded 5 closing trades 8 half-turns 16
warmup 26 bars gated before the first decision
day trail
2026-05-04 eod= 49981.04 pnl= -18.96 floor= 48000.00 *
2026-05-05 eod= 49972.56 pnl= -8.48 floor= 48000.00 *
2026-05-06 eod= 49955.08 pnl= -17.48 floor= 48000.00 *
2026-05-07 eod= 49911.12 pnl= -43.96 floor= 48000.00 *
2026-05-08 eod= 49995.16 pnl= +84.04 floor= 48000.00 *
2026-05-11 eod= 49995.16 pnl= +0.00 floor= 48000.00
summary stats
verdict IN_PROGRESS
closed trades 8
!! PROVISIONAL — 8 closes is under the 200 this framework requires before
treating a measured edge as distinguishable from sampling noise. Every
rate, ratio and percentile below is an ESTIMATE, not a finding.
win rate (gross) 37.50%
expectancy (gross) +4.38
expectancy (R=avg L) 0.37
avg win / avg loss 31.33 / 11.80
payoff ratio 2.66
profit factor (gross) 1.59
longest losing streak 5
net P&L -4.84
breakeven cost/half -0.30
sortino (daily) -0.04
calmar (profit/DD) -0.04
distance to floor 1995.16
consistency headroom -86.46
days traded 5
equity peak 50032.76 <- what a trailing floor anchors to
exposure 28.33% of bars held a position
drawdown (three conventions — a strategy can survive one and violate another)
static (from start) 105.12
EOD trailing 88.88 <- Topstep's MLL mechanic
EOD trailing (avg) 88.88 <- mean of 1 episode(s); near the max = the norm
intraday trailing 147.88 <- harshest; modelled path, not ticks
peak-to-trough 137.88 <- close-sampled; the legacy `max_drawdown`
longest 6 day(s) below the EOD high-water mark
time to recovery not recovered
min floor headroom 1890.88 (2026-05-08)
round trips (8 flat-to-flat — NET of fees, unlike the gross figures above)
win rate (net) 37.50%
expectancy (net) -0.60
avg win / avg loss 28.85 / 18.28
payoff ratio 1.58
expectancy (true R) -0.02 (8/8 with a stop at entry)
best / worst R 1.94 / -0.94
best / worst trade +77.52 / -37.48 <- read the worst against the DLL
holding time avg/max 01:00:22 / 04:45:00
daily P&L (6 closed: 1 win / 4 lose / 1 flat)
worst -43.96
p05 -43.96
p25 -18.96
median -17.48
p75 +0.00
p95 +84.04
best +84.04 (consistency ratio undefined: no net profit to share)
stdev 40.27 <- dollars, so it compares directly against the DLL
topstep-backtest 0.2.0 — unofficial simulation, not affiliated with Topstep. Rule and fee constants are cited config, NOT calibrated against a live account (docs/topstep-rules.md §9): treat the verdict as a diagnostic, not an authoritative pass/fail.
Line by line:
- Verdict —
IN_PROGRESShere: the run neither reached the $3,000 profit target nor breached the floor. The three verdicts arePASSED,FAILED,IN_PROGRESS(a run over too little data endsIN_PROGRESS, which is honest, not a pass). Thereasonstring sits beneath it. - balance / total profit — realized 50,000 → 49,995.16, net −4.84 after all
fees.
total profitis measured off the last EOD close, against thetarget. - best day / cap — the consistency rule: your single best trading day
must be ≤ 50% of total profit. Here total profit is negative, so the cap is
negative and the rule is nowhere near satisfied (see
consistency headroom). - half-turns — 16, i.e. 8 entries + 8 exits at 2 contracts each. Fees are charged per half-turn, so this is the number that costs you money.
- warmup — 26 bars were gated before the first decision (the
Crossneeded 27 bars to become ready; §2). - day trail — the numbers that decide a combine. Per closed day:
eodbalance,pnl(net of fees), thefloorafter that day's ratchet, and*if the day had trades. Notice the floor never moves here — it only ratchets up on a new equity high at EOD, and this run never made one. - provenance — always printed, naming the engine version and stating that
the rule and fee constants are not calibrated, so a screenshotted verdict
carries its own caveat. The version is read from the installed distribution:
this is the one line above that legitimately differs from your run (a source
checkout reports a
.devNsuffix, not0.2.0). REJECTED— absent here, and its absence is information. When the broker refuses any order placement the render grows aREJECTEDline above the day trail, counting the refusals per gateway error code. Without it a strategy whose every order was refused prints a flawless zero-trade report indistinguishable from one that simply never signalled.
Reading the four statistics blocks¶
The blocks below the day trail are where a report most easily lies to you, so read the basis on each one before the number:
!! PROVISIONAL— 8 closes is far under the 200 this framework wants before an edge is distinguishable from sampling noise. Nothing is suppressed; every rate and ratio below it is still arithmetically correct and is still an estimate, not a finding. Quote it as such.summary statsare GROSS (win rate, expectancy, payoff, profit factor) — fees are charged per half-turn and deducted separately, sonet P&Lis the only net figure in the block. That is why this run showsprofit factor 1.59and still loses money.expectancy (R=avg L)is an approximation using R = the average loss. The true R lives in the round-trips block; prefer it when a stop was set.breakeven cost/half— additional cost per half-turn that would zero the run. Negative means you are already under water by that much per half-turn.sortino/calmarare daily-dollar and combine-horizon basis, not annualized — annualizing a five-day sample would be fiction.exposure— 28.33% of bars held a position. Read it before the drawdown numbers, because it says what they are a sample of: this strategy was off the tape roughly seven bars in ten, so its 88.88 EOD drawdown was drawn from far less exposure than a same-drawdown strategy sitting in the market all day. Multi-instrument runs count a bar once — this is time with risk on, not a sum of per-symbol exposures.equity peak— the high-water mark a trailing floor anchors to, which is why it is worth printing next to the floor itself. Close-basis, seeded at the starting balance, soequity peak − peak-to-troughis the trough (50,032.76 − 137.88 = 49,894.88). Topstep's real MLL ratchets end-of-day, so the floor actually in force followed the EOD peak, at or below this one.drawdowngives all three conventions prop firms use, because they are different numbers on one path:staticfrom the fixed initial balance,EOD trailing(Topstep's actual MLL mechanic), andintraday trailing(Apex-style, ratchets on unrealized highs — always the harshest). Here they are 105.12 / 88.88 / 147.88 on the same run.min floor headroomis how close the account ever came to termination, as againstdistance to floor, which is only where it ended.EOD trailing (avg)averages episodes — each separate excursion below the high-water mark, measured at its own trough — whileEOD trailingis the max of that same set. This run had one episode, so the two are equal, which is itself the reading: there is no "typical" drawdown to contrast the worst one against. On a longer run, a max far above the mean is one bad week; a max close to it means the curve lives at that depth.round tripsare NET of fees — the basis flips deliberately, because a flat-to-flat excursion is a complete decision and what matters is what it earned after costs. Note this run:expectancy (gross) +4.38per half-turn versusexpectancy (net) -0.60per round trip. The net one is the one that pays you.expectancy (true R)is net P&L over the dollars actually risked at entry, and8/8 with a stop at entrytells you every trip had a defined risk. Aworst Rbelow −1 would mean a stop was jumped by slippage.best / worst tradeis the same excursions in dollars, and it answers a different question tobest / worst R. R asks how a trade went against its own plan; dollars ask whether the account could absorb it. A −0.94R loss sounds disciplined right up until you notice it was −37.48 against a daily loss limit — check the worst trade against the DLL and againstdistance to floorbefore you trust either ratio.holding time avg/max— one hour typical, 4h45m at the longest. This is a rule question, not trivia: the engine flattens everything at 16:10 ET, so a strategy whose typical hold approaches the session's remainder is one the flatten keeps closing at whatever the tape offers, not one exiting on its own signal.daily P&L— the distribution you need to reason about a Daily Loss Limit.worstis the one bad day you drew;p05is the one you should size against.stdevis the same distribution's spread in dollars, which is what makes it comparable to a dollar-denominated DLL directly: 40.27 here, so a limit within one standard deviation of the mean day would be getting hit routinely.
Beyond one backtest¶
Everything above describes one sample. metrics.monte_carlo block-bootstraps
this run's own trading days and replays thousands of synthetic Combines through
the real rule kernel, returning a pass probability and — more usefully — an
autopsy of how the failures happened. See §9, experiment 4.
The three output objects¶
Report holds four things: report.result (the frozen BacktestResult),
report.stats (a typed SummaryStats), report.trades (the broker's
tuple[HalfTradeModel, ...]), and report.bars_gated (the warmup-gated bar
count, None for a non-SymbolStrategy).
BacktestResult (report.result) — persist it with
msgspec.json.encode(result) and diff two runs byte-for-byte:
| field | meaning |
|---|---|
verdict / reason |
PASSED / FAILED / IN_PROGRESS + human string |
day_records |
tuple of DayRecord(day, eod_balance, day_pnl, floor_after, had_trade) — the day trail |
best_day, total_profit, floor |
the consistency pair, and the final trailing-MLL floor |
breach |
a Breach(kind, ts_ns, equity, limit) if failed, else None (holds only the terminal MLL breach) |
equity_curve |
tuple[(ts_ns, equity), ...] — one close-basis mark per settled bar |
rejections |
tuple[(gateway error_code, count), ...], ascending by code; empty on a clean run |
starting_balance, ending_balance, profit_target, trade_count, days_traded |
economics and counts |
SummaryStats (report.stats) — combine-centric, each field's basis stated
on the struct (no Alpha/Beta/Kelly: meaningless for a 20-session pass/fail). The
one confusion to avoid is the basis: win_rate / expectancy / profit_factor
are gross, classified on each closing half-turn's profit_and_loss with fees
charged separately per half-turn, and are None when undefined. net_pnl is
ending_balance − starting_balance, net of all fees. That is why this run
shows a 1.59 profit factor next to a negative net_pnl: fees ate the gross
edge. Also max_drawdown (peak-to-trough on the close-basis equity curve plus a
terminal mark at ending_balance, so the final flatten's costs land in it),
consistency_headroom (0.5 × total_profit − best_day; negative means the best
day is too large), closed_trades, distance_to_floor, final_balance,
days_traded.
report.trades — real SDK HalfTradeModels carrying price, side,
size, fees, commissions, creation_timestamp, and profit_and_loss
(None on a pure opening fill; FIFO-realized gross P&L on a closing half-turn).
The first entry in this run:
HalfTradeModel(price=17990.75, fees=0.74, commissions=0.50, side=BID, size=2,
profit_and_loss=None, ...)
A 2-lot long entry: commissions = 0.25 × 2 = 0.50, fees = (0.35 + 0.02) × 2 =
0.74 — i.e. $0.62/side/contract (§8). profit_and_loss is None because it
opened the position; the P&L lands on the closing half-turn.
7. Execution semantics you must internalize (Tier-0 fills)¶
Fills come from the Tier-0 bar-fill model — a documented-conservative
approximation that resolves everything along the one intrabar price path from §3.
BarFillConfig exposes its three pessimism knobs (pass via
Backtest(..., fill_config=BarFillConfig(...))):
BarFillConfig(
stop_slippage_ticks: int = 1, # adverse ticks on EVERY triggered stop
market_slippage_ticks: int = 0, # adverse ticks on market fills
fill_limit_on_touch: bool = False, # False = need a full tick THROUGH the level
)
The rules that follow from it:
- Orders placed in
on_barare eligible from the next bar; market orders (includingclose) fill at the next bar's open. This is the participation firewall — an order accepted at a bar's close cannot fill inside that same bar. - Limit orders need the bar to trade a full tick through the level (touch ≠
fill) by default. Set
fill_limit_on_touch=Truefor touch semantics — useful as a sensitivity probe, not as a default. - Stops gap-fill at the open when the bar opens beyond the trigger, else fill at trigger ± ≥ 1 tick slippage. Stop slippage is always applied, and a slipped stop price may print outside the bar's high/low — that models real slippage and is deliberately not clamped.
- A bar containing both your stop and your target resolves adverse-extreme-first — the stop wins. This is the pessimistic default.
- Bracket children (the OCO stop/target) are created only after the entry fills, at signed-tick offsets from the actual entry fill price, and first become active the bar after the entry fill. Don't expect a protective stop to exist on the entry bar itself.
- Don't use
wait_for_fillin a backtest — it raises with guidance. React inon_order/on_fill(the parity-safe idiom that also works live).
The SimBroker supports MARKET, LIMIT, STOP, TRAILING_STOP (absolute
trail_price anchor), signed-tick OCO brackets, netting, and exact-Decimal
P&L; STOP_LIMIT and JOIN_BID/JOIN_ASK are rejected at Tier-0 because they need
live quote data. Rejections are the SDK's APIError with real gateway codes —
4 is the one you will actually hit (position cap, account failed, day
locked), 5 is outside the 16:10–18:00 ET window or a weekend — and with
SymbolStrategy they route to on_reject while the sugar returns None. Every
rejection is counted by code at the single choke point all order paths funnel
through: read the tally off SimBroker.rejections mid-run, or
result.rejections afterwards (§6).
8. The rule engine — what "passing" actually means¶
The CombineKernel enforces Topstep's rulebook in real time, not as an
after-the-fact score. It is driven by three inputs the engine feeds it: trade
activity, every equity tick, and each 17:00 ET session close. Every constant it
uses — per account size, with its source — is in
topstep-rules.md; combine_params(AccountSize.S50K)
returns the set this run used (48,000 floor, 3,000 target, 50 micro-unit cap).
Only two behaviours matter for reading the report above, and both are why the kernel is not a post-processing step:
- The MLL floor is a one-way ratchet, checked in real time. It starts at
starting_balance − mll_buffer, moves up only on a new EOD equity high, never down, and locks permanently once it reachesstarting_balance. That is why the floor in this run's day trail never moves: no day closed at a new high. The breach check, though, runs on every equity tick including unrealized P&L — the instantequity ≤ floorthe account fails and the broker forcibly liquidates mid-bar with slippage and a $10/contract fee. A spike below the floor at 10:14 ends you at 10:14, not at that day's close. - The DLL is a lockout, not a violation. Off by default
(
dll_enabled=True). Hittingday_start_balance − dllintraday flattens and locks trading for the rest of the day; it does not fail the combine and is not stored as the terminal breach. Only an MLL breach populatesresult.breach.
PASS requires all three at a session close: closed_balance ≥ starting +
target and total_profit > 0 and best_day ≤ 0.5 × total_profit.
Our EMA run reached none of these on synthetic random-walk data — which is the
honest result. A simple crossover has no inherent edge; the framework's job is
to tell you that truthfully, with real fees and real risk limits applied.
Fees are the real Topstep schedule (TopstepFees), always applied, split on
each HalfTradeModel as fees = (exchange + NFA) × qty and
commissions = commission × qty. For MNQ: 0.25 commission + 0.35 exchange +
0.02 NFA = $0.62/side/contract (~$1.24 round-turn per contract). The
schedule is researched, not calibrated, so it is correctable per product:
Backtest(..., fee_model=TopstepFees(overrides={...})) replaces the built-in
schedule for the named symbols against a real blotter, without hand-wiring the
stack. Correcting a rate is not the same as tuning one — there is still no way to
turn fees off, which is the point of a trustworthy verdict.
9. Experiments — make the verdict earn your trust¶
Three checks, in this order, before you trust any verdict — this one or your own.
1. Determinism. Run twice (fresh Backtest and fresh strategy each time —
both are single-use) and compare the encoded results byte for byte:
import msgspec
a = Backtest(bars, EmaCross(contract)).run().result
b = Backtest(bars, EmaCross(contract)).run().result
assert msgspec.json.encode(a) == msgspec.json.encode(b) # True
2. Sensitivity probes — a verdict that flips under any of these is a finding about the strategy's fragility, not noise:
from topstep_backtest.fills.bar_fill import BarFillConfig
optimistic = BarFillConfig(fill_limit_on_touch=True) # touch, not trade-through
rough = BarFillConfig(stop_slippage_ticks=2, market_slippage_ticks=1)
Backtest(bars, EmaCross(contract), fill_config=optimistic).run()
Backtest(bars, EmaCross(contract), fill_config=rough).run()
Backtest(bars, EmaCross(contract), account=AccountSize.S150K).run()
Backtest(bars, EmaCross(contract), dll_enabled=True).run()
The strategy's own typed keywords tune the same way:
EmaCross(contract, fast=9, slow=21, size=1, stop_loss_ticks=32,
take_profit_ticks=64). When you sweep fast/slow, pin warmup= so every
parameter set gets an identical warmup window — otherwise you are comparing runs
over different amounts of data.
3. Hand-check a fill. The first trade of the seed-7 run, end to end — every number below is reproducible from the snippets in this page:
| step | value |
|---|---|
| cross-up bar (1-indexed) | 68 — opens 10:37 ET, closes 10:38 ET |
fast / slow on that bar |
17986.968615299105 / 17986.62283658429 |
| entry fill | 17990.75 at 10:38 ET — bar 69's open, not bar 68's close |
| bracket legs | entry ± 40/80 ticks = 17980.75 stop, 18010.75 target |
| entry costs | commissions = 0.25 × 2 = 0.50, fees = 0.37 × 2 = 0.74 |
| exit | 17992.00 on the cross-down (neither leg was hit), gross P&L +5.00 |
That exit P&L is worth doing by hand: (17992.00 − 17990.75) × 2 contracts ×
$2/point = $5.00, exact to the cent. Money is exact Decimal on the tick
grid — any penny of drift there is a bug worth reporting. Indicator values are
the deliberate exception: 17986.968615299105 is a float64 result carried into
Decimal and is not on the 0.25 grid, because an indicator level is not a
tradeable price (§2).
Experiment 4: stop trusting one sample¶
Everything so far describes the single tape you happened to test. The question worth acting on is what fraction of plausible futures this strategy passes:
from topstep_backtest.metrics import monte_carlo
from topstep_backtest.rules.params import AccountSize, combine_params
report = Backtest(bars, EmaCross(MNQ), account=AccountSize.S50K).run()
mc = monte_carlo(report.result, params=combine_params(AccountSize.S50K), paths=3000, seed=7)
print(f"P(pass) {mc.pass_probability:.1%}")
print(f" died: MLL breach {mc.mll_breach_probability:.1%}")
print(f" consistency {mc.consistency_blocked_probability:.1%}")
print(f" too slow {mc.target_not_reached_probability:.1%}")
monte_carlo resamples this run's own trading days in contiguous blocks
(never i.i.d. — day-to-day clustering is exactly what a trailing drawdown bets
against) and replays each synthetic sequence through the real CombineKernel,
carrying each day's worst intraday equity excursion so an intraday MLL breach is
reproduced rather than missed.
Read the autopsy, not the headline. The three failure modes imply three different fixes and are not interchangeable:
| mode | what it means | what to do |
|---|---|---|
mll_breach |
too much risk per day | resize |
consistency_blocked |
the money is made in too few days | throttle the outsized day — the edge is fine |
target_not_reached |
the edge is too slow for the window | nothing risk-side helps; find a better edge |
examples/run_montecarlo.py runs one strategy at two position sizes to show
this discriminating: identical edge, 100% "too slow" at 2 contracts and 100%
"MLL breach" at 30. Size does not change the edge — it changes which way you
fail.
Two behaviors worth knowing. monte_carlo raises on an empty sample rather than
reporting a confident 0% on no evidence. And the horizon defaults to one
billing month (BILLING_MONTH_DAYS = 21, the unit evaluate_ev bills in),
never the observed day count: a blown run stops recording days at the breach, so
that count is a survival time, not a Combine length, and source_truncated
flags such a sample as survivorship-biased by construction — the days after the
blow-up do not exist.
Never quote the pass probability alone. With 3,000 paths its simulation error
is negligible, which makes it easy to over-read — the real uncertainty is the
handful of observed days it resampled, the block-length assumption, and the
regimes the tape never held. metrics.confidence quantifies each:
from topstep_backtest.metrics import crosscheck, mc_confidence, sequential_combines
c = mc_confidence(report.result, params=combine_params(AccountSize.S50K), seed=7)
print(f"P(pass) {c.mc.pass_probability:.1%}")
print(f" 90% CI [{c.ci.p05:.1%}, {c.ci.p95:.1%}] <- quote THIS, not the point")
print(f" block spread {c.sensitivity.spread:.1%} <- wide = the streak assumption decides")
for stratum in c.by_year.strata:
print(f" {stratum.year} {stratum.mc.pass_probability:.1%}")
# The empirical counterpart: the same tape as real, separate 21-day attempts,
# then both estimates side by side. Same horizon on both sides by default —
# one billing month — or crosscheck refuses the comparison.
sweep = sequential_combines(bars, lambda: EmaCross(MNQ), window_days=21)
check = crosscheck(c.mc, sweep)
print(f"vs {check.attempts} real windows divergent={check.divergent}")
report.to_html("run.html", confidence=c, crosscheck=check) # the cards, archived
The CI is the error bar the source-day count earns — a 65% from 500 days and a 65% from 40 are different findings. The per-year strata refuse to average a hostile year against a kind one. And the crosscheck puts the bootstrap beside the real windows under a binomial 2×SE null: agreement is the strongest validation available without a live account, and when they disagree, the disagreement is the finding — usually losses clustering in a way the block length missed.
10. Live parity — the same class against the real gateway¶
EmaCross never imports a broker, a clock, or a fill model. It reaches the venue
only through self.ctx — ctx.orders, ctx.positions, ctx.history,
ctx.clock, ctx.account_id, ctx.instrument(cid) — which are the exact
topstep-sdk surfaces. The backtest SimBroker and the live
AsyncTopstepClient both satisfy them, and tests/parity/test_broker_conformance.py
fails if the SDK surface drifts. Going live is pure wiring: point the same
strategy at the live client instead of the sim. The async hooks and awaited
orders you wrote here are the live contract — that's why the dialect insists
on them.
Be precise about what that test proves. It is a structural conformance
proof: pyright-strict Broker/OrderApi/PositionApi/HistoryApi conformance
on both concrete types, plus a keyword-signature diff against the SDK's own
place(). A strategy written here therefore compiles and binds against the
live client, and a drifted SDK surface is caught. What is not proven is
behavioural: nothing replays one strategy through the sim and a recording live
broker and asserts an identical ordered Submit/Modify/Cancel sequence
(ROADMAP.md Phase 4 names that gate; it does not exist). Until it
does, "the same class runs live" is a claim about the surface, not a demonstrated
agreement of intent — verify your first live session against a sim run over the
same bars yourself.
One live-side action item the backtest cannot do for you: warm the
indicators. A live session starts with an empty buffer, so preload
max(indicator.history_bars for indicator in ...) bars of history before you act
on a signal — for EmaCross, Ema(26).history_bars == 1664 one-minute bars.
Preload that many and every indicator reports warm and matches the backtest bit
for bit; preload only lookback (26) and the values are merely close, still
carrying where that session happened to start. ready is the warmup gate,
warm is the parity gate (§2).
11. Honest caveats (don't skip)¶
- Bar-tier fills are conservative approximations. A strategy whose edge depends on intrabar timing or passive queue position needs the future quote/depth fill tiers before you trust it.
- Fee and rule numbers are researched-but-uncalibrated (July 2026). Treat
marginal verdicts as within noise until calibrated against a real account;
re-verify the rulebook against Topstep's help center
(
topstep-rules.md§9). - There is no exchange calendar — holidays are your problem. Nothing in this
package knows about market holidays or early closes: the validator flags
weekends only, the broker's market-closed check covers weekends only, a
holiday session in your data is replayed as an ordinary weekday, and
synthetic_barswill happily emit one. A table used to exist and was removed in 0.1.0 because it was wrong in the dangerous direction — it marked MLK Day, Presidents' Day, Memorial Day, Juneteenth and Labor Day as full closures when CME equity index runs a half session on each, and the cleaning step that consumed it ran by default, so it silently deleted tradable sessions from your feed. A missing calendar you must supply beats a wrong one you can't see. Filter exchange holidays upstream, before the bars reach the wrangler. - Parity is proven structurally, not behaviourally. Protocol conformance and the SDK signature diff are enforced; an intent-sequence test against a recording live broker is not yet written (§10).
- Indicator values are float64, and TA-Lib's conventions are the authority.
Reruns stay byte-identical, but the values are not
Decimal-exact, not tick-snapped, and TA-Lib's edge cases are inherited whole —Rsion a run of equal closes reads 0, not 100, andStdDev/BBandsuse the naiveE[x²] − E[x]²variance, enough noise at 100k price levels to flip aCrosssitting on a band edge.INDICATORS.mdcatalogues these. - This run is never warm. 720 bars is well under
Ema(26).history_bars(1,664), so every number on this page is a valid, deterministic backtest result and not a bit-exact live-parity demonstration. Parity needs a feed longer than the window — that is the point of §10, not a defect of this one. - Combine only. Funded-account (XFA) modeling is deliberately parked.
Where to go next¶
../examples/ema_cross.py— this strategy, runnable.../examples/sma_cross.py— the SMA twin.../examples/session_scoped.py— the same chassis on a 24h tape, where you must decide which bars feed each indicator. Both EMAs here see every bar you hand them; on a Globex tape that includes Asia and London, which is right for a level and wrong for a volatility measure.../examples/run_real_data.py— your CSV/Parquet → verdict.../examples/hand_wired.py— the sim stack assembled by hand (feed → engine → broker on one clock), every seam visible; the version you'd repoint piece-by-piece at the live client.INDICATORS.md·topstep-rules.md·ROADMAP.md·DESIGN.md·../AGENTS.md