topstep-backtest 0.2.3__tar.gz → 0.4.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.3 → topstep_backtest-0.4.0}/.gitignore +4 -1
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/AGENTS.md +162 -32
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/CHANGELOG.md +235 -1
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/PKG-INFO +72 -8
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/README.md +71 -7
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/DESIGN.md +81 -1
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/INDICATORS.md +61 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/ROADMAP.md +6 -4
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/TUTORIAL_EMA_CROSSOVER.md +68 -6
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_montecarlo.py +30 -7
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_real_data.py +81 -4
- topstep_backtest-0.4.0/examples/run_replay.py +101 -0
- topstep_backtest-0.4.0/examples/run_spaced.py +125 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_windows.py +71 -8
- topstep_backtest-0.4.0/examples/session_scoped.py +151 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/__init__.py +6 -0
- topstep_backtest-0.4.0/src/topstep_backtest/core/sessions.py +143 -0
- topstep_backtest-0.4.0/src/topstep_backtest/data/loaders.py +117 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/synthetic.py +57 -21
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/engine/backtest.py +65 -10
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/execution/sim_broker.py +10 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/harness.py +115 -13
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/__init__.py +31 -1
- topstep_backtest-0.4.0/src/topstep_backtest/metrics/confidence.py +536 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/economics.py +6 -3
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/montecarlo.py +65 -34
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/stats.py +109 -37
- topstep_backtest-0.4.0/src/topstep_backtest/metrics/windows.py +629 -0
- topstep_backtest-0.4.0/src/topstep_backtest/replay.py +1306 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/strategy/base.py +20 -1
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/strategy/symbol.py +329 -21
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/strategy/tracker.py +32 -5
- topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/__init__.py +1290 -0
- topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/_assets/sweep.css +154 -0
- topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/_assets/sweep.js +42 -0
- topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/_assets/tearsheet.css +514 -0
- topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/_assets/tearsheet.js +1516 -0
- topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/sweep.py +522 -0
- topstep_backtest-0.4.0/tests/golden/test_replay_goldens.py +134 -0
- topstep_backtest-0.4.0/tests/property/test_replay_props.py +124 -0
- topstep_backtest-0.4.0/tests/unit/test_confidence.py +342 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_harness.py +81 -0
- topstep_backtest-0.4.0/tests/unit/test_loaders.py +176 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_montecarlo.py +17 -5
- topstep_backtest-0.4.0/tests/unit/test_replay.py +377 -0
- topstep_backtest-0.4.0/tests/unit/test_replay_tearsheet.py +160 -0
- topstep_backtest-0.4.0/tests/unit/test_sessions.py +138 -0
- topstep_backtest-0.4.0/tests/unit/test_sweep_tearsheet.py +131 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_symbol_strategy.py +406 -8
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_synthetic.py +75 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_tearsheet.py +91 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_tracker.py +71 -3
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_windows.py +103 -1
- topstep_backtest-0.2.3/src/topstep_backtest/metrics/windows.py +0 -314
- topstep_backtest-0.2.3/src/topstep_backtest/tearsheet/__init__.py +0 -548
- topstep_backtest-0.2.3/src/topstep_backtest/tearsheet/_assets/tearsheet.css +0 -176
- topstep_backtest-0.2.3/src/topstep_backtest/tearsheet/_assets/tearsheet.js +0 -468
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/LICENSE +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/data/sample_mnq_1m.csv +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/topstep-rules.md +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/ema_cross.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/hand_wired.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_combine.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_tearsheet.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/sma_cross.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/talib_macd.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/pyproject.toml +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/_render.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/clock/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/clock/live_clock.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/clock/test_clock.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/ids.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/instruments.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/money.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/time.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/clean.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/continuous.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/feed.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/validator.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/wrangler.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/engine/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/execution/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/execution/rejections.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/fills/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/fills/bar_fill.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/fills/fees.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/fills/path.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/indicators/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/indicators/base.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/indicators/library.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/indicators/talib_adapter.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/overfitting.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/walkforward.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/protocols.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/py.typed +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/rules/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/rules/kernel.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/rules/params.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/strategy/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/tearsheet/_assets/lightweight-charts.LICENSE +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/tearsheet/_assets/lightweight-charts.standalone.production.js +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/conftest.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/artifacts/verdict_failed_mll_s50k.json +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/artifacts/verdict_passed_s50k.json +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/test_combine_kernel.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/test_facade_equivalence.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/test_sugar_equivalence.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/test_verdict_goldens.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/parity/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/parity/test_broker_conformance.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/property/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/property/test_indicator_props.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/property/test_kernel_props.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/property/test_money_props.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/__init__.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_bar_fill.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_clean.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_clock.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_continuous.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_data_feed.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_economics.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_engine.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_fees.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_indicators.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_instruments.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_overfitting.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_path.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_sim_broker.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_stats.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_talib_adapter_hardening.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_time.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_validator.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_walkforward.py +0 -0
- {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_wrangler.py +0 -0
|
@@ -42,5 +42,8 @@ site/
|
|
|
42
42
|
# Hypothesis
|
|
43
43
|
.hypothesis/
|
|
44
44
|
|
|
45
|
-
# Tearsheet output from examples/run_tearsheet.py (written to the CWD)
|
|
45
|
+
# Tearsheet output from examples/run_tearsheet.py / run_replay.py (written to the CWD)
|
|
46
46
|
tearsheet.html
|
|
47
|
+
replay-tearsheet.html
|
|
48
|
+
mc-confidence-tearsheet.html
|
|
49
|
+
spaced-tearsheet.html
|
|
@@ -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
|
|
|
@@ -109,13 +127,13 @@ Generated from the live objects — every signature below is real.
|
|
|
109
127
|
|
|
110
128
|
| Name | Signature | What it does |
|
|
111
129
|
|---|---|---|
|
|
112
|
-
| `SymbolStrategy` | `(contract_id: 'str', require_ready: 'bool' = True, warmup: 'int \| None' = None)` | Base for strategies trading exactly one contract. |
|
|
130
|
+
| `SymbolStrategy` | `(contract_id: 'str', require_ready: 'bool' = True, warmup: 'int \| None' = None, trade_sessions: 'Sequence[Session] \| None' = None)` | Base for strategies trading exactly one contract. |
|
|
113
131
|
|
|
114
132
|
**Position / order views**
|
|
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
|
|
|
@@ -126,11 +144,17 @@ Generated from the live objects — every signature below is real.
|
|
|
126
144
|
| `bars_from_dataframe` | `(df: 'Any', *, contract_id: 'str', spec: 'InstrumentSpec', unit: 'AggregateBarUnit', unit_number: 'int', stamp: "Literal['open', 'close']") -> 'tuple[Bar, ...]'` | Build ``Bar`` objects from a pandas DataFrame of OHLCV candles. |
|
|
127
145
|
| `bars_from_records` | `(rows: 'Iterable[tuple[object, ...]]', *, contract_id: 'str', spec: 'InstrumentSpec', unit: 'AggregateBarUnit', unit_number: 'int', stamp: "Literal['open', 'close']") -> 'tuple[Bar, ...]'` | Build tick-grid-validated ``Bar`` objects from ``(ts, o, h, l, c, v)`` rows. |
|
|
128
146
|
|
|
147
|
+
**Data in (Parquet export)**
|
|
148
|
+
|
|
149
|
+
| Name | Signature | What it does |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `load_bars` | `(path: 'str \| Path') -> 'tuple[tuple[Bar, ...], InstrumentSpec, dict[str, Any]]'` | Read the export -> (bars, spec, metadata), ready for ``Backtest(...)``. |
|
|
152
|
+
|
|
129
153
|
**Data in (synthetic)**
|
|
130
154
|
|
|
131
155
|
| Name | Signature | What it does |
|
|
132
156
|
|---|---|---|
|
|
133
|
-
| `synthetic_bars` | `(*, contract_id: 'str', spec: 'InstrumentSpec', start_day: 'date', days: 'int', seed: 'int', start_price: 'Decimal', bars_per_day: 'int' =
|
|
157
|
+
| `synthetic_bars` | `(*, contract_id: 'str', spec: 'InstrumentSpec', start_day: 'date', days: 'int', seed: 'int', start_price: 'Decimal', bars_per_day: 'int \| None' = None, unit: 'AggregateBarUnit' = <AggregateBarUnit.MINUTE: 2>, unit_number: 'int' = 1, drift_ticks_per_day: 'int' = 0, vol_ticks: 'int' = 8, hours: 'Hours' = 'rth') -> 'tuple[Bar, ...]'` | Generate ``days`` trading sessions of consistent, on-grid OHLCV bars. |
|
|
134
158
|
|
|
135
159
|
**Instruments**
|
|
136
160
|
|
|
@@ -140,6 +164,15 @@ Generated from the live objects — every signature below is real.
|
|
|
140
164
|
| `symbol_of_contract_id` | `(contract_id: 'str') -> 'str'` | Extract the product symbol from a gateway contract id. |
|
|
141
165
|
| `InstrumentSpec` | `(*args, **kwargs)` | Frozen per-product economics and session metadata. |
|
|
142
166
|
|
|
167
|
+
**Sessions**
|
|
168
|
+
|
|
169
|
+
| Name | Signature | What it does |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `Session` | `(*args, **kwargs)` | A named intraday window, defined in its own local timezone. |
|
|
172
|
+
| `ASIA` | `Session(name='ASIA', tz='Asia/Tokyo', start=09:00:00, end=15:00:00)` | A named intraday window, defined in its own local timezone. |
|
|
173
|
+
| `LONDON` | `Session(name='LONDON', tz='Europe/London', start=08:00:00, end=16:30:00)` | A named intraday window, defined in its own local timezone. |
|
|
174
|
+
| `NEW_YORK` | `Session(name='NEW_YORK', tz='America/New_York', start=09:30:00, end=16:00:00)` | A named intraday window, defined in its own local timezone. |
|
|
175
|
+
|
|
143
176
|
**Results**
|
|
144
177
|
|
|
145
178
|
| Name | Signature | What it does |
|
|
@@ -152,7 +185,17 @@ Generated from the live objects — every signature below is real.
|
|
|
152
185
|
|
|
153
186
|
| Name | Signature | What it does |
|
|
154
187
|
|---|---|---|
|
|
155
|
-
| `render_html` | `(report: 'Report') -> 'str'` | Render ``report`` as one self-contained interactive HTML document. |
|
|
188
|
+
| `render_html` | `(report: 'Report', *, replay: 'ReplaySpec' = 'auto', confidence: 'MonteCarloConfidence \| None' = None, crosscheck: 'CrossCheck \| None' = None) -> 'str'` | Render ``report`` as one self-contained interactive HTML document. |
|
|
189
|
+
| `render_sweep_html` | `(sweep: 'WindowSweep \| SpacedSweep') -> 'str'` | Render a sweep as one self-contained HTML document. |
|
|
190
|
+
|
|
191
|
+
**Replay recording**
|
|
192
|
+
|
|
193
|
+
| Name | Signature | What it does |
|
|
194
|
+
|---|---|---|
|
|
195
|
+
| `Replay` | `(*args, **kwargs)` | One recorded run: everything the tearsheet's replay scrubber shows. |
|
|
196
|
+
| `StatsSnapshot` | `(*args, **kwargs)` | Running statistics as of a frame's settle — a full ``SummaryStats`` over the run's prefix, computed by the sa… |
|
|
197
|
+
| `OrderIntent` | `(*args, **kwargs)` | One strategy decision at the ``ctx`` seam, with its outcome. |
|
|
198
|
+
| `Recorder` | `()` | Engine-side run recorder. Observes; never influences. |
|
|
156
199
|
|
|
157
200
|
**Tuning**
|
|
158
201
|
|
|
@@ -172,12 +215,29 @@ Generated from the live objects — every signature below is real.
|
|
|
172
215
|
| `MonteCarloResult` | `(*args, **kwargs)` | Outcome distribution over ``paths`` synthetic Combine attempts. |
|
|
173
216
|
| `FailureMode` | `FailureMode.MLL_BREACH \| FailureMode.CONSISTENCY_BLOCKED \| FailureMode.TARGET_NOT_REACHED` | Why a simulated path did not pass. Ordered by when it is decided. |
|
|
174
217
|
|
|
218
|
+
**Monte-Carlo confidence**
|
|
219
|
+
|
|
220
|
+
| Name | Signature | What it does |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| `mc_confidence` | `(result: 'BacktestResult', *, params: 'CombineParams', paths: 'int' = 2000, horizon_days: 'int \| None' = None, block_length: 'int' = 5, seed: 'int' = 0, outer: 'int' = 200, inner_paths: 'int' = 200, lengths: 'Sequence[int]' = (1, 5, 10, 20)) -> 'MonteCarloConfidence'` | One call: the estimate plus its CI, sensitivity row, and year strata. |
|
|
223
|
+
| `MonteCarloConfidence` | `(*args, **kwargs)` | The point estimate and every qualifier this module can attach to it. |
|
|
224
|
+
| `pass_probability_ci` | `(result: 'BacktestResult', *, params: 'CombineParams', paths: 'int' = 2000, horizon_days: 'int \| None' = None, block_length: 'int' = 5, seed: 'int' = 0, outer: 'int' = 200, inner_paths: 'int' = 200) -> 'PassProbabilityCI'` | Double bootstrap: a confidence band for the pass probability. |
|
|
225
|
+
| `PassProbabilityCI` | `(*args, **kwargs)` | A pass probability with the error bar its sample size actually earns. |
|
|
226
|
+
| `block_length_sensitivity` | `(result: 'BacktestResult', *, params: 'CombineParams', lengths: 'Sequence[int]' = (1, 5, 10, 20), paths: 'int' = 1000, horizon_days: 'int \| None' = None, seed: 'int' = 0) -> 'BlockLengthSensitivity'` | Re-run the Monte Carlo across block lengths and report the swing. |
|
|
227
|
+
| `BlockLengthSensitivity` | `(*args, **kwargs)` | The same estimate at several block lengths, plus how far it moved. |
|
|
228
|
+
| `monte_carlo_by_year` | `(result: 'BacktestResult', *, params: 'CombineParams', paths: 'int' = 1000, horizon_days: 'int \| None' = None, block_length: 'int' = 5, seed: 'int' = 0) -> 'YearStratification'` | One Monte Carlo per calendar year of the source run. |
|
|
229
|
+
| `YearStratification` | `(*args, **kwargs)` | Per-year estimates, ascending by year. |
|
|
230
|
+
| `crosscheck` | `(mc: 'MonteCarloResult', sweep: 'WindowSweep') -> 'CrossCheck'` | Compare a Monte Carlo against a window sweep, outcome by outcome. |
|
|
231
|
+
| `CrossCheck` | `(*args, **kwargs)` | The bootstrap and the window sweep, forced to answer side by side. |
|
|
232
|
+
|
|
175
233
|
**Sequential Combines**
|
|
176
234
|
|
|
177
235
|
| Name | Signature | What it does |
|
|
178
236
|
|---|---|---|
|
|
179
237
|
| `sequential_combines` | `(bars: 'Sequence[Bar]', factory: 'Callable[[], Strategy]', *, window_days: 'int', account: 'AccountSize' = <AccountSize.S50K: '50K'>, dll_enabled: 'bool' = False, warm_start: 'bool' = True, validate: 'bool' = True) -> 'WindowSweep'` | Run one fresh Combine per non-overlapping ``window_days``-day window. |
|
|
238
|
+
| `spaced_combines` | `(bars: 'Sequence[Bar]', factory: 'Callable[[], Strategy]', *, window_days: 'int', periods: 'int', account: 'AccountSize' = <AccountSize.S50K: '50K'>, dll_enabled: 'bool' = False, warm_start: 'bool' = True, validate: 'bool' = True) -> 'SpacedSweep'` | Run ``periods`` fresh Combines with start days spread evenly over the tape. |
|
|
180
239
|
| `WindowSweep` | `(*args, **kwargs)` | Every window's attempt, plus the rates over them. |
|
|
240
|
+
| `SpacedSweep` | `(*args, **kwargs)` | A requested number of periods, start days spread evenly, overlap allowed. |
|
|
181
241
|
| `WindowResult` | `(*args, **kwargs)` | One window's Combine attempt, resolved on its own merits. |
|
|
182
242
|
|
|
183
243
|
**Overfitting guards**
|
|
@@ -222,11 +282,14 @@ Generated from the live objects — every signature below is real.
|
|
|
222
282
|
|
|
223
283
|
| Name | Signature | What it does |
|
|
224
284
|
|---|---|---|
|
|
225
|
-
| `SymbolStrategy.use` | `(self, indicator: 'T') -> 'T'` | Register an indicator: auto-updated on every matching bar and |
|
|
285
|
+
| `SymbolStrategy.use` | `(self, indicator: 'T', *, session: 'Session \| None' = None) -> 'T'` | Register an indicator: auto-updated on every matching bar and |
|
|
226
286
|
| `SymbolStrategy.buy` | `(self, size: 'int', *, stop_loss_ticks: 'int \| None' = None, take_profit_ticks: 'int \| None' = None, limit_price: 'Decimal \| None' = None, stop_price: 'Decimal \| None' = None, custom_tag: 'str \| None' = None) -> 'int \| None'` | Buy this contract (market unless a price kwarg implies otherwise); |
|
|
227
287
|
| `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
288
|
| `SymbolStrategy.close` | `(self) -> 'None'` | Flatten this contract's position; a rejection goes to ``on_reject``. |
|
|
229
289
|
| `SymbolStrategy.cancel_working` | `(self) -> 'None'` | Cancel every working order on this contract, one cancel per order; |
|
|
290
|
+
| `SymbolStrategy.move_stop` | `(self, *, price: 'Decimal \| None' = None, ticks: 'int \| None' = None) -> 'int'` | Move every bracket stop on this contract; returns how many moved. |
|
|
291
|
+
| `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. |
|
|
292
|
+
| `SymbolStrategy.note` | `(self, text: 'str') -> 'None'` | Attach a free-text breadcrumb to the current bar's replay frame. |
|
|
230
293
|
| `SymbolStrategy.on_bar` | `(self, bar: 'Bar') -> 'None'` | — |
|
|
231
294
|
| `SymbolStrategy.on_fill` | `(self, trade: 'HalfTradeModel') -> 'None'` | — |
|
|
232
295
|
| `SymbolStrategy.on_reject` | `(self, error: 'APIError') -> 'None'` | Called with the ``APIError`` when a sugar order call is rejected. |
|
|
@@ -323,8 +386,10 @@ broker, clock or fill model. That is what makes the class run live unchanged.
|
|
|
323
386
|
| `ctx.account_id` | first positional argument to every `orders`/`positions` call |
|
|
324
387
|
| `ctx.instrument(cid)` | `InstrumentSpec` — tick size, tick value, session metadata |
|
|
325
388
|
|
|
326
|
-
`SymbolStrategy` is sugar over exactly that: `buy/sell/close/cancel_working`,
|
|
327
|
-
|
|
389
|
+
`SymbolStrategy` is sugar over exactly that: `buy/sell/close/cancel_working`, plus
|
|
390
|
+
`move_stop/move_target` over the bracket children (`stop_orders`/`target_orders` are the
|
|
391
|
+
filtered views), `self.position` (`flat`/`is_long`/`is_short`/`avg_price`),
|
|
392
|
+
`self.working_orders`, `self.spec`, `self.bars_gated`. **The sugar
|
|
328
393
|
does not raise** — it catches `APIError`, routes it to `on_reject` and returns `None`;
|
|
329
394
|
`self.ctx.orders` is the raising path. Both are counted in the rejection tally (§5.5).
|
|
330
395
|
|
|
@@ -386,6 +451,9 @@ Rules here; tables, per-function warmups and the refused-function list in `docs/
|
|
|
386
451
|
tradeable price. Never route one into grid math without an explicit `round_to_tick`. They are
|
|
387
452
|
float64-precise, not Decimal-exact: deterministic across reruns, but a `Cross` on a Bollinger
|
|
388
453
|
edge can flip on `STDDEV`'s cancellation noise. A band touch is not exact.
|
|
454
|
+
- **`use(ind, session=…)` scopes the DATA; `trade_sessions=` scopes the DECISION.** Two
|
|
455
|
+
independent switches — see §5.11. Scoping an indicator does not restrict trading, and
|
|
456
|
+
restricting trading does not starve an indicator.
|
|
389
457
|
- One indicator instance per thread; a parameter sweep gets one per worker.
|
|
390
458
|
|
|
391
459
|
### 5.3 Data
|
|
@@ -528,12 +596,23 @@ observed trading days and replays each synthetic sequence through a fresh `Combi
|
|
|
528
596
|
- **`block_length=1` is a footgun.** It degenerates to an i.i.d. resample, destroys the
|
|
529
597
|
losing streaks that actually blow accounts, and will report a pass probability that is far
|
|
530
598
|
too kind. Default is 5.
|
|
531
|
-
-
|
|
532
|
-
|
|
533
|
-
`
|
|
534
|
-
|
|
535
|
-
|
|
599
|
+
- **`horizon_days` defaults to `BILLING_MONTH_DAYS` (21), never the observed day count.** A
|
|
600
|
+
Combine has no time limit, only a monthly fee, so "one attempt" defaults to one fee cycle —
|
|
601
|
+
the same unit `EvalEconomics` bills in. The fixed default is also the safety property: a
|
|
602
|
+
blown run stops recording days at the breach, so its count is a survival time, not a
|
|
603
|
+
Combine length, and is never used as a horizon. `source_truncated` still flags that such a
|
|
604
|
+
sample is survivorship-biased by construction — the days after the blow-up do not exist,
|
|
605
|
+
every figure is conditioned on having survived, and nothing can repair that; do not quote
|
|
606
|
+
such a result without the caveat.
|
|
536
607
|
- **`provisional` below 30 source days.** Resampling cannot create information.
|
|
608
|
+
- **The point estimate ships with its own cross-examination** (`metrics/confidence.py`).
|
|
609
|
+
`pass_probability_ci` double-bootstraps the source days themselves — the error bar the
|
|
610
|
+
day count earns, which the path count never was. `block_length_sensitivity` shows whether
|
|
611
|
+
the streak assumption is load-bearing. `monte_carlo_by_year` refuses to average a hostile
|
|
612
|
+
year against a kind one. `crosscheck` compares against `sequential_combines` under a
|
|
613
|
+
binomial 2×SE null — when they disagree, the disagreement is the finding. Quote a pass
|
|
614
|
+
probability with its CI, not alone; `mc_confidence` bundles the lot and
|
|
615
|
+
`report.to_html(path, confidence=..., crosscheck=...)` renders the cards.
|
|
537
616
|
- It cannot invent a regime the tape never contained, and it inherits every uncalibrated
|
|
538
617
|
constant (§5.4). A probability to three decimals from unverified inputs is precise, not
|
|
539
618
|
accurate.
|
|
@@ -624,25 +703,70 @@ account.
|
|
|
624
703
|
- Still your problem on a multi-year tape: **exchange holidays** (~20/year, no calendar
|
|
625
704
|
ships — filter upstream) and the uncalibrated constants (§5.4).
|
|
626
705
|
|
|
706
|
+
### 5.11 Sessions scope indicator DATA and trading DECISIONS, separately
|
|
707
|
+
|
|
708
|
+
`ASIA` / `LONDON` / `NEW_YORK` live in `core/sessions.py`; the worked example is
|
|
709
|
+
`examples/session_scoped.py`.
|
|
710
|
+
|
|
711
|
+
- **The two switches are independent, and conflating them is the bug.**
|
|
712
|
+
`use(Atr(14), session=NEW_YORK)` restricts which bars that indicator is computed from.
|
|
713
|
+
`SymbolStrategy(..., trade_sessions=(NEW_YORK,))` restricts when `on_bar` may fire. Indicators
|
|
714
|
+
advance regardless of `trade_sessions` — an indicator fed only the tradable window develops
|
|
715
|
+
gaps and computes a different value from the same tape. Both default to today's behaviour, and
|
|
716
|
+
an unscoped strategy is byte-identical to one written before sessions existed.
|
|
717
|
+
- **Which indicators to scope is a modelling decision, and it is not uniform.** Dispersion
|
|
718
|
+
measures (`Atr`, `StdDev`, `Rsi`, `Stoch`, `BBands`) describe how much price moves PER BAR, and
|
|
719
|
+
that is session-dependent: on a 24h feed an `Atr(14)` read at 09:30 ET is computed almost
|
|
720
|
+
entirely from thin pre-market bars, so it understates NY volatility exactly when stop distance
|
|
721
|
+
is being sized. Level measures (`Sma`, `Ema`) answer where price IS, and the overnight move is
|
|
722
|
+
real — an NY-only `Ema` is anchored to yesterday's 16:00 close. **Levels are continuous across
|
|
723
|
+
sessions; dispersion is not.**
|
|
724
|
+
- **A scoped indicator warms in ITS OWN cadence.** It needs `history_bars` bars *of its session*,
|
|
725
|
+
so on 5-minute bars an NY-scoped `Sma(30)` spans ~25 trading days against ~7 unscoped. Read
|
|
726
|
+
`strategy.warm` (per-indicator update counts) or `history_bars_by_session` — never
|
|
727
|
+
`bars_seen >= history_bars`, which cannot express two cadences and OVERSTATES warmth.
|
|
728
|
+
`sequential_combines` still SIZES its preload slice from the unscoped `history_bars`, so a
|
|
729
|
+
scoped strategy will honestly report `fully_warm=False` rather than silently lying.
|
|
730
|
+
- **A `Cross` inherits its inputs' scope** and refuses a conflicting `session=`. Mixed-scope
|
|
731
|
+
inputs are refused outright: they advance on different bars, so comparing them compares values
|
|
732
|
+
sampled at unrelated instants.
|
|
733
|
+
- **Sessions are defined in their own timezone, not as fixed ET offsets.** DST comes from the
|
|
734
|
+
IANA database, so nothing rots — London and New York switch on different dates, and the London
|
|
735
|
+
window really is 04:00 ET rather than 03:00 for ~3 weeks each spring and ~1 each autumn. A
|
|
736
|
+
fixed ET block is still one line: `Session("LONDON_ET", ET, time(3), time(11))`.
|
|
737
|
+
- **Session membership is a DIFFERENT axis from `trading_day_of()`.** Asia sits after the 18:00
|
|
738
|
+
ET rollover, so its bars belong to the NEXT trading day. Membership is tested on `ts_event`
|
|
739
|
+
(the bar's OPEN) over a half-open `[start, end)` window, so the 09:29→09:30 bar — every print
|
|
740
|
+
of it pre-market — is not New York.
|
|
741
|
+
- **`trade_sessions` only narrows what THIS strategy does.** It never widens what the venue
|
|
742
|
+
permits: the 16:10 ET flatten and the 16:10–18:00 no-trade window apply either way.
|
|
743
|
+
- **You need 24h data.** The shipped `data/sample_mnq_1m.csv` is RTH-only (390 bars/day,
|
|
744
|
+
09:30–15:59 ET) and contains no Asia or London bars, so nothing session-scoped is observable on
|
|
745
|
+
it. For synthetic bars pass `synthetic_bars(..., hours="globex")`; the default `"rth"` mode is
|
|
746
|
+
the New York session and nothing else.
|
|
747
|
+
- **Not built: per-session performance attribution.** Nothing in `SummaryStats` splits P&L by
|
|
748
|
+
session, so scoping is currently a modelling choice you make, not one the report scores.
|
|
749
|
+
|
|
627
750
|
## 6. The end-to-end workflow
|
|
628
751
|
|
|
629
752
|
What using this framework actually looks like, in the order you do it:
|
|
630
753
|
|
|
631
754
|
```python
|
|
632
755
|
from topstep_backtest import AccountSize, Backtest
|
|
633
|
-
from topstep_backtest.metrics import
|
|
634
|
-
from topstep_backtest.rules.kernel import Verdict
|
|
756
|
+
from topstep_backtest.metrics import crosscheck, mc_confidence, sequential_combines
|
|
635
757
|
from topstep_backtest.rules.params import combine_params
|
|
636
758
|
|
|
637
|
-
# 1. Write the strategy (§2), then run ONE backtest
|
|
638
|
-
|
|
759
|
+
# 1. Write the strategy (§2), then run ONE backtest — recorded, so the replay
|
|
760
|
+
# tab can show intent against execution.
|
|
761
|
+
report = Backtest(bars, MyStrategy(CONTRACT), account=AccountSize.S50K, record=True).run()
|
|
639
762
|
print(report) # verdict, day trail, four statistics blocks
|
|
640
763
|
|
|
641
764
|
# 2. Sanity-check the wiring BEFORE reading any number.
|
|
642
765
|
assert not report.result.rejections # zero trades + rejections = broken wiring (§5.5)
|
|
643
766
|
assert report.bars_gated != len(bars) # warmup longer than your data
|
|
644
767
|
|
|
645
|
-
# 3. Read the run, minding the basis (§5.6)
|
|
768
|
+
# 3. Read the run, minding the basis (§5.6) — and step the replay tab to each
|
|
769
|
+
# entry: the bracket where you meant it, the notes matching the fills.
|
|
646
770
|
s = report.stats
|
|
647
771
|
if s.provisional:
|
|
648
772
|
... # under 200 closes: estimates, not findings
|
|
@@ -652,16 +776,19 @@ s.drawdown.eod_trailing # Topstep's actual MLL mechanic
|
|
|
652
776
|
s.drawdown.min_floor_headroom # closest the account came to death
|
|
653
777
|
s.daily.p05 # the bad day to size against
|
|
654
778
|
|
|
655
|
-
# 4. Stop trusting one sample
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
)
|
|
779
|
+
# 4. Stop trusting one sample — real attempts first, resampled ones second.
|
|
780
|
+
sweep = sequential_combines(bars, lambda: MyStrategy(CONTRACT), window_days=21)
|
|
781
|
+
c = mc_confidence(report.result, params=combine_params(AccountSize.S50K), seed=7)
|
|
782
|
+
check = crosscheck(c.mc, sweep) # same billing-month horizon on both sides (§5.7)
|
|
660
783
|
|
|
661
|
-
# 5. Act on the AUTOPSY,
|
|
784
|
+
# 5. Act on the AUTOPSY, and quote the CI, never the bare point (§5.7).
|
|
662
785
|
# mll_breach -> resize
|
|
663
786
|
# consistency_blocked -> throttle the outsized day; the edge is fine
|
|
664
787
|
# target_not_reached -> the edge is too slow; nothing risk-side helps
|
|
788
|
+
c.ci.p05, c.ci.p95 # the error bar the source-day count earns
|
|
789
|
+
c.sensitivity.spread # wide = the streak assumption is doing the work
|
|
790
|
+
check.divergent # bootstrap vs real windows: disagreement is the finding
|
|
791
|
+
report.to_html("run.html", confidence=c, crosscheck=check) # the cards, archived
|
|
665
792
|
|
|
666
793
|
# 6. Was it an edge, or did you search until something looked good? The trial
|
|
667
794
|
# count must be RECORDED — a remembered one is always too low, because the
|
|
@@ -674,7 +801,7 @@ dsr.expected_max_sharpe > dsr.sharpe # the search alone explains the result
|
|
|
674
801
|
|
|
675
802
|
# 7. Is the attempt worth its price? Every figure is yours; none is baked in.
|
|
676
803
|
ev = evaluate_ev(
|
|
677
|
-
mc,
|
|
804
|
+
c.mc,
|
|
678
805
|
economics=EvalEconomics(
|
|
679
806
|
monthly_fee=Decimal("149"), pass_value=Decimal("2000"), reset_fee=Decimal("99")
|
|
680
807
|
),
|
|
@@ -692,9 +819,10 @@ that. **EV's `pass_value` is an assumption you supply and this package cannot ch
|
|
|
692
819
|
dominates the answer, so quote `breakeven_pass_value` ("a pass must be worth at least $X")
|
|
693
820
|
rather than `ev` unless you can defend the input.
|
|
694
821
|
|
|
695
|
-
Runnable end to end: `examples/run_combine.py` (steps 1–3) and
|
|
696
|
-
`examples/run_montecarlo.py` (steps 4–5, at two position sizes so the autopsy
|
|
697
|
-
discriminates). `docs/TUTORIAL_EMA_CROSSOVER.md` walks all of it line by line
|
|
822
|
+
Runnable end to end: `examples/run_combine.py` (steps 1–3), `examples/run_windows.py` and
|
|
823
|
+
`examples/run_montecarlo.py` (steps 4–5, the latter at two position sizes so the autopsy
|
|
824
|
+
visibly discriminates). `docs/TUTORIAL_EMA_CROSSOVER.md` walks all of it line by line, and
|
|
825
|
+
`website/workflow.md` is the narrative version with the gate each stage must pass.
|
|
698
826
|
|
|
699
827
|
## 7. What `Backtest()` refuses, and what to use instead
|
|
700
828
|
|
|
@@ -727,9 +855,10 @@ order placement — the SDK is async and the `await`s ARE the live contract. Tra
|
|
|
727
855
|
| `self.data.Close[-1]` | the `bar` argument; `ctx.history.retrieve_bars(…)` for seen history |
|
|
728
856
|
| `crossover(fast, slow)` | `self.use(Cross(fast, slow))` → `.up` / `.down` |
|
|
729
857
|
| `self.buy(size=2, sl=…, tp=…)` | `await self.buy(2, stop_loss_ticks=40, take_profit_ticks=80)` |
|
|
858
|
+
| `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
859
|
| `stats = bt.run()` → 30-key Series | `report = bt.run()` → `Report` + `.stats`/`.result`/`.trades` |
|
|
731
860
|
| `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) |
|
|
861
|
+
| `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
862
|
|
|
734
863
|
## 8. Knobs — the calibration seam
|
|
735
864
|
|
|
@@ -803,6 +932,7 @@ moving one toward optimism is a probe to report next to the baseline, not a new
|
|
|
803
932
|
| `metrics/economics.py` | `evaluate_ev` + `EvalEconomics` — EV per attempt from YOUR prices; lead with `breakeven_pass_value` |
|
|
804
933
|
| `data/continuous.py` | `stitch_continuous` — many expiries → one back-adjusted series on the bare ticker (§5.10) |
|
|
805
934
|
| `indicators/` | TA-Lib adapter (152 of 161 functions), typed wrappers, `Cross`, `Indicator`/`ValueSource` |
|
|
935
|
+
| `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
936
|
| `harness.py` | `Backtest` facade (one shared clock, instruments from the feed, strict validation, refuses backtesting.py knobs) and `Report` |
|
|
807
937
|
|
|
808
938
|
## 11. Not built — do not write code against it
|
|
@@ -810,8 +940,8 @@ moving one toward optimism is a probe to report next to the baseline, not a new
|
|
|
810
940
|
`docs/ROADMAP.md` is the authority. These names appear in older notes and other frameworks and
|
|
811
941
|
**do not exist here**: `LiveBroker`, `RecordingLiveBroker`, `DataEngine`, `TrailingMaxLossLimit`,
|
|
812
942
|
`prob_fill_on_limit` (the real field is `BarFillConfig.fill_limit_on_touch`). Also absent: a
|
|
813
|
-
Parquet/Arrow data catalog, multi-timeframe resampling, a MessageBus, per-year / per-regime
|
|
814
|
-
breakdowns, higher fill tiers, an XFA rule set, and any holiday calendar.
|
|
943
|
+
Parquet/Arrow data catalog, multi-timeframe resampling, a MessageBus, per-year / per-regime /
|
|
944
|
+
per-session breakdowns (§5.11), higher fill tiers, an XFA rule set, and any holiday calendar.
|
|
815
945
|
|
|
816
946
|
**Do** write code against these — they used to be on the list above and now ship: Monte-Carlo
|
|
817
947
|
(§5.7), `optimize()` and the overfitting guards (§5.9), the sequential-Combine sweep (§5.8),
|
|
@@ -4,7 +4,239 @@ 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.4.0] — 2026-08-25
|
|
8
|
+
|
|
9
|
+
A minor: new features throughout. One behavioural default changed — the Monte-Carlo
|
|
10
|
+
horizon now defaults to one billing month rather than the observed day count.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Regional sessions** (`core/sessions.py`: `Session`, `ASIA`, `LONDON`, `NEW_YORK`) and the two
|
|
15
|
+
independent switches that use them. `use(indicator, session=...)` scopes an indicator's INPUT
|
|
16
|
+
DATA; `SymbolStrategy(trade_sessions=...)` scopes DECISIONS. Keeping them separate is the
|
|
17
|
+
whole feature — a strategy can hold a continuous 24h `Ema` beside an NY-only `Atr` and still
|
|
18
|
+
trade only New York. Indicators advance regardless of `trade_sessions`, because starving one
|
|
19
|
+
outside the tradable window would leave it with gaps and a different value than the same
|
|
20
|
+
indicator on the same tape. Both default to today's behaviour and an unscoped run is
|
|
21
|
+
byte-identical.
|
|
22
|
+
|
|
23
|
+
Why you would scope at all: dispersion measures (`Atr`, `StdDev`, `Rsi`) describe how much
|
|
24
|
+
price moves *per bar*, and that is session-dependent — on 24h bars an `Atr(14)` read at 09:30
|
|
25
|
+
ET is computed almost entirely from thin pre-market bars, understating NY volatility exactly
|
|
26
|
+
when stop distance is being sized. Level measures (`Sma`, `Ema`) answer where price *is*, and
|
|
27
|
+
the overnight move is real: an NY-only `Ema` is anchored to yesterday's 16:00 close and blind
|
|
28
|
+
to a London repricing. Levels are continuous across sessions; dispersion is not.
|
|
29
|
+
|
|
30
|
+
**Sessions are defined in their own local timezone**, not as fixed ET offsets, so DST comes
|
|
31
|
+
from the IANA database and there is no hand-maintained table to rot — the same reasoning that
|
|
32
|
+
dropped the exchange calendar. London and New York switch on different dates, so the London
|
|
33
|
+
window really is 04:00 ET rather than 03:00 for about three weeks a year, and the tests assert
|
|
34
|
+
that against concrete 2026 dates. A fixed-ET block remains one line away
|
|
35
|
+
(`Session("LONDON_ET", ET, time(3), time(11))`), midnight-wrapping included.
|
|
36
|
+
|
|
37
|
+
Session membership is a **different axis** from `trading_day_of()`: Asia sits after the 18:00
|
|
38
|
+
ET rollover, so its bars belong to the *next* trading day. Membership is tested on `ts_event`
|
|
39
|
+
(the bar's open) over a half-open window, so the 09:29->09:30 bar — every print of it
|
|
40
|
+
pre-market — is not New York.
|
|
41
|
+
|
|
42
|
+
- `SymbolStrategy.warm`, `.registrations`, `.history_bars_by_session`, `.bars_out_of_session`,
|
|
43
|
+
`.bars_seen`, `.in_trade_session()` — the diagnostics scoping makes necessary. A scoped
|
|
44
|
+
indicator warms in its OWN cadence, so it needs `history_bars` bars *of its session*: on
|
|
45
|
+
5-minute bars an NY-scoped `Sma(30)` spans ~25 trading days against ~7 for the same indicator
|
|
46
|
+
on a 24h feed. `warm` counts each indicator's own updates, which a single bar count cannot
|
|
47
|
+
express, and `sequential_combines` now asks the strategy rather than comparing counts — the
|
|
48
|
+
old `preload >= needed` silently OVERSTATED warmth for a scoped indicator.
|
|
49
|
+
|
|
50
|
+
- `synthetic_bars(hours="globex")` — the full 23-hour electronic session (18:00 ET previous
|
|
51
|
+
calendar day to 17:00 ET), reusing the canonical open from `core/time.py`. The default
|
|
52
|
+
`"rth"` mode is unchanged. Without this nothing session-scoped is testable: the shipped
|
|
53
|
+
`data/sample_mnq_1m.csv` is RTH-only (exactly 390 bars/day, 09:30-15:59 ET) and contains no
|
|
54
|
+
Asia or London bars at all.
|
|
55
|
+
|
|
56
|
+
- A `Cross` now **inherits its inputs' data scope** and refuses a conflicting one, alongside the
|
|
57
|
+
existing refusal of unregistered inputs. Crossing a 24h `Ema` against an NY-scoped `Ema`
|
|
58
|
+
compares values sampled on unrelated cadences; leaving the `Cross` continuous over scoped
|
|
59
|
+
inputs would have it re-read unchanged values on most bars. Both were silent before.
|
|
60
|
+
|
|
61
|
+
- **`metrics.spaced_combines` — pick how many Combine attempts to simulate.**
|
|
62
|
+
Where `sequential_combines` lets the tape dictate the attempt count (disjoint windows),
|
|
63
|
+
the new sweep takes `periods` and `window_days` and spreads that many start days evenly
|
|
64
|
+
across the tape, overlapping as much as the arithmetic requires — 100 days, 10 periods
|
|
65
|
+
of 40 days starts an attempt roughly every week. Each period is still a completely fresh
|
|
66
|
+
Combine (same shared implementation: day-aligned slicing, prewarmed indicators, the
|
|
67
|
+
kernel's own verdict, `classify_failure` attribution), so the sweep measures how much
|
|
68
|
+
passing depends on *when* the attempt starts. Because overlapping periods are not
|
|
69
|
+
independent samples, the result carries `effective_independent_windows`,
|
|
70
|
+
`stride_days` and `overlap_fraction` alongside its rates, and asking for more periods
|
|
71
|
+
than the tape has distinct start days is refused rather than replaying identical
|
|
72
|
+
windows as new observations. `examples/run_spaced.py` renders a sweep as a text
|
|
73
|
+
report — the rates with their effective-sample caveat in the header, then one line
|
|
74
|
+
per period in start order so start-date sensitivity is visible at a glance.
|
|
75
|
+
|
|
76
|
+
- **An HTML tearsheet for sweeps.** `sweep.to_html(path)` / `sweep.show()` on both
|
|
77
|
+
`WindowSweep` and `SpacedSweep` (or `tearsheet.render_sweep_html`) render one
|
|
78
|
+
self-contained page: a calendar timeline with each attempt drawn over its actual
|
|
79
|
+
dates and stacked into lanes where they overlap — the reason overlapping attempts
|
|
80
|
+
are not independent samples made visible rather than footnoted — the outcome
|
|
81
|
+
autopsy as a stacked bar with each failure mode's prescription, every attempt's
|
|
82
|
+
cumulative P&L overlaid from $0, a closest-approach-to-the-MLL-floor strip (a pass
|
|
83
|
+
with $40 of headroom looks identical to a robust one in the pass rate; not here),
|
|
84
|
+
and a sortable per-attempt table with P&L sparklines, all hover-linked. To feed the
|
|
85
|
+
charts, `WindowResult` now carries `daily_pnl` (and a `cumulative_pnl` property),
|
|
86
|
+
the same per-day series walk-forward's trials keep. No charting library and no
|
|
87
|
+
embedded JSON: the charts are SVG generated in Python, so the render stays a pure
|
|
88
|
+
byte-identical function of the frozen sweep data, and the evidence caveat is
|
|
89
|
+
stamped in the page header where a screenshot cannot shed it.
|
|
90
|
+
|
|
91
|
+
- **Confidence instruments for the Monte-Carlo pass probability**
|
|
92
|
+
(`metrics/confidence.py`). The point estimate's *simulation* error was never the real
|
|
93
|
+
uncertainty; these quantify what is. `pass_probability_ci` double-bootstraps the observed
|
|
94
|
+
day set itself and reports a 5th–95th percentile band — the error bar the source-day
|
|
95
|
+
count earns (a 65% from 500 days and a 65% from 40 now read differently).
|
|
96
|
+
`block_length_sensitivity` re-runs the estimate across block lengths and reports the
|
|
97
|
+
spread: wide means the streak-clustering assumption is doing the work.
|
|
98
|
+
`monte_carlo_by_year` stratifies by calendar year so a hostile year is never averaged
|
|
99
|
+
against a kind one — the spread across strata is the error bar non-stationarity imposes.
|
|
100
|
+
`crosscheck` forces the bootstrap and `sequential_combines` (opposite biases, shared
|
|
101
|
+
`classify_failure` on purpose) to answer side by side, flagging any outcome where the
|
|
102
|
+
real windows sit outside 2×SE of what the bootstrap's own probability would produce;
|
|
103
|
+
when they disagree, the disagreement is the finding. `mc_confidence` bundles the first
|
|
104
|
+
three, and every estimate runs through the same `monte_carlo_from_blocks` core as the
|
|
105
|
+
number it qualifies. All deterministic per seed, like everything else in `metrics/`.
|
|
106
|
+
- **Monte-Carlo cards on the tearsheet.** `report.to_html(path, confidence=...,
|
|
107
|
+
crosscheck=...)` (and `show` / `to_timestamped_html`) render the estimate with its CI,
|
|
108
|
+
the block-sensitivity row, the per-year strata, and the bootstrap-vs-windows card.
|
|
109
|
+
Caller-computed on purpose: writing a file never triggers thousands of simulations as a
|
|
110
|
+
side effect, and the render stays a pure, byte-identical function of its inputs.
|
|
111
|
+
- **The workflow, documented** (`website/workflow.md`, on the site nav as "The workflow").
|
|
112
|
+
The stages in the order the questions become answerable — envelope, data, strategy, one
|
|
113
|
+
recorded run, real attempts, the distribution with its confidence, the search guards,
|
|
114
|
+
the price — each ending with the gate that must pass before the next stage's number
|
|
115
|
+
means anything. `AGENTS.md` §6 is the code-first version and now walks the same loop
|
|
116
|
+
(recorded run, `sequential_combines`, `mc_confidence` + `crosscheck`, guards, EV).
|
|
117
|
+
|
|
118
|
+
- **A native loader for databento-data-playground Parquet exports.**
|
|
119
|
+
`topstep_backtest.data.loaders.load_bars(path)` reads an export produced by that project's
|
|
120
|
+
`convert.py` and returns `(bars, spec, meta)` ready for `Backtest(...)`. The product, bar
|
|
121
|
+
span and timestamp stamping are read from the file's embedded Parquet metadata rather than
|
|
122
|
+
passed in — each is a silent corruption when guessed — and the file's copy of the
|
|
123
|
+
instrument economics is cross-checked against the engine's `InstrumentSpec`, failing loudly
|
|
124
|
+
on any mismatch instead of picking one. Files without the metadata blob are refused.
|
|
125
|
+
Requires the existing `[data]` extra (pandas + pyarrow); nothing new to install.
|
|
126
|
+
`examples/run_real_data.py` auto-detects such exports and loads them with no declaration
|
|
127
|
+
flags (and refuses the flags on one — re-declaring what the file states is the
|
|
128
|
+
contradiction they exist to prevent), and the module joins the site's API reference and
|
|
129
|
+
quickstart.
|
|
130
|
+
|
|
131
|
+
### Changed
|
|
132
|
+
|
|
133
|
+
- **The Monte-Carlo horizon defaults to one billing month** (`BILLING_MONTH_DAYS = 21`),
|
|
134
|
+
not the observed day count. A Combine has no time limit, only a monthly fee, so "one
|
|
135
|
+
attempt" defaults to one fee cycle — the same unit `EvalEconomics` bills in (its
|
|
136
|
+
`trading_days_per_month` default now *is* this constant). The fixed default also
|
|
137
|
+
retires the guard that made `horizon_days` mandatory for a blown source run: the old
|
|
138
|
+
hazard was defaulting to a survival time, and the new default cannot. A failed run
|
|
139
|
+
still reports `source_truncated`, and the survivorship-bias caveat stands unchanged.
|
|
140
|
+
- `sample_day_path`, `nearest_rank`, and `monte_carlo_from_blocks` are module-level
|
|
141
|
+
names in `metrics/montecarlo.py` (previously underscore-private) so the confidence
|
|
142
|
+
instruments run through the identical core; they remain outside `__all__`.
|
|
143
|
+
- **The replay cockpit is easier to read.** The running-stats panel no longer stacks all
|
|
144
|
+
~50 rows: snapshots are regrouped into by-type cards — *status*, *trades (gross)*,
|
|
145
|
+
*round trips (net)*, *risk & quant*, *daily P&L* — shown one at a time behind filter
|
|
146
|
+
chips, with a `•` on any chip whose hidden card the newest snapshot moved. The values are
|
|
147
|
+
the same rows the results tab renders, relocated rather than rebuilt (a stat missing from
|
|
148
|
+
the regrouping map fails loudly instead of silently vanishing from the replay). The event
|
|
149
|
+
log moved from the cramped right column to a full-width strip under the charts — one
|
|
150
|
+
event per line, heading and kind filters on one row — and the panel text was tightened
|
|
151
|
+
throughout. Both tabs' price charts also gained a marker legend (entry/exit arrows, and
|
|
152
|
+
in the replay the cross fires and the working stop/target/avg-entry price lines), so the
|
|
153
|
+
glyphs on the tape no longer require guessing.
|
|
154
|
+
|
|
155
|
+
## [0.3.0] — 2026-08-20
|
|
156
|
+
|
|
157
|
+
A minor: new features, no intended breaking changes.
|
|
158
|
+
|
|
159
|
+
### Added
|
|
160
|
+
|
|
161
|
+
- **Bar-by-bar replay of a run.** `Backtest(..., record=True)` hooks a recorder into the
|
|
162
|
+
engine and `Report.replay` carries the full recording (`replay.py`): every decision at the
|
|
163
|
+
`ctx` seam with its exact parameters and the broker's answer — rejections keep their
|
|
164
|
+
gateway code and message instead of being flattened to a count — every order event, fill
|
|
165
|
+
(gross P&L + costs), position snapshot, session enforcement (16:10 flatten, session close,
|
|
166
|
+
MLL breach, DLL lockout), per-bar indicator values named by the attribute the strategy
|
|
167
|
+
stores them under (`self.fast = self.use(Sma(20))` records as `fast`; multi-output
|
|
168
|
+
indicators record line by line; `Cross` fires are events), warmup-gated bars as a state
|
|
169
|
+
rather than a hole, and change-only tracks of position / working orders / balance folded
|
|
170
|
+
from the same SDK events the strategy's own views fold. **Recording is observation only**:
|
|
171
|
+
results are byte-identical with it on or off, and `Strategy.note()` — the new narrative
|
|
172
|
+
breadcrumb channel (`self.note("why")` inside any hook, tagged with the hook that said it)
|
|
173
|
+
— is a pure sink that cannot influence a run. Both pinned by goldens
|
|
174
|
+
(`tests/golden/test_replay_goldens.py`).
|
|
175
|
+
- **Running statistics that cannot drift from the report.** The recording carries sparse
|
|
176
|
+
`SummaryStats` snapshots (one per closing trade, day close, and breach — never per bar),
|
|
177
|
+
each computed over the run's *prefix* by the same `metrics.stats` code `compute_summary`
|
|
178
|
+
uses: the shared helpers were extracted for this (`compute_trade_close_stats`,
|
|
179
|
+
`compute_daily_stats`, `compute_round_trip_stats`, `compute_eod_drawdown`,
|
|
180
|
+
`sortino_ratio`, `exposure_fraction` — `compute_summary`'s behavior is unchanged), and the
|
|
181
|
+
only hand-rolled parts are the O(1) equity folds. The terminal snapshot is golden-pinned
|
|
182
|
+
byte-equal to `compute_summary` and property-swept across tapes
|
|
183
|
+
(`tests/property/test_replay_props.py`). One documented exception: `exposure` refreshes at
|
|
184
|
+
day closes only (the single prefix stat with no exact incremental form) and is exact again
|
|
185
|
+
at the terminal snapshot.
|
|
186
|
+
- **The tearsheet grows a replay tab** when the report carries a recording. The page splits
|
|
187
|
+
into *results* (the finished run) and *replay · bar by bar*, a cockpit sized to the
|
|
188
|
+
viewport — charts down one side, the settled state and running stats beside them, the
|
|
189
|
+
event log alongside — so stepping a run never means scrolling between the tape and the
|
|
190
|
+
panel that explains it. Step it bar by bar (buttons, slider, ←/→ with shift for ×10,
|
|
191
|
+
Home/End, space to autoplay at 1, 5, 15 or 40 bars a second, click a chart or a log row
|
|
192
|
+
to jump); everything after the
|
|
193
|
+
cursor is veiled on every one of the replay tab's charts, so it shows only what the
|
|
194
|
+
strategy had seen, while the results tab keeps showing the run that finished. A strip
|
|
195
|
+
across the tape carries the live trade numbers at the cursor — position and average entry,
|
|
196
|
+
open P&L, the day, equity, floor headroom, and the closed-trade record so far (net P&L,
|
|
197
|
+
net win rate, trades) lifted from the running-stats snapshot in force, plus the cursor
|
|
198
|
+
bar's OHLC; the toolbar's `stats` button hides it. Charts follow the cursor in a
|
|
199
|
+
selectable window (whole run, or 60–480 bars — a long recording opens following, since
|
|
200
|
+
fitting 30,000 bars into a pane gives each one half a pixel), and their series' last-value
|
|
201
|
+
badges are off in favour of a price line at the cursor's own close and equity: a badge
|
|
202
|
+
reporting the end of a run the cursor has not reached is future information printed on the
|
|
203
|
+
axis of the one view that promises not to show any. The running stats mark every row the
|
|
204
|
+
newest snapshot moved (hover for the previous value) and say which snapshot of how many is
|
|
205
|
+
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
|
|
206
|
+
orders — also drawn as price lines on the candlestick pane — balance, equity, floor
|
|
207
|
+
headroom, today's P&L), the indicator lines on their own synced chart with cross fires
|
|
208
|
+
marked on the tape, the running-stats snapshot in force, and a filterable-by-eye event log
|
|
209
|
+
with rejections loud. The open tab rides in the URL fragment (`#replay`), so a reload — or
|
|
210
|
+
a link to an archived file — comes back to the view it was left on. All figures are Python-preformatted by
|
|
211
|
+
the same helpers as the rest of the page; the JS computes nothing beyond differences of
|
|
212
|
+
two displayed numbers. Long tapes embed a **loudly labelled window** past
|
|
213
|
+
`REPLAY_AUTO_FRAME_LIMIT` (20,000) frames — breach-centred when there is a breach, else
|
|
214
|
+
the tail — and `to_html(path, replay=(start, end) | "full" | "off")` overrides it
|
|
215
|
+
(`show()`, `to_timestamped_html()` and `run_with_tearsheet()` forward the same knob).
|
|
216
|
+
Payload schema version bumps to 2.
|
|
217
|
+
- **Amending a live bracket, and the average entry price to measure it from.**
|
|
218
|
+
`SymbolStrategy.move_stop(...)` / `move_target(...)` amend the reduce-only stop and limit
|
|
219
|
+
the venue creates when an entry fills, taking either `price=` (absolute, passed to the
|
|
220
|
+
broker as given) or `ticks=` — an offset from the average entry signed **in the position's
|
|
221
|
+
favour**, so `ticks=0` is breakeven and `ticks=10` is ten ticks of locked profit long or
|
|
222
|
+
short. They act on bracket children only (`parent_order_id` set), so a stop-*entry* of
|
|
223
|
+
your own is never mistaken for protection; they move every child (a scale-in has several)
|
|
224
|
+
and return the count; rejections go to `on_reject` as with every other sugar call. A level
|
|
225
|
+
measured off the average is snapped to the tick grid **against** the position, because the
|
|
226
|
+
venue's average is quantized to tick/100 and a stop rounding toward profit would lock in a
|
|
227
|
+
tick the entry cannot support. `stop_orders` / `target_orders` expose the same filtered
|
|
228
|
+
view. Amended levels are live from the next bar, which the `accepted_ts` firewall
|
|
229
|
+
guarantees and an end-to-end test pins by exit price.
|
|
230
|
+
- **`position.avg_price`** — the venue's display average (`PositionModel.average_price`),
|
|
231
|
+
which the position view previously discarded. Carried from snapshots rather than
|
|
232
|
+
re-derived, so no second averaging convention is invented for a number the gateway
|
|
233
|
+
publishes; fills keep it honest where they can do so exactly (opening from flat, flipping
|
|
234
|
+
through it, flattening), and a scale-in leaves the previous average standing until the
|
|
235
|
+
snapshot corrects it — the next event in the sim, one hub round trip live.
|
|
236
|
+
- `Report.replay_json(path)` dumps the raw recording as JSON — for diffing two runs or
|
|
237
|
+
verifying what was captured without a browser in the way. `SymbolStrategy` gains a
|
|
238
|
+
read-only `registered_indicators`; `SimBroker` gains O(1) `last_bar_equity`.
|
|
239
|
+
`examples/run_replay.py` shows the whole flow, narrated.
|
|
8
240
|
|
|
9
241
|
## [0.2.3] — 2026-08-16
|
|
10
242
|
|
|
@@ -338,4 +570,6 @@ rule and fee constants are **not yet calibrated against a live account**, so a
|
|
|
338
570
|
are not exercised end to end.
|
|
339
571
|
- Tier-0 bar fills only. No quote, depth or MBO tiers.
|
|
340
572
|
|
|
573
|
+
[0.4.0]: https://pypi.org/project/topstep-backtest/0.4.0/
|
|
574
|
+
[0.3.0]: https://pypi.org/project/topstep-backtest/0.3.0/
|
|
341
575
|
[0.1.0]: https://pypi.org/project/topstep-backtest/0.1.0/
|