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.
Files changed (124) hide show
  1. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/.gitignore +2 -1
  2. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/AGENTS.md +41 -7
  3. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/CHANGELOG.md +115 -1
  4. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/PKG-INFO +34 -5
  5. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/README.md +31 -4
  6. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/DESIGN.md +43 -1
  7. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/ROADMAP.md +1 -1
  8. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/TUTORIAL_EMA_CROSSOVER.md +23 -0
  9. topstep_backtest-0.3.0/examples/run_replay.py +101 -0
  10. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/pyproject.toml +12 -1
  11. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/engine/backtest.py +65 -10
  12. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/execution/sim_broker.py +10 -0
  13. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/harness.py +83 -13
  14. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/stats.py +109 -37
  15. topstep_backtest-0.3.0/src/topstep_backtest/replay.py +1306 -0
  16. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/strategy/base.py +20 -1
  17. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/strategy/symbol.py +116 -0
  18. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/strategy/tracker.py +32 -5
  19. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/tearsheet/__init__.py +539 -62
  20. topstep_backtest-0.3.0/src/topstep_backtest/tearsheet/_assets/tearsheet.css +494 -0
  21. topstep_backtest-0.3.0/src/topstep_backtest/tearsheet/_assets/tearsheet.js +1449 -0
  22. topstep_backtest-0.3.0/tests/golden/test_replay_goldens.py +134 -0
  23. topstep_backtest-0.3.0/tests/property/test_replay_props.py +124 -0
  24. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_harness.py +81 -0
  25. topstep_backtest-0.3.0/tests/unit/test_replay.py +377 -0
  26. topstep_backtest-0.3.0/tests/unit/test_replay_tearsheet.py +160 -0
  27. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_symbol_strategy.py +190 -6
  28. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_tearsheet.py +14 -0
  29. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_tracker.py +71 -3
  30. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_wrangler.py +36 -0
  31. topstep_backtest-0.2.2/src/topstep_backtest/tearsheet/_assets/tearsheet.css +0 -176
  32. topstep_backtest-0.2.2/src/topstep_backtest/tearsheet/_assets/tearsheet.js +0 -468
  33. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/LICENSE +0 -0
  34. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/data/sample_mnq_1m.csv +0 -0
  35. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/INDICATORS.md +0 -0
  36. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/docs/topstep-rules.md +0 -0
  37. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/ema_cross.py +0 -0
  38. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/hand_wired.py +0 -0
  39. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_combine.py +0 -0
  40. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_montecarlo.py +0 -0
  41. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_real_data.py +0 -0
  42. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_tearsheet.py +0 -0
  43. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/run_windows.py +0 -0
  44. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/sma_cross.py +0 -0
  45. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/examples/talib_macd.py +0 -0
  46. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/__init__.py +0 -0
  47. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/_render.py +0 -0
  48. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/clock/__init__.py +0 -0
  49. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/clock/live_clock.py +0 -0
  50. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/clock/test_clock.py +0 -0
  51. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/__init__.py +0 -0
  52. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/ids.py +0 -0
  53. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/instruments.py +0 -0
  54. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/money.py +0 -0
  55. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/core/time.py +0 -0
  56. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/__init__.py +0 -0
  57. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/clean.py +0 -0
  58. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/continuous.py +0 -0
  59. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/feed.py +0 -0
  60. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/synthetic.py +0 -0
  61. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/validator.py +0 -0
  62. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/data/wrangler.py +0 -0
  63. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/engine/__init__.py +0 -0
  64. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/execution/__init__.py +0 -0
  65. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/execution/rejections.py +0 -0
  66. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/fills/__init__.py +0 -0
  67. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/fills/bar_fill.py +0 -0
  68. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/fills/fees.py +0 -0
  69. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/fills/path.py +0 -0
  70. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/indicators/__init__.py +0 -0
  71. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/indicators/base.py +0 -0
  72. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/indicators/library.py +0 -0
  73. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/indicators/talib_adapter.py +0 -0
  74. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/__init__.py +0 -0
  75. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/economics.py +0 -0
  76. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/montecarlo.py +0 -0
  77. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/overfitting.py +0 -0
  78. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/walkforward.py +0 -0
  79. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/metrics/windows.py +0 -0
  80. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/protocols.py +0 -0
  81. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/py.typed +0 -0
  82. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/rules/__init__.py +0 -0
  83. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/rules/kernel.py +0 -0
  84. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/rules/params.py +0 -0
  85. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/strategy/__init__.py +0 -0
  86. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/tearsheet/_assets/lightweight-charts.LICENSE +0 -0
  87. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/src/topstep_backtest/tearsheet/_assets/lightweight-charts.standalone.production.js +0 -0
  88. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/__init__.py +0 -0
  89. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/conftest.py +0 -0
  90. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/__init__.py +0 -0
  91. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/artifacts/verdict_failed_mll_s50k.json +0 -0
  92. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/artifacts/verdict_passed_s50k.json +0 -0
  93. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/test_combine_kernel.py +0 -0
  94. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/test_facade_equivalence.py +0 -0
  95. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/test_sugar_equivalence.py +0 -0
  96. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/golden/test_verdict_goldens.py +0 -0
  97. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/parity/__init__.py +0 -0
  98. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/parity/test_broker_conformance.py +0 -0
  99. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/property/__init__.py +0 -0
  100. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/property/test_indicator_props.py +0 -0
  101. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/property/test_kernel_props.py +0 -0
  102. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/property/test_money_props.py +0 -0
  103. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/__init__.py +0 -0
  104. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_bar_fill.py +0 -0
  105. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_clean.py +0 -0
  106. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_clock.py +0 -0
  107. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_continuous.py +0 -0
  108. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_data_feed.py +0 -0
  109. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_economics.py +0 -0
  110. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_engine.py +0 -0
  111. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_fees.py +0 -0
  112. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_indicators.py +0 -0
  113. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_instruments.py +0 -0
  114. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_montecarlo.py +0 -0
  115. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_overfitting.py +0 -0
  116. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_path.py +0 -0
  117. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_sim_broker.py +0 -0
  118. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_stats.py +0 -0
  119. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_synthetic.py +0 -0
  120. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_talib_adapter_hardening.py +0 -0
  121. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_time.py +0 -0
  122. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_validator.py +0 -0
  123. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_walkforward.py +0 -0
  124. {topstep_backtest-0.2.2 → topstep_backtest-0.3.0}/tests/unit/test_windows.py +0 -0
@@ -42,5 +42,6 @@ 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
@@ -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`, `self.position`
327
- (`flat`/`is_long`/`is_short`), `self.working_orders`, `self.spec`, `self.bars_gated`. **The sugar
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
- ## [Unreleased]
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.2.2
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, for DataFrame input
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
- `run_montecarlo.py` (outcome distribution + autopsy), `run_windows.py` (a long tape
304
- replayed as consecutive independent Combine attempts), `ema_cross.py`, `sma_cross.py`,
305
- `talib_macd.py`, `hand_wired.py` (what the facade assembles).
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, for DataFrame input
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
- `run_montecarlo.py` (outcome distribution + autopsy), `run_windows.py` (a long tape
264
- replayed as consecutive independent Combine attempts), `ema_cross.py`, `sma_cross.py`,
265
- `talib_macd.py`, `hand_wired.py` (what the facade assembles).
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 two extras exist, `[data]` and `[dev]`.
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 = ["pandas>=2.2"]
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