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.
- topstep_backtest/__init__.py +43 -0
- topstep_backtest/clock/__init__.py +1 -0
- topstep_backtest/clock/live_clock.py +82 -0
- topstep_backtest/clock/test_clock.py +133 -0
- topstep_backtest/core/__init__.py +1 -0
- topstep_backtest/core/ids.py +23 -0
- topstep_backtest/core/instruments.py +167 -0
- topstep_backtest/core/money.py +160 -0
- topstep_backtest/core/time.py +125 -0
- topstep_backtest/data/__init__.py +1 -0
- topstep_backtest/data/clean.py +86 -0
- topstep_backtest/data/feed.py +56 -0
- topstep_backtest/data/synthetic.py +137 -0
- topstep_backtest/data/validator.py +215 -0
- topstep_backtest/data/wrangler.py +306 -0
- topstep_backtest/engine/__init__.py +1 -0
- topstep_backtest/engine/backtest.py +209 -0
- topstep_backtest/execution/__init__.py +1 -0
- topstep_backtest/execution/rejections.py +53 -0
- topstep_backtest/execution/sim_broker.py +1436 -0
- topstep_backtest/fills/__init__.py +1 -0
- topstep_backtest/fills/bar_fill.py +268 -0
- topstep_backtest/fills/fees.py +120 -0
- topstep_backtest/fills/path.py +59 -0
- topstep_backtest/harness.py +446 -0
- topstep_backtest/indicators/__init__.py +46 -0
- topstep_backtest/indicators/base.py +57 -0
- topstep_backtest/indicators/library.py +303 -0
- topstep_backtest/indicators/talib_adapter.py +657 -0
- topstep_backtest/metrics/__init__.py +5 -0
- topstep_backtest/metrics/stats.py +153 -0
- topstep_backtest/protocols.py +473 -0
- topstep_backtest/py.typed +0 -0
- topstep_backtest/rules/__init__.py +1 -0
- topstep_backtest/rules/kernel.py +281 -0
- topstep_backtest/rules/params.py +74 -0
- topstep_backtest/strategy/__init__.py +20 -0
- topstep_backtest/strategy/base.py +118 -0
- topstep_backtest/strategy/symbol.py +344 -0
- topstep_backtest/strategy/tracker.py +151 -0
- topstep_backtest-0.1.0.dist-info/METADATA +250 -0
- topstep_backtest-0.1.0.dist-info/RECORD +44 -0
- topstep_backtest-0.1.0.dist-info/WHEEL +4 -0
- 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)
|