topstep-backtest 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. topstep_backtest-0.1.0/.gitignore +43 -0
  2. topstep_backtest-0.1.0/AGENTS.md +216 -0
  3. topstep_backtest-0.1.0/CHANGELOG.md +49 -0
  4. topstep_backtest-0.1.0/LICENSE +21 -0
  5. topstep_backtest-0.1.0/PKG-INFO +250 -0
  6. topstep_backtest-0.1.0/README.md +215 -0
  7. topstep_backtest-0.1.0/docs/DESIGN.md +806 -0
  8. topstep_backtest-0.1.0/docs/GUIDE.md +631 -0
  9. topstep_backtest-0.1.0/docs/ROADMAP.md +43 -0
  10. topstep_backtest-0.1.0/docs/STRATEGY_API.md +615 -0
  11. topstep_backtest-0.1.0/docs/TUTORIAL_EMA_CROSSOVER.md +1096 -0
  12. topstep_backtest-0.1.0/docs/topstep-rules.md +230 -0
  13. topstep_backtest-0.1.0/examples/ema_cross.py +118 -0
  14. topstep_backtest-0.1.0/examples/hand_wired.py +104 -0
  15. topstep_backtest-0.1.0/examples/run_combine.py +46 -0
  16. topstep_backtest-0.1.0/examples/run_real_data.py +226 -0
  17. topstep_backtest-0.1.0/examples/sma_cross.py +121 -0
  18. topstep_backtest-0.1.0/examples/talib_macd.py +165 -0
  19. topstep_backtest-0.1.0/pyproject.toml +112 -0
  20. topstep_backtest-0.1.0/src/topstep_backtest/__init__.py +43 -0
  21. topstep_backtest-0.1.0/src/topstep_backtest/clock/__init__.py +1 -0
  22. topstep_backtest-0.1.0/src/topstep_backtest/clock/live_clock.py +82 -0
  23. topstep_backtest-0.1.0/src/topstep_backtest/clock/test_clock.py +133 -0
  24. topstep_backtest-0.1.0/src/topstep_backtest/core/__init__.py +1 -0
  25. topstep_backtest-0.1.0/src/topstep_backtest/core/ids.py +23 -0
  26. topstep_backtest-0.1.0/src/topstep_backtest/core/instruments.py +167 -0
  27. topstep_backtest-0.1.0/src/topstep_backtest/core/money.py +160 -0
  28. topstep_backtest-0.1.0/src/topstep_backtest/core/time.py +125 -0
  29. topstep_backtest-0.1.0/src/topstep_backtest/data/__init__.py +1 -0
  30. topstep_backtest-0.1.0/src/topstep_backtest/data/clean.py +86 -0
  31. topstep_backtest-0.1.0/src/topstep_backtest/data/feed.py +56 -0
  32. topstep_backtest-0.1.0/src/topstep_backtest/data/synthetic.py +137 -0
  33. topstep_backtest-0.1.0/src/topstep_backtest/data/validator.py +215 -0
  34. topstep_backtest-0.1.0/src/topstep_backtest/data/wrangler.py +306 -0
  35. topstep_backtest-0.1.0/src/topstep_backtest/engine/__init__.py +1 -0
  36. topstep_backtest-0.1.0/src/topstep_backtest/engine/backtest.py +209 -0
  37. topstep_backtest-0.1.0/src/topstep_backtest/execution/__init__.py +1 -0
  38. topstep_backtest-0.1.0/src/topstep_backtest/execution/rejections.py +53 -0
  39. topstep_backtest-0.1.0/src/topstep_backtest/execution/sim_broker.py +1436 -0
  40. topstep_backtest-0.1.0/src/topstep_backtest/fills/__init__.py +1 -0
  41. topstep_backtest-0.1.0/src/topstep_backtest/fills/bar_fill.py +268 -0
  42. topstep_backtest-0.1.0/src/topstep_backtest/fills/fees.py +120 -0
  43. topstep_backtest-0.1.0/src/topstep_backtest/fills/path.py +59 -0
  44. topstep_backtest-0.1.0/src/topstep_backtest/harness.py +446 -0
  45. topstep_backtest-0.1.0/src/topstep_backtest/indicators/__init__.py +46 -0
  46. topstep_backtest-0.1.0/src/topstep_backtest/indicators/base.py +57 -0
  47. topstep_backtest-0.1.0/src/topstep_backtest/indicators/library.py +303 -0
  48. topstep_backtest-0.1.0/src/topstep_backtest/indicators/talib_adapter.py +657 -0
  49. topstep_backtest-0.1.0/src/topstep_backtest/metrics/__init__.py +5 -0
  50. topstep_backtest-0.1.0/src/topstep_backtest/metrics/stats.py +153 -0
  51. topstep_backtest-0.1.0/src/topstep_backtest/protocols.py +473 -0
  52. topstep_backtest-0.1.0/src/topstep_backtest/py.typed +0 -0
  53. topstep_backtest-0.1.0/src/topstep_backtest/rules/__init__.py +1 -0
  54. topstep_backtest-0.1.0/src/topstep_backtest/rules/kernel.py +281 -0
  55. topstep_backtest-0.1.0/src/topstep_backtest/rules/params.py +74 -0
  56. topstep_backtest-0.1.0/src/topstep_backtest/strategy/__init__.py +20 -0
  57. topstep_backtest-0.1.0/src/topstep_backtest/strategy/base.py +118 -0
  58. topstep_backtest-0.1.0/src/topstep_backtest/strategy/symbol.py +344 -0
  59. topstep_backtest-0.1.0/src/topstep_backtest/strategy/tracker.py +151 -0
  60. topstep_backtest-0.1.0/tests/__init__.py +0 -0
  61. topstep_backtest-0.1.0/tests/conftest.py +59 -0
  62. topstep_backtest-0.1.0/tests/golden/__init__.py +0 -0
  63. topstep_backtest-0.1.0/tests/golden/artifacts/verdict_failed_mll_s50k.json +1 -0
  64. topstep_backtest-0.1.0/tests/golden/artifacts/verdict_passed_s50k.json +1 -0
  65. topstep_backtest-0.1.0/tests/golden/test_combine_kernel.py +346 -0
  66. topstep_backtest-0.1.0/tests/golden/test_facade_equivalence.py +167 -0
  67. topstep_backtest-0.1.0/tests/golden/test_sugar_equivalence.py +266 -0
  68. topstep_backtest-0.1.0/tests/golden/test_verdict_goldens.py +303 -0
  69. topstep_backtest-0.1.0/tests/parity/__init__.py +0 -0
  70. topstep_backtest-0.1.0/tests/parity/test_broker_conformance.py +74 -0
  71. topstep_backtest-0.1.0/tests/property/__init__.py +0 -0
  72. topstep_backtest-0.1.0/tests/property/test_indicator_props.py +842 -0
  73. topstep_backtest-0.1.0/tests/property/test_kernel_props.py +174 -0
  74. topstep_backtest-0.1.0/tests/property/test_money_props.py +246 -0
  75. topstep_backtest-0.1.0/tests/unit/__init__.py +0 -0
  76. topstep_backtest-0.1.0/tests/unit/test_bar_fill.py +638 -0
  77. topstep_backtest-0.1.0/tests/unit/test_clean.py +68 -0
  78. topstep_backtest-0.1.0/tests/unit/test_clock.py +351 -0
  79. topstep_backtest-0.1.0/tests/unit/test_data_feed.py +85 -0
  80. topstep_backtest-0.1.0/tests/unit/test_engine.py +211 -0
  81. topstep_backtest-0.1.0/tests/unit/test_fees.py +162 -0
  82. topstep_backtest-0.1.0/tests/unit/test_harness.py +360 -0
  83. topstep_backtest-0.1.0/tests/unit/test_indicators.py +1075 -0
  84. topstep_backtest-0.1.0/tests/unit/test_instruments.py +195 -0
  85. topstep_backtest-0.1.0/tests/unit/test_path.py +135 -0
  86. topstep_backtest-0.1.0/tests/unit/test_sim_broker.py +258 -0
  87. topstep_backtest-0.1.0/tests/unit/test_stats.py +212 -0
  88. topstep_backtest-0.1.0/tests/unit/test_symbol_strategy.py +750 -0
  89. topstep_backtest-0.1.0/tests/unit/test_synthetic.py +151 -0
  90. topstep_backtest-0.1.0/tests/unit/test_talib_adapter_hardening.py +353 -0
  91. topstep_backtest-0.1.0/tests/unit/test_time.py +264 -0
  92. topstep_backtest-0.1.0/tests/unit/test_tracker.py +310 -0
  93. topstep_backtest-0.1.0/tests/unit/test_validator.py +196 -0
  94. topstep_backtest-0.1.0/tests/unit/test_wrangler.py +376 -0
@@ -0,0 +1,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ *.so
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # uv
16
+ .uv/
17
+ uv.lock
18
+
19
+ # Testing / coverage
20
+ .pytest_cache/
21
+ .coverage
22
+ coverage.xml
23
+ htmlcov/
24
+ .ruff_cache/
25
+ .mypy_cache/
26
+ .pyright/
27
+
28
+ # Docs
29
+ site/
30
+
31
+ # IDE
32
+ .idea/
33
+ .vscode/
34
+ *.swp
35
+ .DS_Store
36
+
37
+ # Secrets / local config
38
+ .env
39
+ .env.*
40
+ !.env.example
41
+
42
+ # Hypothesis
43
+ .hypothesis/
@@ -0,0 +1,216 @@
1
+ # topstep-backtest — agent guide
2
+
3
+ Dense reference for working on this codebase. Read this before touching code.
4
+ Companion docs: `docs/DESIGN.md` (architecture contract), `docs/topstep-rules.md`
5
+ (rulebook with sources + verify-checklist), `docs/ROADMAP.md` (phase gates).
6
+
7
+ ## What this is
8
+
9
+ An event-driven backtester answering ONE question: *can a strategy profitably pass
10
+ the Topstep Trading Combine?* Two load-bearing properties:
11
+
12
+ 1. **Backtest/live parity** with the sibling `topstep-sdk` (`../topstep-sdk`, an
13
+ editable uv path dep): strategies are written once against structural protocols
14
+ that BOTH `SimBroker` and `AsyncTopstepClient` satisfy. The SDK's msgspec models
15
+ (`OrderModel`, `HalfTradeModel`, `PositionModel`, enums, `APIError`) are imported
16
+ verbatim — never redefined.
17
+ 2. **The prop-firm rule engine is real-time**, not a post-hoc scorecard: the
18
+ two-state trailing MLL, optional DLL, consistency target, position caps, and the
19
+ 16:10 ET flatten all mutate the trade sequence (forced liquidation with adverse
20
+ slippage + Topstep's $10/contract auto-liquidation fee).
21
+
22
+ **Scope:** Combine only. Funded/XFA is parked (docs/topstep-rules.md §6). Tier-0
23
+ (OHLCV bars) fills only, so far.
24
+
25
+ ## Binding conventions (violations are bugs)
26
+
27
+ - **Money:** `Decimal` on the tick grid, always. All grid math via `core/money.py`.
28
+ Position accounting is FIFO lots with an exact cost basis — unrealized P&L goes
29
+ through `position_unrealized` (division-free); never average-then-divide.
30
+ *Indicator values are the deliberate exception:* TA-Lib computes in float64 and the
31
+ `Decimal` handed back at the boundary is **not** tick-snapped — an indicator level is
32
+ not a tradeable price, and quantizing it to the grid would be a lie. Never route an
33
+ indicator value into grid math without an explicit `round_to_tick`.
34
+ - **Time:** int-ns UTC in the hot path; canonical timezone **ET** at boundaries
35
+ (flatten 16:10, session close 17:00, day reset 18:00 ET). All time flows through
36
+ the `Clock` protocol — never `datetime.now()`. `dt_to_ns`/`ns_to_dt` are exact
37
+ integer math (no float round-trips).
38
+ - **No look-ahead, structurally:** bars stamp `ts_init` at CLOSE; an order
39
+ participates in a bar only if `accepted_ts <= bar.ts_event` (its OPEN); trailing
40
+ stops ratchet only from bars the order lived through; `SimHistoryApi` serves only
41
+ already-seen bars; the engine asserts feed time-order.
42
+ - **Intrabar determinism:** fills and rule breaches are ordered along ONE
43
+ pessimistic price path (`fills/path.py`; adverse extreme first) by TRIGGER level
44
+ (never slippage-adjusted prices); breach ties beat fills; equity is re-checked AT
45
+ each fill after it applies.
46
+ - **Rejections:** always the SDK's `APIError` with the gateway's numeric
47
+ `error_code` (see `execution/rejections.py` + SDK `PLACE_ORDER_ERRORS` et al.).
48
+ - `protocols.py` is THE canonical interface module. Change signatures there first
49
+ or not at all; `tests/parity/` has a pyright-strict conformance test plus a
50
+ signature-diff of `SimOrderApi.place` against the SDK's `OrderResource.place`
51
+ that catches drift. **There is no CI** — drift surfaces only when someone runs
52
+ `uv run pytest`. Both tests prove STRUCTURAL conformance; the BEHAVIOURAL gate
53
+ (an intent-sequence test asserting an identical Submit/Modify/Cancel sequence
54
+ sim vs live, docs/ROADMAP.md Phase 4) is not built, so behavioural parity is
55
+ argued, not proven.
56
+ - Toolchain: `uv`, ruff (line 100, E/F/I/UP/B/SIM/ASYNC/RUF), pyright **strict**,
57
+ pytest (+ hypothesis, asyncio_mode=auto). Everything must stay green:
58
+ `uv run pytest && uv run ruff check . && uv run pyright`. Runtime deps are
59
+ `topstep-sdk`, `msgspec`, and `ta-lib` — the last is core, not an extra.
60
+
61
+ ## Map
62
+
63
+ | Module | Owns |
64
+ |---|---|
65
+ | `protocols.py` | Clock/Bar/DataFeed/Broker/OrderApi/PositionApi/HistoryApi/FillModel/Fill/WorkingOrder |
66
+ | `core/` | money (grid/P&L), instruments (15-product spec table), time (ET sessions), ids |
67
+ | `rules/` | `CombineKernel` — THE canonical rulebook state machine (golden-fixtured); params per size |
68
+ | `clock/` | deterministic `TestClock` (`__test__=False`!), wall-clock `LiveClock` |
69
+ | `fills/` | pessimistic Tier-0 `BarFillModel`, shared `build_path`, `TopstepFees` (per-side stack) |
70
+ | `data/` | wrangler (explicit `stamp="open"|"close"` — never guess), validator, synthetic generator, `ListBarFeed` |
71
+ | `execution/` | `SimBroker` (lifecycle, OCO brackets, netting, breach walk, forced liq), rejections |
72
+ | `engine/` | `BacktestEngine` loop + `BacktestResult` (msgspec-serializable; carries `rejections`) |
73
+ | `strategy/` | `Strategy` base + `StrategyContext` (the write-once seam); `SymbolStrategy` (`use()` registration, ready-gate); `tracker.py` — `NetPosition`/`PositionTracker`/`OrderTracker`, pure event folds over SDK models ONLY (that is the parity argument) |
74
+ | `metrics/` | `SummaryStats` (frozen msgspec) + `compute_summary` — drawdown, win rate, expectancy, profit factor, distance-to-floor, consistency headroom |
75
+ | `harness.py` | `Backtest` facade (`run()`/`arun()`/`from_dataframe`, ONE shared clock, instruments derived from the feed, strict `validate_bars`, `account=`, `fee_model=`) + `Report` (verdict/day-trail/summary rendering, provenance line); refuses backtesting.py's economics knobs by name |
76
+ | `indicators/` | TA-Lib adapter — `TalibIndicator` drives 152 of TA-Lib's 161 functions bar-by-bar (nine refused, see below); typed named wrappers (`Sma`/`Ema`/`Rsi`/`Atr`/`Macd`/`BBands`/…); pure-Python `Cross`; `Indicator`/`ValueSource` protocols in `base.py` |
77
+
78
+ ## Semantics that trip people up
79
+
80
+ - **MLL is two-state:** floor ratchets ONLY on end-of-day closed balance (locks
81
+ permanently at the starting balance once EOD ≥ start + buffer); breach checks run
82
+ every tick on realized+unrealized. Intraday-trailing = Apex, not Topstep.
83
+ - A session close at/below the floor is itself a breach (flatten fees can do it).
84
+ - DLL (optional, off by default) is NOT a fail: flatten + day-lock, cleared at
85
+ 18:00 ET; no re-emission while locked; does not preclude passing.
86
+ - Consistency: `best_day <= 0.5 * total_profit` — the formula is canonical; the
87
+ dollar table is canonical (no cross-size ratio holds).
88
+ - **There is NO exchange holiday calendar, by decision.** `core/calendar.py` was
89
+ deleted: its computed table disagreed with CME on about five dates a year (it
90
+ marked MLK / Presidents' Day / Memorial Day / Juneteenth / Labor Day as full
91
+ closures when CME equity index trades a half session), and the cleaner built on
92
+ it ran by DEFAULT, so it silently deleted tradable sessions. A wrong answer you
93
+ cannot see beats a missing feature, so the feature went. Consequences to hold in
94
+ your head: a holiday bar is indistinguishable from any other weekday bar; the
95
+ validator has no `holiday_bar`/`early_close_bar` code and never had a
96
+ replacement; `SimBroker` rejects only weekends (`error_code` 5, "market closed
97
+ (weekend)"); and `data/synthetic.py` skips weekends ONLY — its `days=` counts
98
+ WEEKDAYS, so a synthetic tape spanning Thanksgiving or Good Friday (those two
99
+ ARE genuine CME full closures) emits a session real data would not contain.
100
+ **Filter exchange holidays upstream, in the data you feed in.**
101
+ - ALL market orders — including `positions.close`/`partial_close` — rest and fill
102
+ at the NEXT bar's open (Tier-0). `wait_for_fill` raises in sim: use
103
+ `on_order`/`on_fill` callbacks (parity-safe in both worlds).
104
+ - Bracket children are created at the entry fill (offsets from the ACTUAL fill
105
+ price), active the next bar; only the TP carries `linked_order_id` (gateway
106
+ shape); OCO pairing lives in the broker's internal map. Reduce-only orders clamp
107
+ to the live position and can never flip exposure.
108
+ - `OrderModel.trail_price` is emitted as the trail DISTANCE as a price offset
109
+ (matching the SDK field docs), not the absolute stop.
110
+ - Limit fills require trade-THROUGH by default (exact touch — including at the
111
+ open — does not fill unless `fill_limit_on_touch=True`).
112
+ - **Refused placements are tallied, not silent.** Every order path funnels through
113
+ one choke point that counts rejections by gateway `error_code`:
114
+ `SimBroker.rejections` (a dict) and `BacktestResult.rejections` (sorted
115
+ `(error_code, count)` pairs), and `Report` prints a REJECTED line when it is
116
+ non-empty. Before this, a strategy whose every order was refused — cap 4,
117
+ outside-hours 5 — rendered as a clean zero-trade report. A zero-trade result
118
+ with a REJECTED line is a wiring bug, not a flat strategy.
119
+ - **TA-Lib is the single source of every indicator value.** Nothing in this repo
120
+ re-implements an indicator formula, so there is no second implementation to drift.
121
+ Values are float64 → `Decimal` at the boundary and NOT tick-snapped (see Money above);
122
+ float arithmetic is deterministic, so reruns stay byte-equal, but values are
123
+ float-precise, not Decimal-exact.
124
+ - **Bounded history is a PARITY decision, not an optimisation.** The buffer keeps the last
125
+ `history_bars` bars, so a value is a pure function of that window. Unbounded history would
126
+ make every value depend on where the series happened to start, and a live session warming
127
+ up from a finite history fetch could never reproduce a backtest that began earlier.
128
+ **Preload `history_bars` bars live** for bit-exact parity.
129
+ - **`ready` is NOT `warm`.** `ready` = a value EXISTS (at `lookback`). `warm` = the buffer is
130
+ FULL (at `history_bars`), i.e. the value no longer depends on where this run started. Bars
131
+ in `[lookback, history_bars)` are cold-start dependent. **Parity begins at `warm`**; the
132
+ `use()` ready-gate is a warmup gate, not a parity gate. `TalibLine` exposes `warm` too.
133
+ Never write "parity holds once ready".
134
+ - **`history_bars` is NOT always `max(512, 64 × lookback)`.** Functions whose memory is a
135
+ RATE get a derived window: SAR/SAREXT from `acceleration` (`SAR()` → 2000,
136
+ `SAR(acceleration=0.001)` → 40000; its lookback is 2 either way), MAMA from `slowlimit`,
137
+ KAMA a flat 9000-bar floor. Read `history_bars` off the instance, never recompute it.
138
+ - **"Windowed == unbounded" is NOT universal.** It holds (and is asserted) for the
139
+ exponentially-decaying family — EMA, Wilder RSI/ATR/ADX, MACD, DEMA/TEMA, T3, MAMA,
140
+ HT_TRENDLINE. It does NOT hold for accumulators (OBV, AD — weight 1.0 forever, so the LEVEL
141
+ is window-relative: use slope/divergence, never the level), running-sum functions (SMA and
142
+ what is built on it — STOCH slowd, CCI, MFI, ACCBANDS, BETA, ADOSC — within an ulp, since
143
+ float rounding depends on where the sum began), or adaptive smoothers (KAMA, HT_DCPERIOD).
144
+ **Backtest/live parity is unaffected either way** — both sides run the same window over the
145
+ same bars; only the "also equals an unbounded run" bonus varies.
146
+ - Streaming == batch, **bit for bit**: TA-Lib recomputes from index 0 every call and every
147
+ function is causal, so `FUNC(bars[:t+1])[-1] == FUNC(bars)[t]` exactly. `update(bar)`
148
+ appends to a buffer of already-closed bars and reads the LAST output element — the
149
+ no-look-ahead invariant is structural, not a convention.
150
+ - **Nine of the 161 functions are REFUSED at construction** (152 wrap fine): `EXP`/`COSH`/
151
+ `SINH`/`ACOS`/`ASIN` overflow or leave their domain at futures prices, so they could never
152
+ become ready — `SymbolStrategy` would swallow every bar and the strategy would silently
153
+ never trade (the constructor probes on a PRICE-REALISTIC series to catch this); `MAVP`
154
+ needs a non-bar `periods` input; `MAXINDEX`/`MININDEX`/`MINMAXINDEX` return an OFFSET into
155
+ the array passed in, whose basis silently changes once the bounded window fills.
156
+ - **Lossy parameters are refused.** `Sma(14.7)` (would have become `SMA(14)`) and
157
+ `matype=2.9` (would have become WMA when DEMA was meant) raise `ValueError`; so do bools
158
+ and non-integer `history=`. `Sma(14.0)` — an integral float — is fine.
159
+ - **`_compute` clears its dirty flag only AFTER the TA-Lib call succeeds.** Order matters and
160
+ is not stylistic: clearing it first MEMOISES THE FAILURE — the exception surfaces once, then
161
+ every later read that bar reports a benign "not ready", which `SymbolStrategy._gated()`
162
+ silently swallows, so a broken indicator becomes a strategy that just never trades. Never
163
+ reorder those two lines when refactoring `_compute`. Pinned by
164
+ `tests/unit/test_talib_adapter_hardening.py::test_a_raising_compute_is_not_cached_as_not_ready`.
165
+ - **Thread-safety: build anywhere, drive anywhere; one instance per thread.** `talib`'s
166
+ `Function` keeps its parameters in a `threading.local`, so a Function configured on one
167
+ thread silently reverts to TA-Lib's DEFAULTS on another (an `Ema(50)` quietly computing
168
+ `Ema(30)`). The adapter therefore treats it as STATELESS — parameters re-passed on every
169
+ call, `price=` applied by choosing which bar field fills the input slot instead of mutating
170
+ `input_names`. Keep it that way: never stash config on the Function. A single instance is
171
+ still not safe to drive from two threads at once (per-bar state) — parameter sweeps get one
172
+ instance per worker.
173
+ - **`STDDEV`/`VAR`/`BBANDS` use TA-Lib's naive `E[x²] − E[x]²`**, which cancels at index price
174
+ levels: measured relative error 1.9e-9 @5k, 2.4e-8 @20k, 6.7e-7 @100k. Fine for a threshold
175
+ read; a `Cross` on a Bollinger edge can flip on that noise. A band touch is not exact.
176
+ - **`Rsi` on flat closes reads 0, not 100.** TA-Lib returns 0.0 when all closes are equal
177
+ (all-gains still reads 100, all-losses 0). The old native "zero avg loss reads 100" rule
178
+ is gone; TA-Lib is the authority.
179
+ - **Minimum periods are per-function**, not global: TA-Lib rejects `RSI(1)`, `STDDEV(1)`,
180
+ `MAX(1)`, `MIN(1)`, `ADX(1)` (need ≥2) while `SMA(1)`, `EMA(1)`, `ATR(1)`, `WMA(1)` are
181
+ fine. The adapter PROBES TA-Lib at construction rather than hard-coding a table that
182
+ would drift → `ValueError("TA-Lib rejected …")` at `Rsi(1)`, not 500 bars into a run.
183
+ `period < 1` still raises `ValueError("period must be >= 1, got N")`. Lookbacks are
184
+ TA-Lib's: `Sma(3)`/`Ema(3)`/`StdDev(3)`/`Highest(3)` need 3 bars, `Rsi(3)`/`Atr(3)` need 4.
185
+ - **`TalibLine` is not `use()`-registerable.** A line (`macd.line("macdsignal")`) has no
186
+ `update` on purpose, so it cannot satisfy `Indicator`; `SymbolStrategy.use()` raises
187
+ `ValueError` if handed one — it would never advance and would gate the strategy forever.
188
+ Register the OWNER; `use()` resolves a `Cross` input through `TalibLine.owner`, so
189
+ `use(Cross(macd.line("macd"), macd.line("macdsignal")))` works once `macd` itself is
190
+ `use()`d.
191
+ - **Three more `use()` guards, each pinning a silent failure.** A `Cross` over another
192
+ `Cross` is refused (`ValueError`): it type-checks but has no `.value`, so it used to die
193
+ with `AttributeError` on the first bar both inner inputs were ready, hours in. A
194
+ non-`Indicator` is refused (`TypeError`) at registration, not on the first bar. And a
195
+ `Cross` that was never `use()`-registered now RAISES from `up`/`down` once both inputs are
196
+ ready — it never updates, so it used to read False forever and take zero trades in silence.
197
+
198
+ ## Testing idioms
199
+
200
+ - Golden fixtures for every rulebook worked example (`tests/golden/`); hypothesis
201
+ property suites for money/kernel/fills invariants (`tests/property/`, seeded/
202
+ derandomized); parity conformance in `tests/parity/`; determinism =
203
+ `msgspec.json.encode(result)` equality across reruns.
204
+ - Build test bars with `tests/conftest.py::minute_bar` (ET wall-clock in, exact ns
205
+ out). A bar "at 9:30" OPENS 9:30 and CLOSES 9:31.
206
+
207
+ ## Known deferred items (documented, not bugs)
208
+
209
+ STOP_LIMIT + JOIN_BID/JOIN_ASK rejected at Tier-0; no MessageBus yet; no
210
+ multi-timeframe resampling (history refuses non-native bar specs); DAY-unit
211
+ wrangling rejected (23h Globex day needs a session-aware resampling layer); no
212
+ exchange holiday calendar at all (removed 2026-07-28 — holidays and half sessions
213
+ are the caller's problem, upstream, see above); no behavioural intent-sequence
214
+ parity test (docs/ROADMAP.md Phase 4); fee/rule numbers are cited config pending
215
+ calibration against a real account (docs/topstep-rules.md §9 checklist, correct
216
+ fee rates with `fee_model=`).
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. This project adheres to
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version is
5
+ below 1.0, minor releases may contain breaking changes.
6
+
7
+ ## [0.1.0] — 2026-07-28
8
+
9
+ First public release. Pre-alpha: the engine core is well tested, but the Topstep
10
+ rule and fee constants are **not yet calibrated against a live account**, so a
11
+ `PASSED`/`FAILED` verdict is a diagnostic, not an authoritative answer.
12
+
13
+ ### Added
14
+
15
+ - Event-driven backtest engine with backtest/live parity against
16
+ [`topstep-sdk`](https://pypi.org/project/topstep-sdk/): strategies are written
17
+ once against structural protocols that both `SimBroker` and `AsyncTopstepClient`
18
+ satisfy. SDK msgspec models (`OrderModel`, `PositionModel`, `HalfTradeModel`,
19
+ enums, `APIError`) are imported verbatim, never redefined.
20
+ - `CombineKernel` — the canonical Topstep Combine rulebook as a pure state
21
+ machine: two-state trailing MLL (floor ratchets only on end-of-day closed
22
+ balance, breach checked every tick on realized + unrealized equity), optional
23
+ daily loss limit, consistency target, position caps, 16:10 ET flatten.
24
+ - `Backtest(bars, strategy).run()` facade plus `Report`, and a
25
+ backtesting.py-flavoured strategy dialect (`SymbolStrategy` with `use()`
26
+ indicator registration and `buy`/`sell`/`close` sugar).
27
+ - Tier-0 fill model over OHLCV bars: market orders rest to the next bar's open,
28
+ limit orders require trade-through, one pessimistic intrabar price path shared
29
+ by fills and rule-breach detection.
30
+ - Indicators via TA-Lib — `TalibIndicator` drives 152 of TA-Lib's 161 functions
31
+ bar-by-bar, with typed wrappers (`Sma`, `Ema`, `Rsi`, `Atr`, `Macd`, `BBands`, …).
32
+ Nothing in this package re-implements an indicator formula.
33
+ - Exact-Decimal money on the instrument tick grid, FIFO lot cost basis, int-ns
34
+ UTC hot path with ET session boundaries, and a 15-product instrument table.
35
+ - Data wrangling from OHLCV records or a pandas DataFrame with an explicit
36
+ `stamp="open"|"close"`, a strict validator, and a seeded synthetic generator.
37
+
38
+ ### Known limitations
39
+
40
+ - **No exchange holiday calendar.** Bars on market holidays and past early-close
41
+ halts are not detected or filtered anywhere. Filter them upstream.
42
+ - **No continuous-contract stitching.** One contract per run, inside a single
43
+ front-month window. A multi-month export spanning a roll is silently merged.
44
+ - **Rule and fee constants are uncalibrated** — see `docs/topstep-rules.md` §9.
45
+ - Multi-symbol runs, `dll_enabled=True`, and non-quarter-tick products (CL, GC)
46
+ are not exercised end to end.
47
+ - Tier-0 bar fills only. No quote, depth or MBO tiers.
48
+
49
+ [0.1.0]: https://pypi.org/project/topstep-backtest/0.1.0/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tarric Sookdeo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,250 @@
1
+ Metadata-Version: 2.4
2
+ Name: topstep-backtest
3
+ Version: 0.1.0
4
+ Summary: Event-driven backtesting framework for Topstep Trading Combine strategies, with backtest/live parity against topstep-sdk.
5
+ Author-email: Tarric Sookdeo <tarricsookdeo@outlook.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: backtesting,combine,futures,prop-firm,topstep,trading
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Financial and Insurance Industry
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Office/Business :: Financial :: Investment
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: msgspec<1.0,>=0.18
21
+ Requires-Dist: ta-lib<0.8,>=0.7.1
22
+ Requires-Dist: topstep-sdk<0.2,>=0.1.2
23
+ Requires-Dist: tzdata>=2024.1; sys_platform == 'win32'
24
+ Provides-Extra: data
25
+ Requires-Dist: pandas>=2.2; extra == 'data'
26
+ Provides-Extra: dev
27
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
28
+ Requires-Dist: pandas>=2.2; extra == 'dev'
29
+ Requires-Dist: pyright>=1.1.380; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # topstep-backtest
37
+
38
+ An event-driven backtesting framework for developing futures strategies that can
39
+ **profitably pass the Topstep Trading Combine**.
40
+
41
+ Its defining property is **backtest/live parity** with the companion
42
+ `topstep-sdk`: a strategy is written **once** against structural protocols that
43
+ both the deterministic `SimBroker` *and* the live `AsyncTopstepClient` satisfy.
44
+ Its core differentiator is a first-class **prop-firm rule engine**: the two-state
45
+ trailing Maximum Loss Limit, optional Daily Loss Limit, consistency target,
46
+ position caps, and session flatten are enforced *in real time* (including
47
+ intrabar forced liquidation with adverse slippage), not scored after the fact.
48
+
49
+ ```python
50
+ class SmaCross(SymbolStrategy):
51
+ def __init__(self, contract_id: str) -> None:
52
+ super().__init__(contract_id)
53
+ self.fast = self.use(Sma(20)) # TA-Lib, driven bar by bar, causal by construction
54
+ self.slow = self.use(Sma(50))
55
+ self.cross = self.use(Cross(self.fast, self.slow))
56
+
57
+ async def on_bar(self, bar: Bar) -> None: # gated until every use()d indicator is ready
58
+ if self.cross.up and self.position.flat:
59
+ await self.buy( # sugar over ctx.orders — the topstep-sdk surface
60
+ 2,
61
+ stop_loss_ticks=40,
62
+ take_profit_ticks=80, # signed-tick OCO bracket
63
+ )
64
+ ```
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install topstep-backtest
70
+ pip install "topstep-backtest[data]" # adds pandas, for DataFrame input
71
+ ```
72
+
73
+ Requires Python 3.12+. TA-Lib is a core dependency and ships wheels for common
74
+ platforms; on others you will need the TA-Lib C library first.
75
+
76
+ ```python
77
+ from datetime import date
78
+ from decimal import Decimal
79
+
80
+ from topstep_backtest import AccountSize, Backtest, SymbolStrategy
81
+ from topstep_backtest.core.instruments import spec_for_symbol
82
+ from topstep_backtest.data.synthetic import synthetic_bars
83
+ from topstep_backtest.indicators import Cross, Sma
84
+
85
+ MNQ = "CON.F.US.MNQ.U26"
86
+
87
+
88
+ class SmaCross(SymbolStrategy):
89
+ def __init__(self, contract_id: str) -> None:
90
+ super().__init__(contract_id)
91
+ self.fast = self.use(Sma(10))
92
+ self.slow = self.use(Sma(30))
93
+ self.cross = self.use(Cross(self.fast, self.slow))
94
+
95
+ async def on_bar(self, bar) -> None:
96
+ if self.cross.up and self.position.flat:
97
+ await self.buy(1, stop_loss_ticks=40, take_profit_ticks=80)
98
+ elif self.cross.down and self.position.is_long:
99
+ await self.close()
100
+
101
+
102
+ bars = synthetic_bars(
103
+ contract_id=MNQ,
104
+ spec=spec_for_symbol("MNQ"),
105
+ start_day=date(2026, 5, 4),
106
+ days=5,
107
+ seed=7,
108
+ start_price=Decimal("23000.00"),
109
+ bars_per_day=120,
110
+ vol_ticks=12,
111
+ )
112
+ print(Backtest(bars, SmaCross(MNQ), account=AccountSize.S50K).run())
113
+ ```
114
+
115
+ Feeding your own data (e.g. normalized Databento candles):
116
+
117
+ ```python
118
+ from topstep_backtest.data.wrangler import bars_from_dataframe
119
+
120
+ bars = bars_from_dataframe(
121
+ df,
122
+ contract_id="CON.F.US.MNQ.U26",
123
+ spec=spec,
124
+ unit=AggregateBarUnit.MINUTE,
125
+ unit_number=1,
126
+ stamp="open",
127
+ ) # declare what your timestamps mean!
128
+ ```
129
+
130
+ `bars_from_records` is the same loader without the pandas dependency.
131
+
132
+ ## Status and limitations
133
+
134
+ **Pre-alpha (0.1.0).** The engine core is well covered — exact-Decimal money on
135
+ the tick grid, FIFO lot accounting, a structurally enforced no-look-ahead
136
+ firewall, and byte-identical reruns — but read these before trusting a number:
137
+
138
+ - **The rule and fee constants are NOT calibrated against a live account.** They
139
+ are researched, source-cited config (docs/topstep-rules.md §9 has 8 unchecked
140
+ boxes). Treat a `PASSED`/`FAILED` verdict as a diagnostic, not an answer, and
141
+ distrust any result landing within a tick or a fee of a limit.
142
+ - **No exchange holiday calendar ships with this package.** Bars on market
143
+ holidays and past early-close halts are not detected, flagged or filtered
144
+ anywhere — filter them upstream. (A built-in calendar was removed in 0.1.0: it
145
+ disagreed with CME on several dates a year and silently discarded tradable
146
+ sessions, which is worse than not having one.)
147
+ - **One contract per run, inside a single front-month window.** There is no
148
+ continuous-contract stitching, and no roll detector. A multi-month export
149
+ spanning a quarterly roll is silently merged into one price series.
150
+ - **Tier-0 bar fills only.** Market orders fill at the next bar's open and
151
+ default to zero slippage, so a strategy whose edge is thinner than roughly
152
+ 8–10 ticks per round turn is inside the model's error bars. Event windows
153
+ (08:30 ET releases and the like) are not honestly modelled at bar resolution.
154
+ - **Untested end to end:** multi-symbol runs, `dll_enabled=True`, and
155
+ non-quarter-tick products (CL, GC).
156
+
157
+ What is proven is *structural* parity — pyright-strict conformance plus a
158
+ signature-diff test against the live SDK. The behavioural gate (an intent-sequence
159
+ test against a recording live broker) is **not built yet**; see docs/ROADMAP.md.
160
+
161
+ ## What works today
162
+
163
+ - **Rule kernel** (`rules/`) — the canonical Topstep Combine rulebook: EOD-ratcheting
164
+ trailing MLL with permanent lock at the starting balance, real-time breach on
165
+ realized+unrealized equity, optional DLL (flatten-and-lock, not a fail),
166
+ consistency (`best_day ≤ 50% × total`), fixed 5/10/15-mini position caps —
167
+ golden-fixtured against the documented worked examples.
168
+ - **Deterministic engine** (`engine/`, `clock/`) — single time-ordered loop,
169
+ `TestClock`, strict `ts_init` ordering assertions, bit-for-bit reproducible runs.
170
+ - **SimBroker** (`execution/`) — full order lifecycle (market/limit/stop/trailing,
171
+ signed-tick OCO brackets), netting + exact-Decimal P&L, gateway-parity `APIError`
172
+ rejections tallied on the result, intrabar fill-vs-breach resolution along one
173
+ pessimistic price path, forced liquidation whose loss can exceed the floor.
174
+ - **Tier-0 fills** (`fills/`) — pessimistic bar fills: adverse-extreme-first path,
175
+ next-bar-open for close signals, gap-through at the open, ≥1-tick stop slippage,
176
+ trade-through (not touch) limit fills; per-side/per-instrument Topstep fee stack,
177
+ correctable via `Backtest(..., fee_model=TopstepFees(overrides={...}))`.
178
+ - **Data layer** (`data/`) — pandas/records → validated `Bar` streams (explicit
179
+ open/close stamping kills the off-by-one-bar look-ahead), tick-grid validation,
180
+ session/halt/weekend checks, deterministic synthetic generator.
181
+ - **Indicators** (`indicators/`) — every indicator is TA-Lib: `TalibIndicator` drives 152 of
182
+ its 161 functions bar-by-bar (typed `Sma`/`Ema`/`Rsi`/`Atr`/`Macd`/`BBands`/… wrappers,
183
+ plus a pure-Python `Cross`), so no formula is re-implemented here to drift; the nine
184
+ refused are the ones that could only fail silently. Streaming values are bit-identical to a
185
+ batch run, and the bounded history window is a *parity* decision — preload `history_bars`
186
+ bars live and the live number equals the sim number. Parity begins at `warm` (the window is
187
+ full), not at `ready` (a value exists).
188
+ - **Parity seam** (`protocols.py`) — pyright-strict conformance tests prove
189
+ `AsyncTopstepClient` and `SimBroker` satisfy the same `Broker` protocol, plus a
190
+ signature-diff test against the live SDK that fails the suite on drift.
191
+
192
+ ## Development
193
+
194
+ From a checkout (the sibling `topstep-sdk` repo is expected at `../topstep-sdk`;
195
+ set `UV_NO_SOURCES=1` to resolve it from PyPI instead):
196
+
197
+ ```bash
198
+ uv sync --extra dev
199
+ uv run pytest && uv run ruff check . && uv run ruff format --check . && uv run pyright
200
+ cd examples && uv run python run_combine.py # end-to-end combine verdict
201
+ ```
202
+
203
+ ## Documentation
204
+
205
+ The source repository is private, so there is no public issue tracker or docs
206
+ site. Everything below ships **inside the sdist** — `pip download --no-binary
207
+ :all: topstep-backtest` and unpack it, or read the copies in your environment.
208
+
209
+ - **`docs/GUIDE.md` — start here**: the user guide (data in, writing strategies,
210
+ wiring runs, reading results, verification checklist).
211
+ - `docs/TUTORIAL_EMA_CROSSOVER.md` — an in-depth walkthrough building one
212
+ strategy end to end.
213
+ - `docs/STRATEGY_API.md` — the strategy dialect reference, including the
214
+ indicator surface.
215
+ - `docs/topstep-rules.md` — the rulebook being enforced, with sources,
216
+ confidence levels, and a verify-before-trusting checklist.
217
+ - `docs/DESIGN.md` — architecture contract (parity seam, no-look-ahead
218
+ invariants, fill tiers). Its §4 layout is the *target* architecture, not the
219
+ current tree.
220
+ - `AGENTS.md` — dense maintainer/agent reference and the real module map.
221
+ - `examples/` — runnable: `run_real_data.py` (your CSV/Parquet → verdict),
222
+ `run_combine.py` (synthetic end-to-end), `ema_cross.py`, `sma_cross.py`,
223
+ `talib_macd.py`.
224
+
225
+ ## Roadmap
226
+
227
+ Analytics + Monte-Carlo pass-probability → live adapter + calibration → L1/L2/MBO
228
+ fill tiers. Funded-account (XFA) modeling is deliberately parked. See
229
+ `docs/ROADMAP.md` in the sdist.
230
+
231
+ ## Stack
232
+
233
+ Python 3.12+ · `topstep-sdk` · `msgspec` · `TA-Lib` · `uv` · `ruff` · `pyright`
234
+ (strict) · `pytest` + `hypothesis`. Canonical timezone: **ET** (`America/New_York`);
235
+ internal hot path is int-ns UTC; all money is exact `Decimal` on the tick grid —
236
+ indicator values cross from float64 into `Decimal` and are deliberately *not*
237
+ tick-snapped (an indicator level is not a tradeable price).
238
+
239
+ ## License
240
+
241
+ MIT — see LICENSE.
242
+
243
+ > **Unofficial.** Not affiliated with, endorsed by, or sponsored by Topstep, LLC
244
+ > or ProjectX Trading, LLC. "Topstep" is a trademark of its respective owner and
245
+ > is used here only to identify the evaluation program this tool models. Rule
246
+ > numbers researched July 2026 — re-verify against Topstep's help center before
247
+ > trusting a pass verdict (see the checklist in docs/topstep-rules.md §9).
248
+ >
249
+ > **Not financial advice.** This software simulates a trading evaluation and can
250
+ > be wrong. You are solely responsible for any capital you risk.