topstep-backtest 0.2.2__tar.gz → 0.3.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.
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/.gitignore +2 -1
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/AGENTS.md +41 -7
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/CHANGELOG.md +115 -1
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/PKG-INFO +34 -5
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/README.md +31 -4
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/DESIGN.md +43 -1
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/ROADMAP.md +1 -1
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/TUTORIAL_EMA_CROSSOVER.md +23 -0
- topstep_backtest-0.3.0/examples/run_replay.py +101 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/pyproject.toml +12 -1
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/engine/backtest.py +65 -10
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/execution/sim_broker.py +10 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/harness.py +83 -13
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/stats.py +109 -37
- topstep_backtest-0.3.0/src/topstep_backtest/replay.py +1306 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/strategy/base.py +20 -1
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/strategy/symbol.py +116 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/strategy/tracker.py +32 -5
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/tearsheet/__init__.py +539 -62
- topstep_backtest-0.3.0/src/topstep_backtest/tearsheet/_assets/tearsheet.css +494 -0
- topstep_backtest-0.3.0/src/topstep_backtest/tearsheet/_assets/tearsheet.js +1449 -0
- topstep_backtest-0.3.0/tests/golden/test_replay_goldens.py +134 -0
- topstep_backtest-0.3.0/tests/property/test_replay_props.py +124 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_harness.py +81 -0
- topstep_backtest-0.3.0/tests/unit/test_replay.py +377 -0
- topstep_backtest-0.3.0/tests/unit/test_replay_tearsheet.py +160 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_symbol_strategy.py +190 -6
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_tearsheet.py +14 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_tracker.py +71 -3
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_wrangler.py +36 -0
- topstep_backtest-0.2.2/src/topstep_backtest/tearsheet/_assets/tearsheet.css +0 -176
- topstep_backtest-0.2.2/src/topstep_backtest/tearsheet/_assets/tearsheet.js +0 -468
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/LICENSE +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/data/sample_mnq_1m.csv +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/INDICATORS.md +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/topstep-rules.md +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/ema_cross.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/hand_wired.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_combine.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_montecarlo.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_real_data.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_tearsheet.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_windows.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/sma_cross.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/talib_macd.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/_render.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/clock/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/clock/live_clock.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/clock/test_clock.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/ids.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/instruments.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/money.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/time.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/clean.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/continuous.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/feed.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/synthetic.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/validator.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/wrangler.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/engine/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/execution/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/execution/rejections.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/fills/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/fills/bar_fill.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/fills/fees.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/fills/path.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/indicators/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/indicators/base.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/indicators/library.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/indicators/talib_adapter.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/economics.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/montecarlo.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/overfitting.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/walkforward.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/windows.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/protocols.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/py.typed +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/rules/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/rules/kernel.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/rules/params.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/strategy/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/tearsheet/_assets/lightweight-charts.LICENSE +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/tearsheet/_assets/lightweight-charts.standalone.production.js +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/conftest.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/artifacts/verdict_failed_mll_s50k.json +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/artifacts/verdict_passed_s50k.json +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/test_combine_kernel.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/test_facade_equivalence.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/test_sugar_equivalence.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/test_verdict_goldens.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/parity/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/parity/test_broker_conformance.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/property/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/property/test_indicator_props.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/property/test_kernel_props.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/property/test_money_props.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/__init__.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_bar_fill.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_clean.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_clock.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_continuous.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_data_feed.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_economics.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_engine.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_fees.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_indicators.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_instruments.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_montecarlo.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_overfitting.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_path.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_sim_broker.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_stats.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_synthetic.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_talib_adapter_hardening.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_time.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_validator.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_walkforward.py +0 -0
- {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_windows.py +0 -0
|
@@ -77,10 +77,28 @@ print(report.result.verdict.name) # Verdict is an IntEnum — print .name
|
|
|
77
77
|
|
|
78
78
|
`Backtest` takes an INSTANCE, never the class; `run()` is sync, `arun()` the coroutine. Engine,
|
|
79
79
|
broker, kernel and strategy state are single-use — fresh `Backtest` AND fresh strategy per run.
|
|
80
|
+
|
|
81
|
+
Managing a trade after entry: the bracket is two REAL reduce-only orders (a stop and a
|
|
82
|
+
limit, OCO-paired) created when the entry fills, so `move_stop(ticks=0)` /
|
|
83
|
+
`move_target(price=...)` amend them in flight — `ticks` is signed in the POSITION'S favour
|
|
84
|
+
(`ticks=0` is breakeven either way), measured off `position.avg_price` (the venue's own
|
|
85
|
+
average, `None` when flat), and both return how many orders moved with rejections routed to
|
|
86
|
+
`on_reject`. They act on bracket children only (`parent_order_id` set), so a stop-ENTRY of
|
|
87
|
+
your own is never mistaken for protection. An amended level is live from the NEXT bar.
|
|
88
|
+
|
|
80
89
|
Real data: `Backtest.from_dataframe(df, strategy, *, contract_id, stamp, unit, unit_number)` —
|
|
81
90
|
the spec is derived from `contract_id` — or build bars yourself with `bars_from_dataframe` /
|
|
82
91
|
`bars_from_records`, which do take an explicit `spec`. Read §5.3 on `stamp` first.
|
|
83
92
|
|
|
93
|
+
To watch a run bar by bar, add `record=True`: the report is byte-identical (recording is
|
|
94
|
+
observation only, pinned by a golden), `Report.replay` carries every frame, and
|
|
95
|
+
`report.to_html(path)` grows a second tab holding the replay cockpit — decisions with the
|
|
96
|
+
broker's answers, rejections included, fills, indicator values named by their attributes,
|
|
97
|
+
running stats on the tape and beside it, and the session enforcement between bars. `self.note("why")` inside a hook is the narrative channel:
|
|
98
|
+
the recorder captures WHAT on its own; only the strategy can say WHY. It is a pure sink — a
|
|
99
|
+
no-op unrecorded, and never able to influence the run. `report.replay_json(path)` dumps the
|
|
100
|
+
raw recording.
|
|
101
|
+
|
|
84
102
|
## 3. Public API surface
|
|
85
103
|
|
|
86
104
|
Generated from the live objects — every signature below is real.
|
|
@@ -93,8 +111,8 @@ Generated from the live objects — every signature below is real.
|
|
|
93
111
|
|
|
94
112
|
| Name | Signature | What it does |
|
|
95
113
|
|---|---|---|
|
|
96
|
-
| `Backtest` | `(data: 'Sequence[Bar]', strategy: 'Strategy', account: 'AccountSize' = <AccountSize.S50K: '50K'>, dll_enabled: 'bool' = False, validate: 'bool' = True, account_id: 'int' = 1, fill_config: 'BarFillConfig \| None' = None, broker_config: 'SimBrokerConfig \| None' = None, fee_model: 'TopstepFees \| None' = None, **rejected: 'object')` | The two-line runner: assemble the sim stack correctly and run it once. |
|
|
97
|
-
| `Report` | `(result: 'BacktestResult', stats: 'SummaryStats', trades: 'tuple[HalfTradeModel, ...]', params: 'CombineParams', bars_gated: 'int \| None' = None, bars: 'tuple[Bar, ...]' = (), instruments: 'dict[str, InstrumentSpec] \| None' = None)` | One run's full report: the untouched frozen ``BacktestResult``, derived |
|
|
114
|
+
| `Backtest` | `(data: 'Sequence[Bar]', strategy: 'Strategy', account: 'AccountSize' = <AccountSize.S50K: '50K'>, dll_enabled: 'bool' = False, validate: 'bool' = True, record: 'bool' = False, account_id: 'int' = 1, fill_config: 'BarFillConfig \| None' = None, broker_config: 'SimBrokerConfig \| None' = None, fee_model: 'TopstepFees \| None' = None, **rejected: 'object')` | The two-line runner: assemble the sim stack correctly and run it once. |
|
|
115
|
+
| `Report` | `(result: 'BacktestResult', stats: 'SummaryStats', trades: 'tuple[HalfTradeModel, ...]', params: 'CombineParams', bars_gated: 'int \| None' = None, bars: 'tuple[Bar, ...]' = (), instruments: 'dict[str, InstrumentSpec] \| None' = None, replay: 'Replay \| None' = None)` | One run's full report: the untouched frozen ``BacktestResult``, derived |
|
|
98
116
|
| `AccountSize` | `AccountSize.S50K \| AccountSize.S100K \| AccountSize.S150K` | The three Trading Combine account sizes Topstep offers. |
|
|
99
117
|
| `DataValidationError` | `(issues: 'tuple[ValidationIssue, ...]')` | Bar data failed validation; ``issues`` carries every ERROR finding. |
|
|
100
118
|
|
|
@@ -115,7 +133,7 @@ Generated from the live objects — every signature below is real.
|
|
|
115
133
|
|
|
116
134
|
| Name | Signature | What it does |
|
|
117
135
|
|---|---|---|
|
|
118
|
-
| `NetPosition` | `(net: 'int' = 0)` | Signed net contracts for one instrument (positive = long). |
|
|
136
|
+
| `NetPosition` | `(net: 'int' = 0, avg_price: 'Decimal \| None' = None)` | Signed net contracts for one instrument (positive = long), and the average price they were entered at. |
|
|
119
137
|
| `PositionTracker` | `()` | Per-contract ``NetPosition``s, routed by ``contract_id``. |
|
|
120
138
|
| `OrderTracker` | `()` | Latest ``OrderModel`` per order id, folded from ``on_order`` events. |
|
|
121
139
|
|
|
@@ -152,7 +170,16 @@ Generated from the live objects — every signature below is real.
|
|
|
152
170
|
|
|
153
171
|
| Name | Signature | What it does |
|
|
154
172
|
|---|---|---|
|
|
155
|
-
| `render_html` | `(report: 'Report') -> 'str'` | Render ``report`` as one self-contained interactive HTML document. |
|
|
173
|
+
| `render_html` | `(report: 'Report', *, replay: 'ReplaySpec' = 'auto') -> 'str'` | Render ``report`` as one self-contained interactive HTML document. |
|
|
174
|
+
|
|
175
|
+
**Replay recording**
|
|
176
|
+
|
|
177
|
+
| Name | Signature | What it does |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| `Replay` | `(*args, **kwargs)` | One recorded run: everything the tearsheet's replay scrubber shows. |
|
|
180
|
+
| `StatsSnapshot` | `(*args, **kwargs)` | Running statistics as of a frame's settle — a full ``SummaryStats`` over the run's prefix, computed by the sa… |
|
|
181
|
+
| `OrderIntent` | `(*args, **kwargs)` | One strategy decision at the ``ctx`` seam, with its outcome. |
|
|
182
|
+
| `Recorder` | `()` | Engine-side run recorder. Observes; never influences. |
|
|
156
183
|
|
|
157
184
|
**Tuning**
|
|
158
185
|
|
|
@@ -227,6 +254,9 @@ Generated from the live objects — every signature below is real.
|
|
|
227
254
|
| `SymbolStrategy.sell` | `(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'` | Sell this contract (market unless a price kwarg implies otherwise); |
|
|
228
255
|
| `SymbolStrategy.close` | `(self) -> 'None'` | Flatten this contract's position; a rejection goes to ``on_reject``. |
|
|
229
256
|
| `SymbolStrategy.cancel_working` | `(self) -> 'None'` | Cancel every working order on this contract, one cancel per order; |
|
|
257
|
+
| `SymbolStrategy.move_stop` | `(self, *, price: 'Decimal \| None' = None, ticks: 'int \| None' = None) -> 'int'` | Move every bracket stop on this contract; returns how many moved. |
|
|
258
|
+
| `SymbolStrategy.move_target` | `(self, *, price: 'Decimal \| None' = None, ticks: 'int \| None' = None) -> 'int'` | Move every bracket take-profit on this contract; returns how many moved. |
|
|
259
|
+
| `SymbolStrategy.note` | `(self, text: 'str') -> 'None'` | Attach a free-text breadcrumb to the current bar's replay frame. |
|
|
230
260
|
| `SymbolStrategy.on_bar` | `(self, bar: 'Bar') -> 'None'` | — |
|
|
231
261
|
| `SymbolStrategy.on_fill` | `(self, trade: 'HalfTradeModel') -> 'None'` | — |
|
|
232
262
|
| `SymbolStrategy.on_reject` | `(self, error: 'APIError') -> 'None'` | Called with the ``APIError`` when a sugar order call is rejected. |
|
|
@@ -323,8 +353,10 @@ broker, clock or fill model. That is what makes the class run live unchanged.
|
|
|
323
353
|
| `ctx.account_id` | first positional argument to every `orders`/`positions` call |
|
|
324
354
|
| `ctx.instrument(cid)` | `InstrumentSpec` — tick size, tick value, session metadata |
|
|
325
355
|
|
|
326
|
-
`SymbolStrategy` is sugar over exactly that: `buy/sell/close/cancel_working`,
|
|
327
|
-
|
|
356
|
+
`SymbolStrategy` is sugar over exactly that: `buy/sell/close/cancel_working`, plus
|
|
357
|
+
`move_stop/move_target` over the bracket children (`stop_orders`/`target_orders` are the
|
|
358
|
+
filtered views), `self.position` (`flat`/`is_long`/`is_short`/`avg_price`),
|
|
359
|
+
`self.working_orders`, `self.spec`, `self.bars_gated`. **The sugar
|
|
328
360
|
does not raise** — it catches `APIError`, routes it to `on_reject` and returns `None`;
|
|
329
361
|
`self.ctx.orders` is the raising path. Both are counted in the rejection tally (§5.5).
|
|
330
362
|
|
|
@@ -727,9 +759,10 @@ order placement — the SDK is async and the `await`s ARE the live contract. Tra
|
|
|
727
759
|
| `self.data.Close[-1]` | the `bar` argument; `ctx.history.retrieve_bars(…)` for seen history |
|
|
728
760
|
| `crossover(fast, slow)` | `self.use(Cross(fast, slow))` → `.up` / `.down` |
|
|
729
761
|
| `self.buy(size=2, sl=…, tp=…)` | `await self.buy(2, stop_loss_ticks=40, take_profit_ticks=80)` |
|
|
762
|
+
| `trade.sl = price` / `trade.tp = price` | `await self.move_stop(price=…)` / `move_target(price=…)` — or `ticks=` from `position.avg_price`, signed in the position's favour. These amend REAL working orders, so a rejection is real too |
|
|
730
763
|
| `stats = bt.run()` → 30-key Series | `report = bt.run()` → `Report` + `.stats`/`.result`/`.trades` |
|
|
731
764
|
| `optimize()` | `metrics.optimize(...)` — but read §5.9 first; it keeps every trial on purpose |
|
|
732
|
-
| `plot()` | `report.show()` — interactive HTML tearsheet (`report.to_html(path)` to name the file; `bt.run_with_tearsheet(dir)` to get one from every run) |
|
|
765
|
+
| `plot()` | `report.show()` — interactive HTML tearsheet (`report.to_html(path)` to name the file; `bt.run_with_tearsheet(dir)` to get one from every run; `Backtest(..., record=True)` adds the bar-by-bar replay tab) |
|
|
733
766
|
|
|
734
767
|
## 8. Knobs — the calibration seam
|
|
735
768
|
|
|
@@ -803,6 +836,7 @@ moving one toward optimism is a probe to report next to the baseline, not a new
|
|
|
803
836
|
| `metrics/economics.py` | `evaluate_ev` + `EvalEconomics` — EV per attempt from YOUR prices; lead with `breakeven_pass_value` |
|
|
804
837
|
| `data/continuous.py` | `stitch_continuous` — many expiries → one back-adjusted series on the bare ticker (§5.10) |
|
|
805
838
|
| `indicators/` | TA-Lib adapter (152 of 161 functions), typed wrappers, `Cross`, `Indicator`/`ValueSource` |
|
|
839
|
+
| `replay.py` | `Recorder` + `Replay` — bar-by-bar run recording behind `Backtest(record=True)`: decisions at the ctx seam (via recording proxies), events, indicator values, state tracks, sparse `SummaryStats` snapshots built by the SAME `metrics/stats` helpers (terminal snapshot pinned equal to `compute_summary`). Observation only: results are byte-identical either way |
|
|
806
840
|
| `harness.py` | `Backtest` facade (one shared clock, instruments from the feed, strict validation, refuses backtesting.py knobs) and `Report` |
|
|
807
841
|
|
|
808
842
|
## 11. Not built — do not write code against it
|
|
@@ -4,7 +4,120 @@ All notable changes to this project are documented here. This project adheres to
|
|
|
4
4
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version is
|
|
5
5
|
below 1.0, minor releases may contain breaking changes.
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.3.0] — 2026-08-20
|
|
8
|
+
|
|
9
|
+
A minor: new features, no intended breaking changes.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Bar-by-bar replay of a run.** `Backtest(..., record=True)` hooks a recorder into the
|
|
14
|
+
engine and `Report.replay` carries the full recording (`replay.py`): every decision at the
|
|
15
|
+
`ctx` seam with its exact parameters and the broker's answer — rejections keep their
|
|
16
|
+
gateway code and message instead of being flattened to a count — every order event, fill
|
|
17
|
+
(gross P&L + costs), position snapshot, session enforcement (16:10 flatten, session close,
|
|
18
|
+
MLL breach, DLL lockout), per-bar indicator values named by the attribute the strategy
|
|
19
|
+
stores them under (`self.fast = self.use(Sma(20))` records as `fast`; multi-output
|
|
20
|
+
indicators record line by line; `Cross` fires are events), warmup-gated bars as a state
|
|
21
|
+
rather than a hole, and change-only tracks of position / working orders / balance folded
|
|
22
|
+
from the same SDK events the strategy's own views fold. **Recording is observation only**:
|
|
23
|
+
results are byte-identical with it on or off, and `Strategy.note()` — the new narrative
|
|
24
|
+
breadcrumb channel (`self.note("why")` inside any hook, tagged with the hook that said it)
|
|
25
|
+
— is a pure sink that cannot influence a run. Both pinned by goldens
|
|
26
|
+
(`tests/golden/test_replay_goldens.py`).
|
|
27
|
+
- **Running statistics that cannot drift from the report.** The recording carries sparse
|
|
28
|
+
`SummaryStats` snapshots (one per closing trade, day close, and breach — never per bar),
|
|
29
|
+
each computed over the run's *prefix* by the same `metrics.stats` code `compute_summary`
|
|
30
|
+
uses: the shared helpers were extracted for this (`compute_trade_close_stats`,
|
|
31
|
+
`compute_daily_stats`, `compute_round_trip_stats`, `compute_eod_drawdown`,
|
|
32
|
+
`sortino_ratio`, `exposure_fraction` — `compute_summary`'s behavior is unchanged), and the
|
|
33
|
+
only hand-rolled parts are the O(1) equity folds. The terminal snapshot is golden-pinned
|
|
34
|
+
byte-equal to `compute_summary` and property-swept across tapes
|
|
35
|
+
(`tests/property/test_replay_props.py`). One documented exception: `exposure` refreshes at
|
|
36
|
+
day closes only (the single prefix stat with no exact incremental form) and is exact again
|
|
37
|
+
at the terminal snapshot.
|
|
38
|
+
- **The tearsheet grows a replay tab** when the report carries a recording. The page splits
|
|
39
|
+
into *results* (the finished run) and *replay · bar by bar*, a cockpit sized to the
|
|
40
|
+
viewport — charts down one side, the settled state and running stats beside them, the
|
|
41
|
+
event log alongside — so stepping a run never means scrolling between the tape and the
|
|
42
|
+
panel that explains it. Step it bar by bar (buttons, slider, ←/→ with shift for ×10,
|
|
43
|
+
Home/End, space to autoplay at 1, 5, 15 or 40 bars a second, click a chart or a log row
|
|
44
|
+
to jump); everything after the
|
|
45
|
+
cursor is veiled on every one of the replay tab's charts, so it shows only what the
|
|
46
|
+
strategy had seen, while the results tab keeps showing the run that finished. A strip
|
|
47
|
+
across the tape carries the live trade numbers at the cursor — position and average entry,
|
|
48
|
+
open P&L, the day, equity, floor headroom, and the closed-trade record so far (net P&L,
|
|
49
|
+
net win rate, trades) lifted from the running-stats snapshot in force, plus the cursor
|
|
50
|
+
bar's OHLC; the toolbar's `stats` button hides it. Charts follow the cursor in a
|
|
51
|
+
selectable window (whole run, or 60–480 bars — a long recording opens following, since
|
|
52
|
+
fitting 30,000 bars into a pane gives each one half a pixel), and their series' last-value
|
|
53
|
+
badges are off in favour of a price line at the cursor's own close and equity: a badge
|
|
54
|
+
reporting the end of a run the cursor has not reached is future information printed on the
|
|
55
|
+
axis of the one view that promises not to show any. The running stats mark every row the
|
|
56
|
+
newest snapshot moved (hover for the previous value) and say which snapshot of how many is
|
|
57
|
+
in force; the event log filters by kind, with counts, and says how many rows are hidden. Panels show the settled state at the cursor (position, working
|
|
58
|
+
orders — also drawn as price lines on the candlestick pane — balance, equity, floor
|
|
59
|
+
headroom, today's P&L), the indicator lines on their own synced chart with cross fires
|
|
60
|
+
marked on the tape, the running-stats snapshot in force, and a filterable-by-eye event log
|
|
61
|
+
with rejections loud. The open tab rides in the URL fragment (`#replay`), so a reload — or
|
|
62
|
+
a link to an archived file — comes back to the view it was left on. All figures are Python-preformatted by
|
|
63
|
+
the same helpers as the rest of the page; the JS computes nothing beyond differences of
|
|
64
|
+
two displayed numbers. Long tapes embed a **loudly labelled window** past
|
|
65
|
+
`REPLAY_AUTO_FRAME_LIMIT` (20,000) frames — breach-centred when there is a breach, else
|
|
66
|
+
the tail — and `to_html(path, replay=(start, end) | "full" | "off")` overrides it
|
|
67
|
+
(`show()`, `to_timestamped_html()` and `run_with_tearsheet()` forward the same knob).
|
|
68
|
+
Payload schema version bumps to 2.
|
|
69
|
+
- **Amending a live bracket, and the average entry price to measure it from.**
|
|
70
|
+
`SymbolStrategy.move_stop(...)` / `move_target(...)` amend the reduce-only stop and limit
|
|
71
|
+
the venue creates when an entry fills, taking either `price=` (absolute, passed to the
|
|
72
|
+
broker as given) or `ticks=` — an offset from the average entry signed **in the position's
|
|
73
|
+
favour**, so `ticks=0` is breakeven and `ticks=10` is ten ticks of locked profit long or
|
|
74
|
+
short. They act on bracket children only (`parent_order_id` set), so a stop-*entry* of
|
|
75
|
+
your own is never mistaken for protection; they move every child (a scale-in has several)
|
|
76
|
+
and return the count; rejections go to `on_reject` as with every other sugar call. A level
|
|
77
|
+
measured off the average is snapped to the tick grid **against** the position, because the
|
|
78
|
+
venue's average is quantized to tick/100 and a stop rounding toward profit would lock in a
|
|
79
|
+
tick the entry cannot support. `stop_orders` / `target_orders` expose the same filtered
|
|
80
|
+
view. Amended levels are live from the next bar, which the `accepted_ts` firewall
|
|
81
|
+
guarantees and an end-to-end test pins by exit price.
|
|
82
|
+
- **`position.avg_price`** — the venue's display average (`PositionModel.average_price`),
|
|
83
|
+
which the position view previously discarded. Carried from snapshots rather than
|
|
84
|
+
re-derived, so no second averaging convention is invented for a number the gateway
|
|
85
|
+
publishes; fills keep it honest where they can do so exactly (opening from flat, flipping
|
|
86
|
+
through it, flattening), and a scale-in leaves the previous average standing until the
|
|
87
|
+
snapshot corrects it — the next event in the sim, one hub round trip live.
|
|
88
|
+
- `Report.replay_json(path)` dumps the raw recording as JSON — for diffing two runs or
|
|
89
|
+
verifying what was captured without a browser in the way. `SymbolStrategy` gains a
|
|
90
|
+
read-only `registered_indicators`; `SimBroker` gains O(1) `last_bar_equity`.
|
|
91
|
+
`examples/run_replay.py` shows the whole flow, narrated.
|
|
92
|
+
|
|
93
|
+
## [0.2.3] — 2026-08-16
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
|
|
97
|
+
- The `[data]` and `[dev]` extras now install `pyarrow`, so **Parquet input works out of the
|
|
98
|
+
box**. `examples/run_real_data.py` has always branched to `pd.read_parquet` on a `.parquet` /
|
|
99
|
+
`.pq` suffix, but pandas ships no Parquet engine of its own — so `[data]` alone met that
|
|
100
|
+
branch with `ImportError: Unable to find a usable engine`, on the format most vendors
|
|
101
|
+
(Databento included) actually export. Deliberately unbounded, unlike `topstep-sdk` and
|
|
102
|
+
`ta-lib`: a breaking `pyarrow` major fails loudly at read time instead of quietly changing a
|
|
103
|
+
number. Reading the same bars from CSV and from Parquet produces a byte-identical report.
|
|
104
|
+
|
|
105
|
+
Released as 0.2.3, not 0.2.2: the `v0.2.2` tag was cut from a commit three minutes before
|
|
106
|
+
this change merged, so **0.2.2 shipped without pyarrow** and its Parquet path fails exactly
|
|
107
|
+
as 0.2.1's did. A PyPI version can never be re-uploaded, so the fix moved forward a version
|
|
108
|
+
rather than the tag moving backward.
|
|
109
|
+
|
|
110
|
+
## [0.2.2] — 2026-08-16
|
|
111
|
+
|
|
112
|
+
### Fixed
|
|
113
|
+
|
|
114
|
+
- Repository plumbing only; no user-facing change. `ruff format` also formats Python inside
|
|
115
|
+
markdown fences, so the tearsheet examples added to the README and `website/results.md` in
|
|
116
|
+
0.2.1 were unformatted code and CI had been red since — through two merges. And the docs
|
|
117
|
+
workflow's `actions/configure-pages` step called the Pages REST API under a `contents: read`
|
|
118
|
+
token, failing every deploy with "Resource not accessible by integration" while blaming the
|
|
119
|
+
repository's Pages settings; MkDocs takes its base URL from `site_url`, so the step was
|
|
120
|
+
removed rather than the token widened.
|
|
8
121
|
|
|
9
122
|
## [0.2.1] — 2026-08-16
|
|
10
123
|
|
|
@@ -309,4 +422,5 @@ rule and fee constants are **not yet calibrated against a live account**, so a
|
|
|
309
422
|
are not exercised end to end.
|
|
310
423
|
- Tier-0 bar fills only. No quote, depth or MBO tiers.
|
|
311
424
|
|
|
425
|
+
[0.3.0]: https://pypi.org/project/topstep-backtest/0.3.0/
|
|
312
426
|
[0.1.0]: https://pypi.org/project/topstep-backtest/0.1.0/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: topstep-backtest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Event-driven backtesting framework for Topstep Trading Combine strategies, with backtest/live parity against topstep-sdk.
|
|
5
5
|
Author-email: Tarric Sookdeo <tarricsookdeo@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,9 +23,11 @@ Requires-Dist: topstep-sdk<0.2,>=0.1.2
|
|
|
23
23
|
Requires-Dist: tzdata>=2024.1; sys_platform == 'win32'
|
|
24
24
|
Provides-Extra: data
|
|
25
25
|
Requires-Dist: pandas>=2.2; extra == 'data'
|
|
26
|
+
Requires-Dist: pyarrow>=17; extra == 'data'
|
|
26
27
|
Provides-Extra: dev
|
|
27
28
|
Requires-Dist: hypothesis>=6.100; extra == 'dev'
|
|
28
29
|
Requires-Dist: pandas>=2.2; extra == 'dev'
|
|
30
|
+
Requires-Dist: pyarrow>=17; extra == 'dev'
|
|
29
31
|
Requires-Dist: pyright>=1.1.380; extra == 'dev'
|
|
30
32
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
31
33
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
@@ -69,6 +71,12 @@ class SmaCross(SymbolStrategy):
|
|
|
69
71
|
await self.buy(2, stop_loss_ticks=40, take_profit_ticks=80) # signed-tick OCO
|
|
70
72
|
```
|
|
71
73
|
|
|
74
|
+
The bracket becomes two real reduce-only orders when the entry fills, so a running trade can
|
|
75
|
+
be managed rather than only abandoned: `await self.move_stop(ticks=0)` pulls every bracket
|
|
76
|
+
stop to breakeven (`ticks` is signed in the position's favour and measured from
|
|
77
|
+
`self.position.avg_price`, the venue's own average), `move_target` does the same for the
|
|
78
|
+
take-profit, and both return how many orders moved.
|
|
79
|
+
|
|
72
80
|
**Every named indicator is a typed alias for a TA-Lib function**, not a
|
|
73
81
|
reimplementation: `Sma(20)` *is* `TalibIndicator("SMA", timeperiod=20)`, and the generic
|
|
74
82
|
form reaches 152 of TA-Lib's 161 functions directly. Nothing in this repo implements an
|
|
@@ -80,7 +88,7 @@ framework helper that compares two TA-Lib outputs rather than computing anything
|
|
|
80
88
|
|
|
81
89
|
```bash
|
|
82
90
|
pip install topstep-backtest
|
|
83
|
-
pip install "topstep-backtest[data]" # adds pandas
|
|
91
|
+
pip install "topstep-backtest[data]" # adds pandas + pyarrow: DataFrame and Parquet input
|
|
84
92
|
```
|
|
85
93
|
|
|
86
94
|
Requires Python 3.12+. TA-Lib is a core dependency and ships wheels for common platforms;
|
|
@@ -175,6 +183,26 @@ cannot disagree. For a sheet from *every* run without naming a file each time,
|
|
|
175
183
|
`Backtest(...).run_with_tearsheet("runs")` writes `tearsheet-<UTC stamp>.html` and returns
|
|
176
184
|
both the report and the path.
|
|
177
185
|
|
|
186
|
+
**Replay a run bar by bar.** Pass `record=True` and the tearsheet grows a second tab: a
|
|
187
|
+
replay cockpit that fills the window — its own charts on one side, the settled state, the
|
|
188
|
+
running stats and the event log on the other, so nothing has to be scrolled between. Step
|
|
189
|
+
through the run (buttons, slider, arrow keys, autoplay) with everything after the cursor
|
|
190
|
+
veiled, and watch the position, working orders (drawn as price lines), balance, floor
|
|
191
|
+
headroom, indicator values — named by the attributes your strategy stores them under — and
|
|
192
|
+
the running statistics as they accumulated, computed by the same code as the final report so
|
|
193
|
+
they cannot disagree with it. A strip across the tape carries the live trade numbers at the
|
|
194
|
+
cursor: the bar's OHLC, position and average entry, open P&L, the day, floor headroom, and
|
|
195
|
+
the closed-trade record so far. The charts follow the cursor in a window you pick (or fit the
|
|
196
|
+
whole run), the price axis labels the cursor's own close rather than the run's last — nothing
|
|
197
|
+
on a replay chart reports a bar the strategy had not reached — the stats mark what the newest
|
|
198
|
+
snapshot moved, and the event log filters by kind. The event log lists every decision with the exact parameters
|
|
199
|
+
and the broker's answer (rejections stay loud), every fill with its P&L and costs, and the
|
|
200
|
+
session enforcement between bars; `self.note("why")` inside a hook adds your own narrative at
|
|
201
|
+
the decision point. Recording is observation only — the report is byte-identical with it on
|
|
202
|
+
or off, pinned by a golden test — and `report.replay_json(path)` dumps the raw recording.
|
|
203
|
+
Long tapes embed a loudly-labelled window (`replay=(start, end)` chooses it;
|
|
204
|
+
`replay="full"` forces everything).
|
|
205
|
+
|
|
178
206
|
Then stop trusting one sample:
|
|
179
207
|
|
|
180
208
|
```python
|
|
@@ -300,9 +328,10 @@ environment. There is no public issue tracker.
|
|
|
300
328
|
(XFA) modeling is deliberately parked.
|
|
301
329
|
- `examples/` — runnable: `run_real_data.py` (your CSV/Parquet → verdict), `run_combine.py`
|
|
302
330
|
(synthetic end to end), `run_tearsheet.py` (the same run as one HTML file),
|
|
303
|
-
`
|
|
304
|
-
|
|
305
|
-
`
|
|
331
|
+
`run_replay.py` (a recorded run with the bar-by-bar scrubber), `run_montecarlo.py`
|
|
332
|
+
(outcome distribution + autopsy), `run_windows.py` (a long tape replayed as consecutive
|
|
333
|
+
independent Combine attempts), `ema_cross.py`, `sma_cross.py`, `talib_macd.py`,
|
|
334
|
+
`hand_wired.py` (what the facade assembles).
|
|
306
335
|
|
|
307
336
|
## Stack
|
|
308
337
|
|
|
@@ -29,6 +29,12 @@ class SmaCross(SymbolStrategy):
|
|
|
29
29
|
await self.buy(2, stop_loss_ticks=40, take_profit_ticks=80) # signed-tick OCO
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
The bracket becomes two real reduce-only orders when the entry fills, so a running trade can
|
|
33
|
+
be managed rather than only abandoned: `await self.move_stop(ticks=0)` pulls every bracket
|
|
34
|
+
stop to breakeven (`ticks` is signed in the position's favour and measured from
|
|
35
|
+
`self.position.avg_price`, the venue's own average), `move_target` does the same for the
|
|
36
|
+
take-profit, and both return how many orders moved.
|
|
37
|
+
|
|
32
38
|
**Every named indicator is a typed alias for a TA-Lib function**, not a
|
|
33
39
|
reimplementation: `Sma(20)` *is* `TalibIndicator("SMA", timeperiod=20)`, and the generic
|
|
34
40
|
form reaches 152 of TA-Lib's 161 functions directly. Nothing in this repo implements an
|
|
@@ -40,7 +46,7 @@ framework helper that compares two TA-Lib outputs rather than computing anything
|
|
|
40
46
|
|
|
41
47
|
```bash
|
|
42
48
|
pip install topstep-backtest
|
|
43
|
-
pip install "topstep-backtest[data]" # adds pandas
|
|
49
|
+
pip install "topstep-backtest[data]" # adds pandas + pyarrow: DataFrame and Parquet input
|
|
44
50
|
```
|
|
45
51
|
|
|
46
52
|
Requires Python 3.12+. TA-Lib is a core dependency and ships wheels for common platforms;
|
|
@@ -135,6 +141,26 @@ cannot disagree. For a sheet from *every* run without naming a file each time,
|
|
|
135
141
|
`Backtest(...).run_with_tearsheet("runs")` writes `tearsheet-<UTC stamp>.html` and returns
|
|
136
142
|
both the report and the path.
|
|
137
143
|
|
|
144
|
+
**Replay a run bar by bar.** Pass `record=True` and the tearsheet grows a second tab: a
|
|
145
|
+
replay cockpit that fills the window — its own charts on one side, the settled state, the
|
|
146
|
+
running stats and the event log on the other, so nothing has to be scrolled between. Step
|
|
147
|
+
through the run (buttons, slider, arrow keys, autoplay) with everything after the cursor
|
|
148
|
+
veiled, and watch the position, working orders (drawn as price lines), balance, floor
|
|
149
|
+
headroom, indicator values — named by the attributes your strategy stores them under — and
|
|
150
|
+
the running statistics as they accumulated, computed by the same code as the final report so
|
|
151
|
+
they cannot disagree with it. A strip across the tape carries the live trade numbers at the
|
|
152
|
+
cursor: the bar's OHLC, position and average entry, open P&L, the day, floor headroom, and
|
|
153
|
+
the closed-trade record so far. The charts follow the cursor in a window you pick (or fit the
|
|
154
|
+
whole run), the price axis labels the cursor's own close rather than the run's last — nothing
|
|
155
|
+
on a replay chart reports a bar the strategy had not reached — the stats mark what the newest
|
|
156
|
+
snapshot moved, and the event log filters by kind. The event log lists every decision with the exact parameters
|
|
157
|
+
and the broker's answer (rejections stay loud), every fill with its P&L and costs, and the
|
|
158
|
+
session enforcement between bars; `self.note("why")` inside a hook adds your own narrative at
|
|
159
|
+
the decision point. Recording is observation only — the report is byte-identical with it on
|
|
160
|
+
or off, pinned by a golden test — and `report.replay_json(path)` dumps the raw recording.
|
|
161
|
+
Long tapes embed a loudly-labelled window (`replay=(start, end)` chooses it;
|
|
162
|
+
`replay="full"` forces everything).
|
|
163
|
+
|
|
138
164
|
Then stop trusting one sample:
|
|
139
165
|
|
|
140
166
|
```python
|
|
@@ -260,9 +286,10 @@ environment. There is no public issue tracker.
|
|
|
260
286
|
(XFA) modeling is deliberately parked.
|
|
261
287
|
- `examples/` — runnable: `run_real_data.py` (your CSV/Parquet → verdict), `run_combine.py`
|
|
262
288
|
(synthetic end to end), `run_tearsheet.py` (the same run as one HTML file),
|
|
263
|
-
`
|
|
264
|
-
|
|
265
|
-
`
|
|
289
|
+
`run_replay.py` (a recorded run with the bar-by-bar scrubber), `run_montecarlo.py`
|
|
290
|
+
(outcome distribution + autopsy), `run_windows.py` (a long tape replayed as consecutive
|
|
291
|
+
independent Combine attempts), `ema_cross.py`, `sma_cross.py`, `talib_macd.py`,
|
|
292
|
+
`hand_wired.py` (what the facade assembles).
|
|
266
293
|
|
|
267
294
|
## Stack
|
|
268
295
|
|
|
@@ -143,6 +143,47 @@ and a breakeven. `optimize()` was withheld until those guards existed — the or
|
|
|
143
143
|
point, and reversing it (shipping a sweep whose output nothing deflates) would re-open this
|
|
144
144
|
failure mode.
|
|
145
145
|
|
|
146
|
+
### 3.9 The replay recorder observes at the engine's seams — never inside them (2026-08-17)
|
|
147
|
+
|
|
148
|
+
`Backtest(record=True)` answers "what did the strategy see and decide on THIS bar", and the
|
|
149
|
+
design constraint that shaped it is that **the answer must be worthless-proof: recording can
|
|
150
|
+
never change the run.** Three placements follow:
|
|
151
|
+
|
|
152
|
+
- **Capture at existing seams, add none.** The engine already has the phase boundaries
|
|
153
|
+
(`commit_bar` after a bar fully settles, `on_session` around enforcement, `on_user_event`
|
|
154
|
+
in the dispatch loop); decisions are captured by wrapping the strategy's `ctx.orders`/
|
|
155
|
+
`ctx.positions` in structurally-conformant recording proxies, so raw and sugar order paths
|
|
156
|
+
are seen identically and the broker is untouched. State tracks (position, working orders)
|
|
157
|
+
fold the SAME SDK events the strategy's own views fold (`strategy/tracker.py`) — never
|
|
158
|
+
broker internals, so the recording shows what a live session would have shown.
|
|
159
|
+
- **Running statistics may not become a second implementation.** Snapshots call the same
|
|
160
|
+
`metrics/stats.py` helpers `compute_summary` calls (extracted for exactly this), over
|
|
161
|
+
prefix data; the only hand-rolled parts are the O(1) equity folds (peak/max-DD/static/
|
|
162
|
+
intraday trio), and the terminal snapshot is pinned byte-equal to `compute_summary` by
|
|
163
|
+
`tests/golden/test_replay_goldens.py` and swept across tapes by
|
|
164
|
+
`tests/property/test_replay_props.py`. Trade figures inside snapshots re-read
|
|
165
|
+
`broker.trades` (the ledger), not the recorder's event fold — events lag the ledger inside
|
|
166
|
+
a session roll, and a snapshot mixing the two would be internally inconsistent.
|
|
167
|
+
- **The narrative channel is a pure sink.** `Strategy.note()` writes into the recording and
|
|
168
|
+
nowhere else; unrecorded it is a no-op. Byte-identity of results with recording on/off and
|
|
169
|
+
with/without notes is pinned by goldens — if either pin ever breaks, every recording made
|
|
170
|
+
since is suspect, which is why they are goldens and not unit tests.
|
|
171
|
+
|
|
172
|
+
The tearsheet embeds the recording behind the same determinism contract as the rest of the
|
|
173
|
+
render (Python-preformatted strings; JS computes nothing beyond differences of two displayed
|
|
174
|
+
numbers), windowed with a loud label past `REPLAY_AUTO_FRAME_LIMIT` frames because a scrubber
|
|
175
|
+
silently missing five sixths of a run reads as the whole run.
|
|
176
|
+
|
|
177
|
+
The replay lives in its **own tab with its own charts**, not inline under the results sheet.
|
|
178
|
+
Two reasons, and the second is the load-bearing one: a cockpit worth stepping through wants
|
|
179
|
+
the chart, the state and the event log in one viewport rather than stacked down a scrolling
|
|
180
|
+
column; and a veil belongs only on a run mid-flight. Sharing one set of charts meant the
|
|
181
|
+
finished sheet was dimmed by wherever the cursor happened to sit — the results view is the
|
|
182
|
+
run that *finished*, and it should say so. The cost is a second chart set built from the same
|
|
183
|
+
payload (lazily, on first open: a chart created in a `display:none` container measures zero
|
|
184
|
+
and opens on an empty time scale) and the drawing helpers shared, so two views of the same
|
|
185
|
+
bars cannot drift apart.
|
|
186
|
+
|
|
146
187
|
---
|
|
147
188
|
|
|
148
189
|
## 4. Why the strategy dialect is shaped this way (2026-07-22)
|
|
@@ -219,7 +260,8 @@ Violations are bugs, not style. Most are pinned by a named test.
|
|
|
219
260
|
`ruff format --check` is the one people forget — which is also why `ruff` is pinned
|
|
220
261
|
`>=0.16,<0.17`: format output changes between minors, so an unbounded pin would fail CI on
|
|
221
262
|
correctly formatted code. Runtime deps are `topstep-sdk`, `msgspec`, `ta-lib` (the last is
|
|
222
|
-
core, not an extra); exactly
|
|
263
|
+
core, not an extra); exactly three extras exist, `[data]` (pandas + pyarrow, so both
|
|
264
|
+
DataFrame and Parquet input work), `[dev]` and `[docs]`.
|
|
223
265
|
|
|
224
266
|
## 6. Testing idioms
|
|
225
267
|
|
|
@@ -11,7 +11,7 @@ in [`topstep-rules.md`](./topstep-rules.md).
|
|
|
11
11
|
| 3. SimBroker + Tier-0 fills + rules wired | `fills/bar_fill.py::BarFillModel`, `fills/fees.py::TopstepFees`, netting/PnL, intrabar mark path, forced liquidation | **Done** — the mark/breach walk and forced liquidation are inside `SimBroker`, not separate components |
|
|
12
12
|
| 4. Strategy API + parity gate | `Strategy`/`StrategyContext`, `SymbolStrategy`, reference MA-cross strategies | **Partial** — API and `examples/{sma,ema}_cross.py` ship; the intent-sequence gate is **NOT met**. Only *structural* parity is proven (pyright-strict protocol conformance + a `place()` keyword diff, `tests/parity/test_broker_conformance.py`) |
|
|
13
13
|
| 5. Data layer | Parquet/Arrow catalog, validator, warmup, causal continuous-contract stitcher | **Partial** — `data/validator.py`, `SymbolStrategy` warmup and `data/continuous.py::stitch_continuous` ship (additive back-adjustment, volume or explicit rolls, tick-exact offsets, `RollEvent` metadata). No Parquet/Arrow catalog |
|
|
14
|
-
| 6. Analytics | Decimal-path metrics + tearsheet; Monte-Carlo pass-probability; overfitting guards (PBO/DSR/walk-forward) | **Partial** — `metrics/stats.py::compute_summary` covers trade stats (expectancy/payoff/streaks/breakeven cost), flat-to-flat round trips with true R-multiples plus dollar extremes and holding times, drawdown under all three prop conventions plus duration/recovery/mean-episode-depth/min-floor-headroom, the daily-P&L distribution with a dollar standard deviation, exposure, the equity peak, the run window, and Sortino/Calmar. `metrics/montecarlo.py::monte_carlo` block-bootstraps observed days through the real `CombineKernel` for pass probability, a violation autopsy (MLL breach / consistency-blocked / target-not-reached) and days-to-target. `metrics/overfitting.py::deflated_sharpe` + `TrialLedger` deflate a Sharpe by the recorded trial count, and `metrics/economics.py::evaluate_ev` turns a pass probability plus YOUR prices into an EV and a breakeven. `metrics/walkforward.py` adds `optimize` (which keeps every trial, not just the winner), anchored `walk_forward` with efficiency, and `metrics/overfitting.py::probability_of_backtest_overfitting` (CSCV). `optimize()` was deferred for a long time as an overfitting machine; it ships now *because* DSR and PBO exist to catch what it produces. The interactive HTML tearsheet ships: `report.to_html(path)` / `report.show()` render one self-contained file (candlestick tape with fills marked, equity vs the trailing MLL floor, daily P&L, R-multiple distribution, and every text-render stat with its basis label; `tearsheet/`, charting via vendored Lightweight Charts). **One deliverable remains: per-year / per-regime breakdowns. Not blocked — the Phase-5 stitcher they waited on now ships** |
|
|
14
|
+
| 6. Analytics | Decimal-path metrics + tearsheet; Monte-Carlo pass-probability; overfitting guards (PBO/DSR/walk-forward) | **Partial** — `metrics/stats.py::compute_summary` covers trade stats (expectancy/payoff/streaks/breakeven cost), flat-to-flat round trips with true R-multiples plus dollar extremes and holding times, drawdown under all three prop conventions plus duration/recovery/mean-episode-depth/min-floor-headroom, the daily-P&L distribution with a dollar standard deviation, exposure, the equity peak, the run window, and Sortino/Calmar. `metrics/montecarlo.py::monte_carlo` block-bootstraps observed days through the real `CombineKernel` for pass probability, a violation autopsy (MLL breach / consistency-blocked / target-not-reached) and days-to-target. `metrics/overfitting.py::deflated_sharpe` + `TrialLedger` deflate a Sharpe by the recorded trial count, and `metrics/economics.py::evaluate_ev` turns a pass probability plus YOUR prices into an EV and a breakeven. `metrics/walkforward.py` adds `optimize` (which keeps every trial, not just the winner), anchored `walk_forward` with efficiency, and `metrics/overfitting.py::probability_of_backtest_overfitting` (CSCV). `optimize()` was deferred for a long time as an overfitting machine; it ships now *because* DSR and PBO exist to catch what it produces. The interactive HTML tearsheet ships: `report.to_html(path)` / `report.show()` render one self-contained file (candlestick tape with fills marked, equity vs the trailing MLL floor, daily P&L, R-multiple distribution, and every text-render stat with its basis label; `tearsheet/`, charting via vendored Lightweight Charts). Bar-by-bar replay ships: `Backtest(record=True)` records every decision/event/indicator value/running-stats snapshot (`replay.py`, observation-only, golden-pinned byte-identical results) and the tearsheet grows a scrubber over it. **One deliverable remains: per-year / per-regime breakdowns. Not blocked — the Phase-5 stitcher they waited on now ships** |
|
|
15
15
|
| 7. Live adapter + calibration | `LiveBroker` shim over `topstep-sdk`, captured-gateway fixtures, field-level parity diff, calibration vs a real eval account | **Not started** — nothing has run against a real account; fee and rule constants are uncalibrated |
|
|
16
16
|
| 8. Higher fill tiers | `QuoteFillModel` → `DepthFillModel` → `MBOFillModel` (CME FIFO) | **Not started** — Tier-0 only |
|
|
17
17
|
| — Parked: Express Funded (XFA) | Funded phase (scaling, payout paths, post-first-payout MLL→0) as a new `RuleSet` | **Parked** — only after the Combine path is trustworthy |
|
|
@@ -300,6 +300,29 @@ contract. Like `buy`, a rejection routes to `on_reject`; it fills at the **next*
|
|
|
300
300
|
bar's open (§7). `cancel_working()` is the companion that cancels every working
|
|
301
301
|
order on the contract.
|
|
302
302
|
|
|
303
|
+
### Beyond the listing: managing the trade you are already in
|
|
304
|
+
|
|
305
|
+
`EmaCross` enters and then waits — the bracket or a cross-down ends the trade.
|
|
306
|
+
It does not have to be that way. The bracket from step (5) became two real
|
|
307
|
+
reduce-only orders the moment the entry filled, so a strategy can amend it
|
|
308
|
+
without closing anything:
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
entry = self.position.avg_price # the venue's average; None when flat
|
|
312
|
+
if self.position.is_long and entry is not None:
|
|
313
|
+
if bar.close - entry >= 20 * self.spec.tick_size:
|
|
314
|
+
await self.move_stop(ticks=0) # stop to breakeven
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`ticks=` is measured from the average entry and signed **in the position's
|
|
318
|
+
favour**, so `ticks=0` is breakeven and `ticks=10` is ten ticks of locked
|
|
319
|
+
profit whether the trade is long or short; `price=` sets an absolute level
|
|
320
|
+
instead. Both return how many orders moved — every bracket child, since a
|
|
321
|
+
scale-in has more than one — and route rejections to `on_reject`. They touch
|
|
322
|
+
bracket children only, so an entry order of your own resting in the market is
|
|
323
|
+
never mistaken for protection. The new level is live from the **next** bar, for
|
|
324
|
+
the same reason a market order fills at the next bar's open.
|
|
325
|
+
|
|
303
326
|
### Swapping the EMAs for any other TA-Lib indicator
|
|
304
327
|
|
|
305
328
|
Because `Ema` is only a typed spelling of a TA-Lib call, changing the *signal*
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Record a run bar by bar and step through it in the tearsheet's replay.
|
|
2
|
+
|
|
3
|
+
Same assembly as ``examples/run_tearsheet.py`` with ONE knob added:
|
|
4
|
+
``Backtest(..., record=True)``. Recording is observation only — the report is
|
|
5
|
+
byte-identical to an unrecorded run — but ``Report.replay`` then carries every
|
|
6
|
+
frame, and the HTML tearsheet grows a second tab, ``replay · bar by bar``: a
|
|
7
|
+
cockpit sized to the window, charts on one side and panels on the other, so
|
|
8
|
+
nothing has to be scrolled between:
|
|
9
|
+
|
|
10
|
+
* step the run bar by bar (buttons, slider, arrow keys; space to autoplay) —
|
|
11
|
+
everything after the cursor is veiled, so the page shows only what the
|
|
12
|
+
strategy had seen (the results tab keeps showing the finished run);
|
|
13
|
+
* a strip across the tape carries the live trade numbers at the cursor: the
|
|
14
|
+
position and its average entry, open P&L, the day, equity, floor headroom,
|
|
15
|
+
and the closed-trade record so far;
|
|
16
|
+
* the state panel shows the position, working orders (drawn as price lines on
|
|
17
|
+
the candlestick pane), balance, equity, floor headroom and today's P&L as
|
|
18
|
+
of the cursor;
|
|
19
|
+
* the running-stats panel replays ``SummaryStats`` as it accumulated —
|
|
20
|
+
snapshots land on every closing trade and day close, computed by the same
|
|
21
|
+
code that produces the final report, so the numbers cannot disagree;
|
|
22
|
+
* the event log lists every decision the strategy made (with the exact order
|
|
23
|
+
parameters and the broker's answer, rejections included), every fill with
|
|
24
|
+
its P&L and costs, every ``self.note(...)`` breadcrumb, and every session
|
|
25
|
+
enforcement (16:10 flatten, session close, MLL/DLL) — click a row to jump.
|
|
26
|
+
|
|
27
|
+
``self.note(...)`` is the narrative channel: the recorder can capture WHAT
|
|
28
|
+
happened on its own, but only the strategy knows WHY, so say it at the
|
|
29
|
+
decision point and read it back at the same bar in the log.
|
|
30
|
+
|
|
31
|
+
Long runs: ``report.to_html(path)`` embeds every frame up to a documented
|
|
32
|
+
limit and then falls back to a loudly-labelled window (centred on the MLL
|
|
33
|
+
breach when there is one). ``to_html(path, replay=(start, end))`` picks the
|
|
34
|
+
window by hand; ``replay="full"`` forces everything; ``replay="off"`` renders
|
|
35
|
+
the classic sheet. ``report.replay_json(path)`` dumps the raw recording for
|
|
36
|
+
anything a browser page is the wrong tool for.
|
|
37
|
+
|
|
38
|
+
uv run python examples/run_replay.py
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from datetime import date
|
|
44
|
+
from decimal import Decimal
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from topstep_backtest import AccountSize, Backtest, SymbolStrategy
|
|
48
|
+
from topstep_backtest.core.instruments import spec_for_symbol
|
|
49
|
+
from topstep_backtest.data.synthetic import synthetic_bars
|
|
50
|
+
from topstep_backtest.indicators import Cross, Sma
|
|
51
|
+
from topstep_backtest.protocols import Bar
|
|
52
|
+
|
|
53
|
+
CONTRACT = "CON.F.US.MNQ.U26"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NarratedSmaCross(SymbolStrategy):
|
|
57
|
+
"""The quickstart SMA cross, narrating its decisions for the replay."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, contract_id: str) -> None:
|
|
60
|
+
super().__init__(contract_id)
|
|
61
|
+
self.fast = self.use(Sma(10))
|
|
62
|
+
self.slow = self.use(Sma(30))
|
|
63
|
+
self.cross = self.use(Cross(self.fast, self.slow))
|
|
64
|
+
|
|
65
|
+
async def on_bar(self, bar: Bar) -> None:
|
|
66
|
+
if self.cross.up and self.position.flat:
|
|
67
|
+
self.note(f"fast {self.fast.value} crossed above slow {self.slow.value}: long 2")
|
|
68
|
+
await self.buy(2, stop_loss_ticks=40, take_profit_ticks=80)
|
|
69
|
+
elif self.cross.down and self.position.is_long:
|
|
70
|
+
self.note("down-cross with a long on: closing")
|
|
71
|
+
await self.close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> None:
|
|
75
|
+
bars = synthetic_bars(
|
|
76
|
+
contract_id=CONTRACT,
|
|
77
|
+
spec=spec_for_symbol("MNQ"),
|
|
78
|
+
start_day=date(2026, 5, 4),
|
|
79
|
+
days=10,
|
|
80
|
+
seed=7,
|
|
81
|
+
start_price=Decimal("23000.00"),
|
|
82
|
+
bars_per_day=120,
|
|
83
|
+
vol_ticks=12,
|
|
84
|
+
)
|
|
85
|
+
report = Backtest(bars, NarratedSmaCross(CONTRACT), account=AccountSize.S50K, record=True).run()
|
|
86
|
+
print(report)
|
|
87
|
+
|
|
88
|
+
replay = report.replay
|
|
89
|
+
assert replay is not None # record=True was passed
|
|
90
|
+
print(
|
|
91
|
+
f"\nrecorded {replay.frame_count} frames: "
|
|
92
|
+
f"{len(replay.intents)} decisions, {len(replay.fill_events)} fills, "
|
|
93
|
+
f"{len(replay.notes)} notes, {len(replay.snapshots)} stat snapshots"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
written = report.to_html(Path("replay-tearsheet.html"))
|
|
97
|
+
print(f"wrote {written.resolve()} — open it, take the replay tab, press space")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
|
@@ -37,13 +37,24 @@ dependencies = [
|
|
|
37
37
|
]
|
|
38
38
|
|
|
39
39
|
[project.optional-dependencies]
|
|
40
|
-
data = [
|
|
40
|
+
data = [
|
|
41
|
+
"pandas>=2.2",
|
|
42
|
+
# pandas reads Parquet only through a third-party engine and ships none, so
|
|
43
|
+
# `[data]` without this turns every .parquet export — the format Databento
|
|
44
|
+
# and most vendors hand you — into an ImportError at load time. No upper
|
|
45
|
+
# bound, unlike topstep-sdk and ta-lib above: a breaking pyarrow major fails
|
|
46
|
+
# LOUDLY at read time rather than quietly changing a number, so pinning it
|
|
47
|
+
# out would buy nothing and strand the extra on an old wheel.
|
|
48
|
+
"pyarrow>=17",
|
|
49
|
+
]
|
|
41
50
|
dev = [
|
|
42
51
|
"pytest>=8.0",
|
|
43
52
|
"pytest-asyncio>=0.24",
|
|
44
53
|
"pytest-cov>=5.0",
|
|
45
54
|
"hypothesis>=6.100",
|
|
46
55
|
"pandas>=2.2",
|
|
56
|
+
# Mirrors `[data]`: the Parquet path needs an engine to be exercised at all.
|
|
57
|
+
"pyarrow>=17",
|
|
47
58
|
# ruff is PINNED to a minor range on purpose: `ruff format --check` runs in
|
|
48
59
|
# CI, and formatting output changes between ruff minors. Unbounded, CI would
|
|
49
60
|
# spontaneously fail on code that is correctly formatted locally. Bump this
|