topstep-backtest 0.1.0__py3-none-any.whl

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 (44) hide show
  1. topstep_backtest/__init__.py +43 -0
  2. topstep_backtest/clock/__init__.py +1 -0
  3. topstep_backtest/clock/live_clock.py +82 -0
  4. topstep_backtest/clock/test_clock.py +133 -0
  5. topstep_backtest/core/__init__.py +1 -0
  6. topstep_backtest/core/ids.py +23 -0
  7. topstep_backtest/core/instruments.py +167 -0
  8. topstep_backtest/core/money.py +160 -0
  9. topstep_backtest/core/time.py +125 -0
  10. topstep_backtest/data/__init__.py +1 -0
  11. topstep_backtest/data/clean.py +86 -0
  12. topstep_backtest/data/feed.py +56 -0
  13. topstep_backtest/data/synthetic.py +137 -0
  14. topstep_backtest/data/validator.py +215 -0
  15. topstep_backtest/data/wrangler.py +306 -0
  16. topstep_backtest/engine/__init__.py +1 -0
  17. topstep_backtest/engine/backtest.py +209 -0
  18. topstep_backtest/execution/__init__.py +1 -0
  19. topstep_backtest/execution/rejections.py +53 -0
  20. topstep_backtest/execution/sim_broker.py +1436 -0
  21. topstep_backtest/fills/__init__.py +1 -0
  22. topstep_backtest/fills/bar_fill.py +268 -0
  23. topstep_backtest/fills/fees.py +120 -0
  24. topstep_backtest/fills/path.py +59 -0
  25. topstep_backtest/harness.py +446 -0
  26. topstep_backtest/indicators/__init__.py +46 -0
  27. topstep_backtest/indicators/base.py +57 -0
  28. topstep_backtest/indicators/library.py +303 -0
  29. topstep_backtest/indicators/talib_adapter.py +657 -0
  30. topstep_backtest/metrics/__init__.py +5 -0
  31. topstep_backtest/metrics/stats.py +153 -0
  32. topstep_backtest/protocols.py +473 -0
  33. topstep_backtest/py.typed +0 -0
  34. topstep_backtest/rules/__init__.py +1 -0
  35. topstep_backtest/rules/kernel.py +281 -0
  36. topstep_backtest/rules/params.py +74 -0
  37. topstep_backtest/strategy/__init__.py +20 -0
  38. topstep_backtest/strategy/base.py +118 -0
  39. topstep_backtest/strategy/symbol.py +344 -0
  40. topstep_backtest/strategy/tracker.py +151 -0
  41. topstep_backtest-0.1.0.dist-info/METADATA +250 -0
  42. topstep_backtest-0.1.0.dist-info/RECORD +44 -0
  43. topstep_backtest-0.1.0.dist-info/WHEEL +4 -0
  44. topstep_backtest-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,306 @@
1
+ """Wrangle user-supplied OHLCV rows into validated, time-ordered ``Bar`` tuples.
2
+
3
+ The single most dangerous bug in bar-based backtesting is the silent
4
+ off-by-one-bar look-ahead: treating a bar's OPEN timestamp as if it were its
5
+ CLOSE (or vice versa) hands the strategy one bar of the future. This module
6
+ kills that bug at the API level — the caller MUST declare what the source
7
+ timestamp means via ``stamp``:
8
+
9
+ - ``stamp="open"``: ``ts_event = ts`` and ``ts_init = ts + step``
10
+ - ``stamp="close"``: ``ts_init = ts`` and ``ts_event = ts - step``
11
+
12
+ where ``step = unit x unit_number`` in nanoseconds. There is no default.
13
+
14
+ Other hard guarantees:
15
+ - NAIVE timestamps are rejected with an error naming the fix — never guessed.
16
+ - Prices convert via ``str() -> Decimal`` (never ``float -> Decimal``) and
17
+ must land exactly on the instrument's tick grid (``RowOffGridError`` with
18
+ the offending row index otherwise). NaN/Infinity prices and negative
19
+ volumes are rejected with the row context.
20
+ - Only fixed-span intraday units (SECOND/MINUTE/HOUR) are accepted:
21
+ a Globex trading day is 23 hours, so DAY-and-above bars belong to the
22
+ session-aware calendar-resampling layer, not a fixed nanosecond step.
23
+ - Output is sorted ascending by ``ts_init`` (stable), ready for a feed.
24
+
25
+ pandas is imported lazily — only ``bars_from_dataframe`` needs it.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from datetime import datetime
31
+ from decimal import Decimal, InvalidOperation
32
+ from typing import TYPE_CHECKING, Any, Literal
33
+
34
+ from topstep_sdk import AggregateBarUnit
35
+
36
+ from ..core.money import OffGridError, is_on_grid
37
+ from ..core.time import NS_PER_SEC, dt_to_ns
38
+ from ..protocols import Bar, BarType
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Iterable
42
+
43
+ from ..core.instruments import InstrumentSpec
44
+
45
+ __all__ = [
46
+ "RowOffGridError",
47
+ "bars_from_dataframe",
48
+ "bars_from_records",
49
+ "step_ns",
50
+ ]
51
+
52
+ _STEP_NS_PER_UNIT: dict[AggregateBarUnit, int] = {
53
+ AggregateBarUnit.SECOND: NS_PER_SEC,
54
+ AggregateBarUnit.MINUTE: 60 * NS_PER_SEC,
55
+ AggregateBarUnit.HOUR: 3_600 * NS_PER_SEC,
56
+ }
57
+
58
+ _SESSION_UNITS = (AggregateBarUnit.DAY, AggregateBarUnit.WEEK, AggregateBarUnit.MONTH)
59
+
60
+ _TS_COLUMN_ALIASES = ("timestamp", "ts", "time", "datetime", "ts_event")
61
+ _OHLCV_FIELDS = ("open", "high", "low", "close", "volume")
62
+
63
+
64
+ class RowOffGridError(OffGridError):
65
+ """An input price is off the tick grid; carries the offending row index."""
66
+
67
+ def __init__(self, price: Decimal, tick_size: Decimal, *, row: int, field: str) -> None:
68
+ super().__init__(price, tick_size)
69
+ self.row = row
70
+ self.field = field
71
+ # Re-point the message at the row context (OffGridError.__init__ set a
72
+ # generic one); ``price``/``tick_size`` attributes remain intact.
73
+ self.args = (f"row {row}: {field}={price} is not on the {tick_size} tick grid",)
74
+
75
+
76
+ def step_ns(unit: AggregateBarUnit, unit_number: int) -> int:
77
+ """The bar step (open -> close span) in nanoseconds.
78
+
79
+ Supports SECOND / MINUTE / HOUR only. TICK bars have no fixed time span.
80
+ DAY / WEEK / MONTH bars are session-scoped, not fixed-span: a Globex
81
+ trading day runs 23 hours (18:00 ET -> 17:00 ET), so a fixed 86,400s step
82
+ would mis-stamp every daily bar and mis-attribute its trading day.
83
+ Session-aware daily bars arrive with the calendar-resampling layer —
84
+ supply intraday bars here.
85
+ """
86
+ if unit_number < 1:
87
+ raise ValueError(f"unit_number must be >= 1, got {unit_number}")
88
+ per_unit = _STEP_NS_PER_UNIT.get(unit)
89
+ if per_unit is None:
90
+ if unit in _SESSION_UNITS:
91
+ raise ValueError(
92
+ f"unsupported bar unit {unit!r}: a Globex trading day is 23 hours "
93
+ "(18:00 ET -> 17:00 ET), so DAY-and-above bars have no fixed "
94
+ "nanosecond step; session-aware daily bars arrive with the "
95
+ "calendar-resampling layer — supply intraday (SECOND/MINUTE/HOUR) "
96
+ "bars instead"
97
+ )
98
+ raise ValueError(
99
+ f"unsupported bar unit {unit!r}: only SECOND/MINUTE/HOUR have a fixed time step"
100
+ )
101
+ return per_unit * unit_number
102
+
103
+
104
+ def _ts_to_ns(value: object, *, row: int) -> int:
105
+ """Coerce one source timestamp to int UTC nanoseconds.
106
+
107
+ Accepts int (already ns), tz-aware ``datetime``, or tz-aware pandas
108
+ ``Timestamp`` (exact ``.value`` ns, no float round-trip). Naive timestamps
109
+ are rejected with the fix spelled out.
110
+ """
111
+ if isinstance(value, bool):
112
+ raise TypeError(f"row {row}: bool is not a valid timestamp")
113
+ if isinstance(value, int):
114
+ return value
115
+ if isinstance(value, datetime):
116
+ if value.tzinfo is None:
117
+ raise ValueError(
118
+ f"row {row}: naive timestamp {value!r} — localize it first (e.g. "
119
+ "df['timestamp'].dt.tz_localize('UTC') for Databento UTC data, or "
120
+ "tz_localize('America/New_York') for ET wall-clock data); "
121
+ "guessing a timezone would silently shift every bar"
122
+ )
123
+ # pandas Timestamp: use its exact integer ns instead of float seconds.
124
+ ns_value: object = getattr(value, "value", None)
125
+ if isinstance(ns_value, int):
126
+ return ns_value
127
+ return dt_to_ns(value)
128
+ raise TypeError(
129
+ f"row {row}: cannot interpret timestamp {value!r} of type "
130
+ f"{type(value).__name__}; pass int UTC ns or a tz-aware datetime"
131
+ )
132
+
133
+
134
+ def _price_to_decimal(value: object, *, row: int, field: str) -> Decimal:
135
+ """Convert a price via ``str() -> Decimal`` — never float -> Decimal.
136
+
137
+ NaN / Infinity inputs (float or Decimal) are rejected with a ValueError
138
+ carrying the row index, field, and value — never a raw
139
+ ``decimal.InvalidOperation`` from downstream grid math.
140
+ """
141
+ if isinstance(value, bool):
142
+ raise TypeError(f"row {row}: bool is not a valid {field} price")
143
+ if isinstance(value, Decimal):
144
+ result = value
145
+ else:
146
+ try:
147
+ result = Decimal(str(value))
148
+ except InvalidOperation:
149
+ raise ValueError(
150
+ f"row {row}: cannot parse {field} price {value!r} as a Decimal"
151
+ ) from None
152
+ if not result.is_finite():
153
+ raise ValueError(
154
+ f"row {row}: {field} price {value!r} is not a finite number "
155
+ "(NaN/Infinity are not valid prices)"
156
+ )
157
+ return result
158
+
159
+
160
+ def _volume_to_int(value: object, *, row: int) -> int:
161
+ if isinstance(value, bool):
162
+ raise TypeError(f"row {row}: bool is not a valid volume")
163
+ if isinstance(value, int):
164
+ volume = value
165
+ else:
166
+ try:
167
+ as_decimal = Decimal(str(value))
168
+ except InvalidOperation:
169
+ raise ValueError(f"row {row}: cannot parse volume {value!r} as an integer") from None
170
+ if not as_decimal.is_finite() or as_decimal != as_decimal.to_integral_value():
171
+ raise ValueError(f"row {row}: volume {value!r} is not a whole number")
172
+ volume = int(as_decimal)
173
+ if volume < 0:
174
+ raise ValueError(f"row {row}: volume {volume} is negative")
175
+ return volume
176
+
177
+
178
+ def bars_from_records(
179
+ rows: Iterable[tuple[object, ...]],
180
+ *,
181
+ contract_id: str,
182
+ spec: InstrumentSpec,
183
+ unit: AggregateBarUnit,
184
+ unit_number: int,
185
+ stamp: Literal["open", "close"],
186
+ ) -> tuple[Bar, ...]:
187
+ """Build tick-grid-validated ``Bar`` objects from ``(ts, o, h, l, c, v)`` rows.
188
+
189
+ ``stamp`` declares what the source timestamp means — see module docstring.
190
+ Rows are sorted ascending by the resulting ``ts_init`` (stable sort), so
191
+ the output is feed-ready regardless of input order.
192
+ """
193
+ if stamp not in ("open", "close"):
194
+ raise ValueError(f'stamp must be "open" or "close", got {stamp!r}')
195
+ step = step_ns(unit, unit_number)
196
+ bar_type = BarType(contract_id=contract_id, unit=unit, unit_number=unit_number)
197
+
198
+ bars: list[Bar] = []
199
+ for row_index, row in enumerate(rows):
200
+ if len(row) != 6:
201
+ raise ValueError(
202
+ f"row {row_index}: expected 6 fields (ts, open, high, low, close, "
203
+ f"volume), got {len(row)}"
204
+ )
205
+ ts = _ts_to_ns(row[0], row=row_index)
206
+ prices: dict[str, Decimal] = {}
207
+ for field, raw in zip(_OHLCV_FIELDS[:4], row[1:5], strict=True):
208
+ price = _price_to_decimal(raw, row=row_index, field=field)
209
+ if not is_on_grid(price, spec.tick_size):
210
+ raise RowOffGridError(price, spec.tick_size, row=row_index, field=field)
211
+ prices[field] = price
212
+ volume = _volume_to_int(row[5], row=row_index)
213
+
214
+ if stamp == "open":
215
+ ts_event, ts_init = ts, ts + step
216
+ else:
217
+ ts_event, ts_init = ts - step, ts
218
+ bars.append(
219
+ Bar(
220
+ bar_type=bar_type,
221
+ ts_event=ts_event,
222
+ ts_init=ts_init,
223
+ open=prices["open"],
224
+ high=prices["high"],
225
+ low=prices["low"],
226
+ close=prices["close"],
227
+ volume=volume,
228
+ )
229
+ )
230
+ bars.sort(key=lambda b: b.ts_init)
231
+ return tuple(bars)
232
+
233
+
234
+ def _import_pandas() -> Any:
235
+ try:
236
+ import pandas # pyright: ignore[reportMissingTypeStubs]
237
+ except ImportError as exc: # pragma: no cover - exercised only without pandas
238
+ raise ImportError(
239
+ "bars_from_dataframe requires pandas — install it with "
240
+ "`pip install 'topstep-backtest[data]'` (or `uv add pandas`), or use "
241
+ "bars_from_records with plain tuples instead"
242
+ ) from exc
243
+ return pandas
244
+
245
+
246
+ def bars_from_dataframe(
247
+ df: Any,
248
+ *,
249
+ contract_id: str,
250
+ spec: InstrumentSpec,
251
+ unit: AggregateBarUnit,
252
+ unit_number: int,
253
+ stamp: Literal["open", "close"],
254
+ ) -> tuple[Bar, ...]:
255
+ """Build ``Bar`` objects from a pandas DataFrame of OHLCV candles.
256
+
257
+ The timestamp may be a column (case-insensitive: timestamp / ts / time /
258
+ datetime / ts_event) or the index — but the index is used ONLY when it is
259
+ a ``DatetimeIndex`` or is named (case-insensitively) after one of those
260
+ timestamp columns. A default ``RangeIndex`` (or any anonymous integer
261
+ index) is rejected: its 0, 1, 2, ... would otherwise be read as epoch
262
+ nanoseconds and every bar would silently land in 1970. OHLCV column names
263
+ are matched case-insensitively. Everything else — stamping,
264
+ naive-timestamp rejection, tick-grid enforcement, sorting — is delegated
265
+ to ``bars_from_records``.
266
+ """
267
+ pd = _import_pandas()
268
+ if not isinstance(df, pd.DataFrame):
269
+ raise TypeError(f"expected a pandas DataFrame, got {type(df).__name__}")
270
+
271
+ by_lower: dict[str, object] = {}
272
+ for column in df.columns:
273
+ by_lower.setdefault(str(column).lower(), column)
274
+
275
+ ts_column = next((by_lower[a] for a in _TS_COLUMN_ALIASES if a in by_lower), None)
276
+ if ts_column is not None:
277
+ ts_values: list[object] = df[ts_column].tolist()
278
+ else:
279
+ index_name = str(df.index.name).lower() if df.index.name is not None else None
280
+ if not isinstance(df.index, pd.DatetimeIndex) and (index_name not in _TS_COLUMN_ALIASES):
281
+ raise ValueError(
282
+ "no timestamp column found and the index "
283
+ f"({type(df.index).__name__}) is neither a DatetimeIndex nor named "
284
+ f"after one of the accepted timestamp columns "
285
+ f"{list(_TS_COLUMN_ALIASES)}; refusing to interpret a plain "
286
+ "integer index as epoch nanoseconds"
287
+ )
288
+ ts_values = df.index.tolist()
289
+
290
+ missing = [f for f in _OHLCV_FIELDS if f not in by_lower]
291
+ if missing:
292
+ raise ValueError(
293
+ f"DataFrame is missing required OHLCV columns {missing} "
294
+ f"(case-insensitive); found columns {[str(c) for c in df.columns]}"
295
+ )
296
+ series: list[list[object]] = [df[by_lower[f]].tolist() for f in _OHLCV_FIELDS]
297
+
298
+ rows = zip(ts_values, *series, strict=True)
299
+ return bars_from_records(
300
+ rows,
301
+ contract_id=contract_id,
302
+ spec=spec,
303
+ unit=unit,
304
+ unit_number=unit_number,
305
+ stamp=stamp,
306
+ )
@@ -0,0 +1 @@
1
+ """topstep_backtest.engine"""
@@ -0,0 +1,209 @@
1
+ """The deterministic backtest engine: one loop, strict time order, no look-ahead.
2
+
3
+ Per bar (the three-phase settle):
4
+ 1. session boundaries due before the bar are enforced (16:10 ET flatten,
5
+ 17:00 ET session close -> MLL ratchet, day roll);
6
+ 2. the SimBroker matches the bar (fills + intrabar rule breaches, in path
7
+ order) and queued user events (on_order/on_fill/on_position) dispatch;
8
+ 3. the strategy sees the completed bar (``on_bar``) and may submit orders,
9
+ which are accepted at the bar's close timestamp — eligible from the NEXT
10
+ bar (the accepted_ts firewall).
11
+
12
+ Determinism: single-threaded, a single monotonic event order, all state
13
+ transitions driven by feed timestamps through the TestClock. Two runs over the
14
+ same inputs produce byte-identical results.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from decimal import Decimal
20
+ from typing import TYPE_CHECKING
21
+
22
+ import msgspec
23
+ from topstep_sdk import HalfTradeModel, OrderModel
24
+
25
+ from ..core.time import TOPSTEP_SESSION, SessionTimes, trading_day_of
26
+ from ..rules.kernel import Breach, DayRecord, Verdict
27
+ from ..strategy.base import Strategy, StrategyContext
28
+
29
+ if TYPE_CHECKING:
30
+ from datetime import date
31
+
32
+ from ..clock.test_clock import TestClock
33
+ from ..execution.sim_broker import SimBroker
34
+ from ..protocols import DataFeed
35
+
36
+ __all__ = ["BacktestEngine", "BacktestResult"]
37
+
38
+
39
+ class BacktestResult(msgspec.Struct, frozen=True):
40
+ """End-of-run outcome (msgspec-serializable -> golden-master friendly)."""
41
+
42
+ verdict: Verdict
43
+ reason: str
44
+ ending_balance: Decimal
45
+ starting_balance: Decimal
46
+ profit_target: Decimal
47
+ floor: Decimal
48
+ best_day: Decimal
49
+ total_profit: Decimal
50
+ days_traded: int
51
+ day_records: tuple[DayRecord, ...]
52
+ breach: Breach | None
53
+ trade_count: int
54
+ equity_curve: tuple[tuple[int, Decimal], ...]
55
+ # Rejected order placements as (gateway error_code, count), ascending by
56
+ # code. Empty on a clean run. A strategy whose orders were all rejected
57
+ # otherwise reports a flawless zero-trade run indistinguishable from one
58
+ # that simply never signalled.
59
+ rejections: tuple[tuple[int, int], ...] = ()
60
+
61
+ @property
62
+ def passed(self) -> bool:
63
+ return self.verdict is Verdict.PASSED
64
+
65
+
66
+ class BacktestEngine:
67
+ """Drives feed -> broker -> strategy under a TestClock."""
68
+
69
+ def __init__(
70
+ self,
71
+ *,
72
+ feed: DataFeed,
73
+ broker: SimBroker,
74
+ strategy: Strategy,
75
+ clock: TestClock,
76
+ session: SessionTimes = TOPSTEP_SESSION,
77
+ ) -> None:
78
+ self._feed = feed
79
+ self._broker = broker
80
+ self._strategy = strategy
81
+ self._clock = clock
82
+ self._session = session
83
+
84
+ async def run(self) -> BacktestResult:
85
+ broker = self._broker
86
+ strategy = self._strategy
87
+ session = self._session
88
+
89
+ strategy.bind(
90
+ StrategyContext.from_broker(
91
+ broker,
92
+ clock=self._clock,
93
+ account_id=broker.account_id,
94
+ instruments=broker.instruments,
95
+ )
96
+ )
97
+ strategy.on_start()
98
+
99
+ equity_curve: list[tuple[int, Decimal]] = []
100
+ current_day: date | None = None
101
+ flattened_today = False
102
+ last_ts = -1
103
+
104
+ try:
105
+ for bar in self._feed:
106
+ # --- no-look-ahead ordering invariant -----------------------
107
+ if bar.ts_init < last_ts:
108
+ raise AssertionError(
109
+ f"feed violated time order: bar ts_init {bar.ts_init} < {last_ts}"
110
+ )
111
+ last_ts = bar.ts_init
112
+
113
+ day = trading_day_of(bar.ts_init)
114
+ if current_day is None:
115
+ current_day = day
116
+ elif day != current_day:
117
+ self._roll_session(current_day, flattened=flattened_today)
118
+ current_day = day
119
+ flattened_today = False
120
+ if broker.dead:
121
+ break
122
+
123
+ # --- 16:10 ET flatten enforcement --------------------------
124
+ # Strictly AFTER the deadline: a bar closing exactly at 16:10
125
+ # still contains tradable prints and must be matched first.
126
+ # The clock lands on the deadline BEFORE the flatten fires so
127
+ # anything a strategy tries from the resulting fill callbacks
128
+ # is correctly rejected as OutsideTradingHours.
129
+ if not flattened_today and bar.ts_init > session.flatten_ns(day):
130
+ flatten_ns = session.flatten_ns(day)
131
+ if flatten_ns > self._clock.now_ns():
132
+ self._clock.advance_to(flatten_ns)
133
+ broker.flatten_all(flatten_ns, reason="eod_flatten")
134
+ flattened_today = True
135
+ await self._dispatch()
136
+
137
+ self._clock.advance_to(bar.ts_init)
138
+
139
+ # --- phase 1: match, then deliver resulting user events ----
140
+ broker.on_bar(bar)
141
+ await self._dispatch()
142
+ if broker.dead:
143
+ equity_curve.append((bar.ts_init, broker.equity()))
144
+ break
145
+
146
+ # --- phase 2: strategy acts on the completed bar -----------
147
+ await strategy.handle_bar(bar)
148
+ await self._dispatch()
149
+
150
+ equity_curve.append((bar.ts_init, broker.equity()))
151
+
152
+ if current_day is not None and not broker.dead:
153
+ self._roll_session(current_day, flattened=flattened_today)
154
+ finally:
155
+ strategy.on_stop()
156
+
157
+ kernel = broker.kernel
158
+ reason = self._reason(kernel.verdict, kernel.breach)
159
+ return BacktestResult(
160
+ verdict=kernel.verdict,
161
+ reason=reason,
162
+ ending_balance=broker.balance,
163
+ starting_balance=kernel.params.starting_balance,
164
+ profit_target=kernel.params.profit_target,
165
+ floor=kernel.floor,
166
+ best_day=kernel.best_day,
167
+ total_profit=kernel.total_profit,
168
+ days_traded=kernel.days_traded,
169
+ day_records=kernel.day_records,
170
+ breach=kernel.breach,
171
+ trade_count=len(broker.trades),
172
+ equity_curve=tuple(equity_curve),
173
+ rejections=tuple(sorted(broker.rejections.items())),
174
+ )
175
+
176
+ def _roll_session(self, day: date, *, flattened: bool) -> None:
177
+ """End the trading day: enforce the flatten backstop, then snapshot the
178
+
179
+ closed balance for the kernel's EOD-MLL ratchet at 17:00 ET. The clock
180
+ lands on each boundary before its action so no callback can observe a
181
+ stale (pre-16:10) time.
182
+ """
183
+ if not flattened:
184
+ flatten_ns = self._session.flatten_ns(day)
185
+ if flatten_ns > self._clock.now_ns():
186
+ self._clock.advance_to(flatten_ns)
187
+ self._broker.flatten_all(flatten_ns, reason="eod_flatten")
188
+ close_ns = self._session.close_ns(day)
189
+ if close_ns > self._clock.now_ns():
190
+ self._clock.advance_to(close_ns)
191
+ self._broker.session_close(close_ns)
192
+
193
+ async def _dispatch(self) -> None:
194
+ """Deliver queued user-hub-shaped events to the strategy, in order."""
195
+ for event in self._broker.drain_events():
196
+ if isinstance(event, OrderModel):
197
+ await self._strategy.handle_order(event)
198
+ elif isinstance(event, HalfTradeModel):
199
+ await self._strategy.handle_fill(event)
200
+ else:
201
+ await self._strategy.handle_position(event)
202
+
203
+ @staticmethod
204
+ def _reason(verdict: Verdict, breach: Breach | None) -> str:
205
+ if verdict is Verdict.PASSED:
206
+ return "profit target reached with consistency satisfied"
207
+ if verdict is Verdict.FAILED and breach is not None:
208
+ return f"maximum loss limit breached (equity {breach.equity} <= floor {breach.limit})"
209
+ return "combine still in progress at end of data"
@@ -0,0 +1 @@
1
+ """topstep_backtest.execution"""
@@ -0,0 +1,53 @@
1
+ """Gateway-parity domain rejections.
2
+
3
+ The SimBroker surfaces every rejection as the SDK's ``APIError`` with the same
4
+ numeric ``error_code`` the real gateway would use, so ``except APIError`` code
5
+ paths (and ``e.error_code`` branching) behave identically in sim and live.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import NoReturn
11
+
12
+ from topstep_sdk import APIError
13
+ from topstep_sdk.enums import (
14
+ CANCEL_ORDER_ERRORS,
15
+ MODIFY_ORDER_ERRORS,
16
+ PLACE_ORDER_ERRORS,
17
+ error_code_name,
18
+ )
19
+
20
+ __all__ = [
21
+ "UnsupportedInBacktestError",
22
+ "reject_cancel",
23
+ "reject_modify",
24
+ "reject_place",
25
+ ]
26
+
27
+
28
+ class UnsupportedInBacktestError(RuntimeError):
29
+ """A live-only usage pattern that has no deterministic backtest equivalent.
30
+
31
+ Raised instead of silently diverging (e.g. ``wait_for_fill`` busy-polling,
32
+ which cannot advance a deterministic TestClock). The message names the
33
+ parity-safe alternative.
34
+ """
35
+
36
+
37
+ def reject_place(code: int, detail: str = "") -> NoReturn:
38
+ """Raise the place-order rejection the gateway would return."""
39
+ name = error_code_name(PLACE_ORDER_ERRORS, code)
40
+ message = f"{name}: {detail}" if detail else name
41
+ raise APIError(message, error_code=code)
42
+
43
+
44
+ def reject_modify(code: int, detail: str = "") -> NoReturn:
45
+ name = error_code_name(MODIFY_ORDER_ERRORS, code)
46
+ message = f"{name}: {detail}" if detail else name
47
+ raise APIError(message, error_code=code)
48
+
49
+
50
+ def reject_cancel(code: int, detail: str = "") -> NoReturn:
51
+ name = error_code_name(CANCEL_ORDER_ERRORS, code)
52
+ message = f"{name}: {detail}" if detail else name
53
+ raise APIError(message, error_code=code)