Metadata-Version: 2.5
Name: topstep-backtest
Version: 0.4.0
Summary: Event-driven backtesting framework for Topstep Trading Combine strategies, with backtest/live parity against topstep-sdk.
Author-email: Tarric Sookdeo <tarricsookdeo@outlook.com>
License-Expression: MIT
License-File: LICENSE
Keywords: backtesting,combine,futures,prop-firm,topstep,trading
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: msgspec<1.0,>=0.18
Requires-Dist: ta-lib<0.8,>=0.7.1
Requires-Dist: topstep-sdk<0.2,>=0.1.2
Requires-Dist: tzdata>=2024.1; sys_platform == 'win32'
Provides-Extra: data
Requires-Dist: pandas>=2.2; extra == 'data'
Requires-Dist: pyarrow>=17; extra == 'data'
Provides-Extra: dev
Requires-Dist: hypothesis>=6.100; extra == 'dev'
Requires-Dist: pandas>=2.2; extra == 'dev'
Requires-Dist: pyarrow>=17; extra == 'dev'
Requires-Dist: pyright>=1.1.380; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
Provides-Extra: docs
Requires-Dist: mkdocs-gen-files<1,>=0.5; extra == 'docs'
Requires-Dist: mkdocs-material<10,>=9.5; extra == 'docs'
Requires-Dist: mkdocs<2,>=1.6; extra == 'docs'
Requires-Dist: mkdocstrings[python]>=0.26; extra == 'docs'
Description-Content-Type: text/markdown

# topstep-backtest

An event-driven backtesting framework for developing futures strategies that can
**profitably pass the Topstep Trading Combine**.

Two things make it different. **Backtest/live parity**: a strategy is written once against
structural protocols that both the deterministic `SimBroker` *and* the live
`AsyncTopstepClient` from `topstep-sdk` satisfy. **A first-class prop-firm rule engine**:
the two-state trailing Maximum Loss Limit, optional Daily Loss Limit, consistency target,
position caps and session flatten are enforced *in real time* — including intrabar forced
liquidation with adverse slippage — not scored after the fact.

Money is exact `Decimal` on the tick grid, lots are FIFO, reruns are bit-for-bit
reproducible. `SimBroker` runs the full order lifecycle (market/limit/stop/trailing,
signed-tick OCO brackets, gateway-parity `APIError` rejections) and resolves fill-vs-breach
intrabar along one pessimistic price path. Every indicator is TA-Lib, driven bar by bar, so
no formula is re-implemented here to drift. Module map in `AGENTS.md`.

```python
class SmaCross(SymbolStrategy):
    def __init__(self, contract_id: str) -> None:
        super().__init__(contract_id)
        self.fast = self.use(Sma(20))  # TA-Lib SMA — causal by construction
        self.slow = self.use(Sma(50))  # == TalibIndicator("SMA", timeperiod=50)
        self.cross = self.use(Cross(self.fast, self.slow))  # compares them; not TA-Lib

    async def on_bar(self, bar: Bar) -> None:  # gated until every use()d indicator is ready
        if self.cross.up and self.position.flat:
            await self.buy(2, stop_loss_ticks=40, take_profit_ticks=80)  # signed-tick OCO
```

The bracket becomes two real reduce-only orders when the entry fills, so a running trade can
be managed rather than only abandoned: `await self.move_stop(ticks=0)` pulls every bracket
stop to breakeven (`ticks` is signed in the position's favour and measured from
`self.position.avg_price`, the venue's own average), `move_target` does the same for the
take-profit, and both return how many orders moved.

**Every named indicator is a typed alias for a TA-Lib function**, not a
reimplementation: `Sma(20)` *is* `TalibIndicator("SMA", timeperiod=20)`, and the generic
form reaches 152 of TA-Lib's 161 functions directly. Nothing in this repo implements an
indicator formula, so there is no second implementation to drift from the reference one.
`Cross` is the deliberate exception — TA-Lib has no crossover primitive, so it is a
framework helper that compares two TA-Lib outputs rather than computing anything.

**Sessions are two independent switches.** On a 24h tape you can scope each indicator's input
data and, separately, restrict when the strategy may trade:

```python
super().__init__(contract_id, trade_sessions=(NEW_YORK,))  # trade New York only
self.trend = self.use(Ema(50))  # ...but see the whole tape
self.atr = self.use(Atr(14), session=NEW_YORK)  # ...while this sees only NY
```

The `Ema` keeps consuming Asia and London — an indicator fed only the tradable window would
develop gaps — while every decision happens in New York. Which indicators to scope is a
modelling choice with a rule behind it: *levels are continuous across sessions, dispersion is
not*. `ASIA`/`LONDON`/`NEW_YORK` are defined in their own timezones, so daylight saving comes
from the IANA database rather than a table that rots. See
[`examples/session_scoped.py`](examples/session_scoped.py).

## Install

```bash
pip install topstep-backtest
pip install "topstep-backtest[data]"   # adds pandas + pyarrow: DataFrame and Parquet input
```

Requires Python 3.12+. TA-Lib is a core dependency and ships wheels for common platforms;
on others you will need the TA-Lib C library first.

```python
from datetime import date
from decimal import Decimal

from topstep_backtest import AccountSize, Backtest, SymbolStrategy
from topstep_backtest.core.instruments import spec_for_symbol
from topstep_backtest.data.synthetic import synthetic_bars
from topstep_backtest.indicators import Cross, Sma

MNQ = "CON.F.US.MNQ.U26"


class SmaCross(SymbolStrategy):
    def __init__(self, contract_id: str) -> None:
        super().__init__(contract_id)
        self.fast = self.use(Sma(10))  # TA-Lib SMA
        self.slow = self.use(Sma(30))  # TA-Lib SMA
        self.cross = self.use(Cross(self.fast, self.slow))  # framework helper, not TA-Lib

    async def on_bar(self, bar) -> None:
        if self.cross.up and self.position.flat:
            await self.buy(1, stop_loss_ticks=40, take_profit_ticks=80)
        elif self.cross.down and self.position.is_long:
            await self.close()


bars = synthetic_bars(
    contract_id=MNQ,
    spec=spec_for_symbol("MNQ"),
    start_day=date(2026, 5, 4),
    days=5,
    seed=7,
    start_price=Decimal("23000.00"),
    bars_per_day=120,
    vol_ticks=12,
)
print(Backtest(bars, SmaCross(MNQ), account=AccountSize.S50K).run())
```

For your own data, `data.wrangler.bars_from_dataframe` (pandas) and `bars_from_records` (no
pandas) turn candles into validated `Bar` streams. Both make you declare `stamp="open"` or
`stamp="close"` — what your timestamps mean is the difference between a causal backtest and
an off-by-one-bar look-ahead.

## What you get back

`print(report)` renders the verdict, the day-by-day MLL trail, and four statistics blocks.
Every figure states its **basis**, because most admit two honest answers and mixing them
silently is how a report lies:

- **Trade statistics** — expectancy, payoff ratio, win rate, profit factor, longest losing
  streak, and `breakeven_cost_per_half_turn` (the extra cost per half-turn that would zero
  the run). Gross-basis, alongside net P&L. Under 200 closes the report prints a
  **`PROVISIONAL`** banner: nothing is suppressed, but a measured edge that thin is not
  distinguishable from sampling noise, and the report says so rather than letting you read
  it as a finding.
- **Drawdown, three ways** — `static` from the initial balance, `eod_trailing` (**Topstep's
  actual MLL mechanic**) and `intraday_trailing` (Apex-style, ratchets on unrealized highs).
  These are different numbers on the same path and a strategy can survive one while
  violating another. Plus duration, time-to-recovery, `avg_eod_trailing` (the mean episode
  depth, so you can see whether the worst one was typical), and `min_floor_headroom` — how
  close the account ever came to termination, as against where it merely ended.
- **Round trips** — flat-to-flat excursions with **true R-multiples**: net P&L over the
  dollars actually risked at entry, taken from the bracket stop. Net-basis, deliberately
  opposite to the gross trade statistics, because a round trip is a complete decision. Plus
  the dollar extremes and holding times: R says how a trade went against its own plan,
  `worst_trade` says whether the account could absorb it.
- **Daily P&L distribution** — worst day, p05/p25/median/p75/p95, and `stdev` in dollars, for
  reasoning about a daily loss limit against the day you should *size* for, not just the one
  you drew.
- **`exposure`** — the fraction of bars that actually held a position, which is what tells you
  how to read every figure above. The same drawdown at 5% and at 95% exposure are not the same
  risk. Alongside `equity_peak` (what a trailing floor anchors to) and the run's window.

The same report renders as an **interactive HTML tearsheet** — one self-contained file with
no server and no network, so it opens offline and archives next to the run:

```python
report.to_html("tearsheet.html")  # name the file
report.show()  # or write a temp file and open a browser
```

It draws the candlestick tape with every fill marked, the equity curve against the trailing
MLL floor, daily P&L and the R-multiple distribution, plus every statistic the text render
prints, basis labels included — the two renders share their formatting helpers, so they
cannot disagree. For a sheet from *every* run without naming a file each time,
`Backtest(...).run_with_tearsheet("runs")` writes `tearsheet-<UTC stamp>.html` and returns
both the report and the path.

**Replay a run bar by bar.** Pass `record=True` and the tearsheet grows a second tab: a
replay cockpit that fills the window — its own charts beside the settled state and the
running stats (by-type cards behind filter chips), the event log across the bottom, so
nothing has to be scrolled between. Step
through the run (buttons, slider, arrow keys, autoplay) with everything after the cursor
veiled, and watch the position, working orders (drawn as price lines), balance, floor
headroom, indicator values — named by the attributes your strategy stores them under — and
the running statistics as they accumulated, computed by the same code as the final report so
they cannot disagree with it. A strip across the tape carries the live trade numbers at the
cursor: the bar's OHLC, position and average entry, open P&L, the day, floor headroom, and
the closed-trade record so far. The charts follow the cursor in a window you pick (or fit the
whole run), the price axis labels the cursor's own close rather than the run's last — nothing
on a replay chart reports a bar the strategy had not reached — the stats mark what the newest
snapshot moved, and the event log filters by kind. The event log lists every decision with the exact parameters
and the broker's answer (rejections stay loud), every fill with its P&L and costs, and the
session enforcement between bars; `self.note("why")` inside a hook adds your own narrative at
the decision point. Recording is observation only — the report is byte-identical with it on
or off, pinned by a golden test — and `report.replay_json(path)` dumps the raw recording.
Long tapes embed a loudly-labelled window (`replay=(start, end)` chooses it;
`replay="full"` forces everything).

Then stop trusting one sample:

```python
from topstep_backtest.metrics import monte_carlo
from topstep_backtest.rules.params import AccountSize, combine_params

mc = monte_carlo(report.result, params=combine_params(AccountSize.S50K), paths=3000, seed=7)
print(
    f"P(pass) {mc.pass_probability:.1%}  |  died: "
    f"MLL {mc.mll_breach_probability:.1%} · "
    f"consistency {mc.consistency_blocked_probability:.1%} · "
    f"too slow {mc.target_not_reached_probability:.1%}"
)
```

`monte_carlo` block-bootstraps the run's own trading days — in contiguous blocks, never
i.i.d., because day-to-day clustering is exactly what a trailing drawdown bets against — and
replays thousands of synthetic Combines through the **real rule kernel**, carrying each day's
intraday equity excursion so an intraday breach is reproduced rather than missed. What
matters is not the pass probability but the **autopsy**, because the three failure modes
imply three different fixes:

| mode | what to do about it |
|---|---|
| `mll_breach` | too much risk per day — resize |
| `consistency_blocked` | money 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 |

The horizon defaults to one billing month (21 sessions) — a Combine has no time limit, only
a monthly fee, so "one attempt" means one fee cycle, the same unit `evaluate_ev` bills in.
And never quote the pass probability alone: `metrics.confidence.mc_confidence` attaches the
error bar the *source-day count* earns (a double-bootstrap CI — the path count was never the
real uncertainty), a block-length sensitivity row, and per-year strata, while
`metrics.confidence.crosscheck` compares the bootstrap against `sequential_combines`' real
windows — when the two disagree, the disagreement is the finding. The whole bundle renders
on the tearsheet via `report.to_html(path, confidence=..., crosscheck=...)`.

```bash
uv run python examples/run_montecarlo.py   # the same edge at two sizes, failing two ways
```

## Status and limitations

**Pre-alpha (0.2.0).** The engine core is well covered — exact-Decimal money on the tick
grid, FIFO lot accounting, a structurally enforced no-look-ahead firewall, byte-identical
reruns — but read these before trusting a number:

- **The rule and fee constants are NOT calibrated against a live account.** They are
  researched, source-cited config (`docs/topstep-rules.md` §9 — the "trading day" definition
  was calibrated 2026-08-03 and the engine already matched; the rest is still unchecked).
  Treat a `PASSED`/`FAILED` verdict as a diagnostic, not an answer, and distrust any result
  landing within a tick or a fee of a limit. This applies with *more* force to the
  Monte-Carlo pass probability: a figure printed to one decimal from unverified constants is
  precise, not accurate.
- **Analytics can only resample the tape you gave them.** Walk-forward, PBO, deflated Sharpe
  and the EV-per-attempt model all ship (`metrics/walkforward.py`, `metrics/overfitting.py`,
  `metrics/economics.py`), but none of them escapes your sample: the Monte-Carlo resamples a
  strategy's own observed days, so it cannot invent a market regime your tape never contained
  and will understate tail risk on a short or single-regime sample.
- **No per-year, per-regime or per-session performance breakdown.** Sessions scope which bars
  an indicator is computed from and when a strategy may trade, but nothing in `SummaryStats`
  splits P&L by session, so a scoping choice is one you make on reasoning rather than one the
  report scores for you.
- **No exchange holiday calendar ships with this package.** Bars on market holidays and
  past early-close halts are not detected, flagged or filtered anywhere — filter them
  upstream. (A built-in calendar was removed in 0.1.0: it disagreed with CME on several
  dates a year and silently discarded tradable sessions, which is worse than not having
  one.) Weekends and the 17:00–18:00 ET maintenance halt *are* modelled.
- **Multi-year runs need stitching, which now ships.** `data.continuous.stitch_continuous`
  back-adjusts several expiries into one continuous series labelled with the bare ticker
  (`"MNQ"`). A *raw* splice does not corrupt P&L — the 16:10 flatten means no position spans
  a roll seam — but it does corrupt indicator state, producing spurious crossovers and ATR
  spikes at every roll. Additive adjustment is exactly P&L-neutral here and is pinned by an
  end-to-end test; it does distort logic keyed to absolute price levels.
- **Tier-0 bar fills only.** Market orders fill at the next bar's open and default to zero
  slippage, so a strategy whose edge is thinner than roughly 8–10 ticks per round turn is
  inside the model's error bars. Event windows (08:30 ET releases and the like) are not
  honestly modelled at bar resolution.
- **Untested end to end:** multi-symbol runs, `dll_enabled=True`, and non-quarter-tick
  products (CL, GC).

What is proven is *structural* parity — pyright-strict protocol conformance plus a
`place()` signature-diff test against the live SDK. The behavioural gate (an
intent-sequence test against a recording live broker) is **not built yet**.

## Development

From a checkout (the sibling `topstep-sdk` repo is expected at `../topstep-sdk`; set
`UV_NO_SOURCES=1` to resolve it from PyPI instead):

```bash
uv sync --extra dev
uv run pytest && uv run ruff check . && uv run ruff format --check . && uv run pyright
uv run python scripts/gen_api_surface.py --check   # AGENTS.md §3 must not be stale
uv run python examples/run_combine.py              # end-to-end combine verdict
uv run python examples/run_montecarlo.py           # outcome distribution + autopsy
uv run python examples/session_scoped.py           # session-scoped indicators + trade gate
```

Those checks are the gate: CI runs all of them on Python 3.12/3.13/3.14, then installs the
built wheel into a clean venv and smoke-tests it. **`ruff format --check` is part of it** —
`ruff check` passing is not sufficient, and the two fail independently.

## Documentation

There is a full documentation site — a browsable version of everything below, plus a
quickstart, a page on reading the report, and an API reference generated from the live
docstrings:

**<https://tarricsookdeo.github.io/topstep-backtest/>**

It is rebuilt from `main` on every push. The site is public; the **source repository is
not**, so there are no "Edit this page" links and there is no public issue tracker. To read
it offline, or to preview a change before pushing it:

```bash
uv run --extra docs mkdocs serve      # http://127.0.0.1:8000
```

Everything the site renders also ships as plain markdown **inside the sdist** — `pip
download --no-binary :all: topstep-backtest` and unpack it, or read the copies in your
environment. There is no public issue tracker.

- **`docs/TUTORIAL_EMA_CROSSOVER.md` — start here**: one strategy end to end, raw candles
  to Combine verdict.
- `website/workflow.md` — **the workflow**: the stages in the order the questions become
  answerable — data, mechanics, real attempts, the distribution with its confidence, the
  search guards, the price — and the gate each must pass before the next number means
  anything.
- `website/results.md` — **reading the report**: what every figure means, what basis it is
  on, which ones mislead alone, and what this framework deliberately does *not* report.
- `AGENTS.md` — dense reference for *using* the framework: strategy dialect, module map, the
  invariants you must not break, and **§6, the end-to-end workflow** (run recorded → check
  the wiring → read the run minding each metric's basis → real windows + Monte-Carlo with
  its CI → act on the autopsy → deflate the search → price the attempt).
- `docs/INDICATORS.md` — the TA-Lib indicator surface, wrapper by wrapper.
- `docs/topstep-rules.md` — the rulebook being enforced, with sources, confidence levels,
  and a verify-before-trusting checklist.
- `docs/DESIGN.md` — architecture contract, for *modifying* the framework.
- `docs/ROADMAP.md` — what is built, partial, and not started. Next: **live adapter +
  calibration** → per-regime (non-calendar) breakdowns → L1/L2/MBO fill tiers;
  funded-account (XFA) modeling is deliberately parked.
- `examples/` — runnable: `run_real_data.py` (your CSV/Parquet → verdict), `run_combine.py`
  (synthetic end to end), `run_tearsheet.py` (the same run as one HTML file),
  `run_replay.py` (a recorded run with the bar-by-bar scrubber), `run_montecarlo.py`
  (outcome distribution + autopsy), `run_windows.py` (a long tape replayed as consecutive
  independent Combine attempts), `run_spaced.py` (a chosen number of attempts, start days
  spread evenly, overlap allowed), `ema_cross.py`, `sma_cross.py`, `talib_macd.py`,
  `hand_wired.py` (what the facade assembles).

## Stack

Python 3.12+ · `topstep-sdk` · `msgspec` · `TA-Lib` · `uv` · `ruff` · `pyright` (strict) ·
`pytest` + `hypothesis`. Canonical timezone **ET** (`America/New_York`); the internal hot
path is int-ns UTC; all money is exact `Decimal` on the tick grid — indicator values cross
from float64 into `Decimal` and are deliberately *not* tick-snapped, because an indicator
level is not a tradeable price.

## License

MIT — see LICENSE.

> **Unofficial.** Not affiliated with, endorsed by, or sponsored by Topstep, LLC or
> ProjectX Trading, LLC. "Topstep" is a trademark of its respective owner and is used here
> only to identify the evaluation program this tool models. Rule numbers researched July
> 2026 — re-verify against Topstep's help center before trusting a pass verdict (see the
> checklist in `docs/topstep-rules.md` §9).
>
> **Not financial advice.** This software simulates a trading evaluation and can be wrong.
> You are solely responsible for any capital you risk.
