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.
Files changed (138) hide show
  1. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/.gitignore +4 -1
  2. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/AGENTS.md +162 -32
  3. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/CHANGELOG.md +235 -1
  4. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/PKG-INFO +72 -8
  5. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/README.md +71 -7
  6. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/DESIGN.md +81 -1
  7. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/INDICATORS.md +61 -0
  8. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/ROADMAP.md +6 -4
  9. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/TUTORIAL_EMA_CROSSOVER.md +68 -6
  10. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_montecarlo.py +30 -7
  11. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_real_data.py +81 -4
  12. topstep_backtest-0.4.0/examples/run_replay.py +101 -0
  13. topstep_backtest-0.4.0/examples/run_spaced.py +125 -0
  14. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_windows.py +71 -8
  15. topstep_backtest-0.4.0/examples/session_scoped.py +151 -0
  16. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/__init__.py +6 -0
  17. topstep_backtest-0.4.0/src/topstep_backtest/core/sessions.py +143 -0
  18. topstep_backtest-0.4.0/src/topstep_backtest/data/loaders.py +117 -0
  19. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/synthetic.py +57 -21
  20. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/engine/backtest.py +65 -10
  21. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/execution/sim_broker.py +10 -0
  22. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/harness.py +115 -13
  23. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/__init__.py +31 -1
  24. topstep_backtest-0.4.0/src/topstep_backtest/metrics/confidence.py +536 -0
  25. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/economics.py +6 -3
  26. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/montecarlo.py +65 -34
  27. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/stats.py +109 -37
  28. topstep_backtest-0.4.0/src/topstep_backtest/metrics/windows.py +629 -0
  29. topstep_backtest-0.4.0/src/topstep_backtest/replay.py +1306 -0
  30. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/strategy/base.py +20 -1
  31. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/strategy/symbol.py +329 -21
  32. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/strategy/tracker.py +32 -5
  33. topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/__init__.py +1290 -0
  34. topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/_assets/sweep.css +154 -0
  35. topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/_assets/sweep.js +42 -0
  36. topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/_assets/tearsheet.css +514 -0
  37. topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/_assets/tearsheet.js +1516 -0
  38. topstep_backtest-0.4.0/src/topstep_backtest/tearsheet/sweep.py +522 -0
  39. topstep_backtest-0.4.0/tests/golden/test_replay_goldens.py +134 -0
  40. topstep_backtest-0.4.0/tests/property/test_replay_props.py +124 -0
  41. topstep_backtest-0.4.0/tests/unit/test_confidence.py +342 -0
  42. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_harness.py +81 -0
  43. topstep_backtest-0.4.0/tests/unit/test_loaders.py +176 -0
  44. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_montecarlo.py +17 -5
  45. topstep_backtest-0.4.0/tests/unit/test_replay.py +377 -0
  46. topstep_backtest-0.4.0/tests/unit/test_replay_tearsheet.py +160 -0
  47. topstep_backtest-0.4.0/tests/unit/test_sessions.py +138 -0
  48. topstep_backtest-0.4.0/tests/unit/test_sweep_tearsheet.py +131 -0
  49. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_symbol_strategy.py +406 -8
  50. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_synthetic.py +75 -0
  51. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_tearsheet.py +91 -0
  52. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_tracker.py +71 -3
  53. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_windows.py +103 -1
  54. topstep_backtest-0.2.3/src/topstep_backtest/metrics/windows.py +0 -314
  55. topstep_backtest-0.2.3/src/topstep_backtest/tearsheet/__init__.py +0 -548
  56. topstep_backtest-0.2.3/src/topstep_backtest/tearsheet/_assets/tearsheet.css +0 -176
  57. topstep_backtest-0.2.3/src/topstep_backtest/tearsheet/_assets/tearsheet.js +0 -468
  58. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/LICENSE +0 -0
  59. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/data/sample_mnq_1m.csv +0 -0
  60. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/docs/topstep-rules.md +0 -0
  61. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/ema_cross.py +0 -0
  62. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/hand_wired.py +0 -0
  63. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_combine.py +0 -0
  64. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/run_tearsheet.py +0 -0
  65. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/sma_cross.py +0 -0
  66. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/examples/talib_macd.py +0 -0
  67. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/pyproject.toml +0 -0
  68. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/_render.py +0 -0
  69. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/clock/__init__.py +0 -0
  70. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/clock/live_clock.py +0 -0
  71. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/clock/test_clock.py +0 -0
  72. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/__init__.py +0 -0
  73. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/ids.py +0 -0
  74. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/instruments.py +0 -0
  75. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/money.py +0 -0
  76. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/core/time.py +0 -0
  77. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/__init__.py +0 -0
  78. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/clean.py +0 -0
  79. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/continuous.py +0 -0
  80. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/feed.py +0 -0
  81. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/validator.py +0 -0
  82. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/data/wrangler.py +0 -0
  83. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/engine/__init__.py +0 -0
  84. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/execution/__init__.py +0 -0
  85. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/execution/rejections.py +0 -0
  86. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/fills/__init__.py +0 -0
  87. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/fills/bar_fill.py +0 -0
  88. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/fills/fees.py +0 -0
  89. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/fills/path.py +0 -0
  90. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/indicators/__init__.py +0 -0
  91. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/indicators/base.py +0 -0
  92. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/indicators/library.py +0 -0
  93. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/indicators/talib_adapter.py +0 -0
  94. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/overfitting.py +0 -0
  95. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/metrics/walkforward.py +0 -0
  96. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/protocols.py +0 -0
  97. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/py.typed +0 -0
  98. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/rules/__init__.py +0 -0
  99. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/rules/kernel.py +0 -0
  100. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/rules/params.py +0 -0
  101. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/strategy/__init__.py +0 -0
  102. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/tearsheet/_assets/lightweight-charts.LICENSE +0 -0
  103. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/src/topstep_backtest/tearsheet/_assets/lightweight-charts.standalone.production.js +0 -0
  104. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/__init__.py +0 -0
  105. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/conftest.py +0 -0
  106. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/__init__.py +0 -0
  107. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/artifacts/verdict_failed_mll_s50k.json +0 -0
  108. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/artifacts/verdict_passed_s50k.json +0 -0
  109. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/test_combine_kernel.py +0 -0
  110. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/test_facade_equivalence.py +0 -0
  111. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/test_sugar_equivalence.py +0 -0
  112. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/golden/test_verdict_goldens.py +0 -0
  113. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/parity/__init__.py +0 -0
  114. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/parity/test_broker_conformance.py +0 -0
  115. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/property/__init__.py +0 -0
  116. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/property/test_indicator_props.py +0 -0
  117. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/property/test_kernel_props.py +0 -0
  118. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/property/test_money_props.py +0 -0
  119. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/__init__.py +0 -0
  120. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_bar_fill.py +0 -0
  121. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_clean.py +0 -0
  122. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_clock.py +0 -0
  123. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_continuous.py +0 -0
  124. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_data_feed.py +0 -0
  125. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_economics.py +0 -0
  126. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_engine.py +0 -0
  127. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_fees.py +0 -0
  128. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_indicators.py +0 -0
  129. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_instruments.py +0 -0
  130. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_overfitting.py +0 -0
  131. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_path.py +0 -0
  132. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_sim_broker.py +0 -0
  133. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_stats.py +0 -0
  134. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_talib_adapter_hardening.py +0 -0
  135. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_time.py +0 -0
  136. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_validator.py +0 -0
  137. {topstep_backtest-0.2.3 → topstep_backtest-0.4.0}/tests/unit/test_walkforward.py +0 -0
  138. {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' = 390, unit: 'AggregateBarUnit' = <AggregateBarUnit.MINUTE: 2>, unit_number: 'int' = 1, drift_ticks_per_day: 'int' = 0, vol_ticks: 'int' = 8) -> 'tuple[Bar, ...]'` | Generate ``days`` trading sessions of consistent, on-grid OHLCV bars. |
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`, `self.position`
327
- (`flat`/`is_long`/`is_short`), `self.working_orders`, `self.spec`, `self.bars_gated`. **The sugar
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
- - **It refuses to default `horizon_days` when the source run FAILED** — a blown run stops
532
- recording days at the breach, so that count is a survival time, not a Combine length.
533
- `source_truncated` then flags that the sample is survivorship-biased by construction: the
534
- days after the blow-up do not exist, so every figure is conditioned on having survived.
535
- Nothing can repair that; do not quote such a result without the caveat.
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 monte_carlo
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
- report = Backtest(bars, MyStrategy(CONTRACT), account=AccountSize.S50K).run()
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. A FAILED run needs an explicit horizon (§5.7).
656
- horizon = None if report.result.verdict is not Verdict.FAILED else 40
657
- mc = monte_carlo(
658
- report.result, params=combine_params(AccountSize.S50K), paths=3000, horizon_days=horizon, seed=7
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, not the headline.
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 visibly
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
- ## [Unreleased]
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/