Skip to content

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:

  1. A Bar is a completed candle stamped at its close. Your strategy cannot see or act on an unfinished bar. (§4)
  2. 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)
  3. You talk only to self.ctx — protocol surfaces that both the backtest SimBroker and the live AsyncTopstepClient satisfy. Write once. (§5)
  4. 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 / close sugar with tick-defined OCO brackets.

The constructor calls super().__init__(contract_id):

SymbolStrategy(contract_id: str, *, require_ready: bool = True, warmup: int | None = None)

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 period closes, then the standard recursion ema += k * (close − ema) with k = 2 / (period + 1) — TA-Lib's convention. On this tutorial's feed the 12th bar's Ema(12) reads 17993.395833333332, which is exactly mean(first twelve closes).
  • lookback == period — it is ready exactly on its period-th update. Other families differ; INDICATORS.md has the table.
  • update(bar) appends to a bounded float64 buffer, 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.up is True on the bar where a − b goes from ≤ 0 to > 0.
  • self.cross.down is True where a − b goes from ≥ 0 to < 0.
  • An exact a == b touch 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_price makes it a LIMIT; stop_price makes 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_ticks are 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), or None on rejection. A rejection (position cap hit, outside the trading window) is normal combine control flow, not an exception — the sugar swallows the APIError and routes it to on_reject (overridable; default: ignore). The raw raising path is still there as self.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:

  • --symbol is 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-symbol skips this check, for a genuinely misleading filename or one naming several products.
  • --contract is cross-checked too, and that check has no override: a contract id naming a different product than --symbol is 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_PROGRESS here: the run neither reached the $3,000 profit target nor breached the floor. The three verdicts are PASSED, FAILED, IN_PROGRESS (a run over too little data ends IN_PROGRESS, which is honest, not a pass). The reason string sits beneath it.
  • balance / total profit — realized 50,000 → 49,995.16, net −4.84 after all fees. total profit is measured off the last EOD close, against the target.
  • 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 Cross needed 27 bars to become ready; §2).
  • day trail — the numbers that decide a combine. Per closed day: eod balance, pnl (net of fees), the floor after 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 .devN suffix, not 0.2.0).
  • REJECTED — absent here, and its absence is information. When the broker refuses any order placement the render grows a REJECTED line 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 stats are GROSS (win rate, expectancy, payoff, profit factor) — fees are charged per half-turn and deducted separately, so net P&L is the only net figure in the block. That is why this run shows profit factor 1.59 and 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 / calmar are 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, so equity peak − peak-to-trough is 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.
  • drawdown gives all three conventions prop firms use, because they are different numbers on one path: static from the fixed initial balance, EOD trailing (Topstep's actual MLL mechanic), and intraday 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 headroom is how close the account ever came to termination, as against distance 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 — while EOD trailing is 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 trips are 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.38 per half-turn versus expectancy (net) -0.60 per 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, and 8/8 with a stop at entry tells you every trip had a defined risk. A worst R below −1 would mean a stop was jumped by slippage.
  • best / worst trade is the same excursions in dollars, and it answers a different question to best / 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 against distance to floor before 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. worst is the one bad day you drew; p05 is the one you should size against. stdev is 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_bar are eligible from the next bar; market orders (including close) 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=True for 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_fill in a backtest — it raises with guidance. React in on_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 reaches starting_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 instant equity ≤ floor the 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). Hitting day_start_balance − dll intraday 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 populates result.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_bars will 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 — Rsi on a run of equal closes reads 0, not 100, and StdDev/BBands use the naive E[x²] − E[x]² variance, enough noise at 100k price levels to flip a Cross sitting on a band edge. INDICATORS.md catalogues 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