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,344 @@
1
+ """Single-contract ergonomic strategy base (the backtesting.py-style dialect).
2
+
3
+ ``SymbolStrategy`` narrows the raw ``Strategy`` seam to one contract: events
4
+ for other instruments never reach the ``on_*`` hooks, ``use()``-registered
5
+ indicators are auto-updated once per matching bar (registration order ==
6
+ update order), and ``on_bar`` is gated until every indicator is ready. The
7
+ position/working-order views fold ONLY the SDK models both brokers emit (plus
8
+ a submit-time latch of each sugar-placed order id, superseded by its first
9
+ real event), and the order sugar targets the ``ctx`` protocol surfaces with
10
+ rejections routed to ``on_reject`` — a rejection is normal combine control
11
+ flow (position cap,
12
+ no-trade window), not an exception. The raw raising path remains
13
+ ``self.ctx.orders``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from decimal import Decimal
19
+ from typing import cast
20
+
21
+ from topstep_sdk import (
22
+ APIError,
23
+ HalfTradeModel,
24
+ OrderModel,
25
+ OrderSide,
26
+ OrderStatus,
27
+ OrderType,
28
+ PositionModel,
29
+ )
30
+
31
+ from ..core.instruments import InstrumentSpec
32
+ from ..indicators import Cross, Indicator, TalibLine, ValueSource
33
+ from ..protocols import Bar
34
+ from .base import Strategy
35
+ from .tracker import NetPosition, OrderTracker
36
+
37
+ __all__ = ["SymbolStrategy"]
38
+
39
+
40
+ def _implied_type(limit_price: Decimal | None, stop_price: Decimal | None) -> OrderType:
41
+ """The order type the price kwargs imply.
42
+
43
+ Both together would imply STOP_LIMIT, which the Tier-0 sim rejects while
44
+ live places — a strategy validated in sim would carry a silently dead
45
+ branch — so the combination is refused loudly at the seam.
46
+ """
47
+ if limit_price is not None and stop_price is not None:
48
+ raise ValueError(
49
+ "limit_price and stop_price together imply STOP_LIMIT, which the "
50
+ "Tier-0 sim cannot fill (sim would reject what live would place); "
51
+ "place it explicitly via self.ctx.orders if you mean it"
52
+ )
53
+ if limit_price is not None:
54
+ return OrderType.LIMIT
55
+ if stop_price is not None:
56
+ return OrderType.STOP
57
+ return OrderType.MARKET
58
+
59
+
60
+ def _owner_of(source: object) -> object:
61
+ """A ``Cross`` input's registerable identity: a line resolves to its owner."""
62
+ return source.owner if isinstance(source, TalibLine) else source
63
+
64
+
65
+ def _check_cross_inputs(cross: Cross, registered_ids: set[int]) -> None:
66
+ """Both hazards a ``Cross`` can be wired into, refused at registration.
67
+
68
+ Inputs are viewed as plain objects because the checks exist for callers the
69
+ type system never saw — a ``Cross`` over another ``Cross`` type-checks (a
70
+ Cross has lookback/ready) but has no ``.value``, and would die with an
71
+ ``AttributeError`` only on the first bar both inner inputs happened to be
72
+ ready, potentially hours into a run.
73
+ """
74
+ for source in cast("tuple[object, object]", cross.inputs):
75
+ if not isinstance(source, ValueSource):
76
+ raise ValueError(
77
+ f"Cross input {source!r} exposes no `.value`, so the Cross would "
78
+ f"die mid-run on the first bar both inputs are ready"
79
+ )
80
+ if id(_owner_of(source)) not in registered_ids:
81
+ raise ValueError(
82
+ "Cross inputs must be use()-registered before the Cross "
83
+ "that reads them (update the Cross manually if an input "
84
+ "is maintained outside use())"
85
+ )
86
+
87
+
88
+ def _require_indicator(candidate: object) -> None:
89
+ """Reject a non-``Indicator`` at registration rather than on the first bar.
90
+
91
+ Typed callers cannot reach this (``use`` is bound to ``Indicator``), but an
92
+ untyped one would otherwise get a bare ``AttributeError`` a bar later.
93
+ """
94
+ if not isinstance(candidate, Indicator):
95
+ raise TypeError(
96
+ f"{candidate!r} is not an Indicator (needs lookback/ready/update); "
97
+ f"registering it would fail on the first bar instead of here"
98
+ )
99
+
100
+
101
+ class SymbolStrategy(Strategy):
102
+ """Base for strategies trading exactly one contract.
103
+
104
+ Subclasses register indicators with ``use()`` in ``__init__`` and override
105
+ ``on_bar`` — invoked only for ``contract_id``, only once every registered
106
+ indicator is ready (and, when ``warmup`` is explicitly overridden, at
107
+ least that many matching bars have been seen; an explicit ``warmup``
108
+ gates even under ``require_ready=False``), with every indicator already
109
+ updated for that bar. ``position`` and ``working_orders`` are folded from
110
+ the same user events live emits, and ``buy``/``sell`` latch the returned
111
+ order id into ``working_orders`` at submit time — so entry guards like
112
+ ``position.flat and not self.working_orders`` hold even when live hub
113
+ confirmations lag the REST return past the next bar.
114
+
115
+ One known hook-cadence gap: the sim emits no ``PositionModel`` event on a
116
+ full close (live sends a closed/size-0 snapshot), so detect flatness from
117
+ ``position.flat`` in ``on_fill`` — never by overriding ``on_position``.
118
+ The views themselves stay correct on both sides.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ contract_id: str,
124
+ *,
125
+ require_ready: bool = True,
126
+ warmup: int | None = None,
127
+ ) -> None:
128
+ self.contract_id = contract_id
129
+ self._require_ready = require_ready
130
+ self._warmup_override = warmup
131
+ self._indicators: list[Indicator] = []
132
+ self._position = NetPosition()
133
+ self._orders = OrderTracker()
134
+ self._bars_seen = 0
135
+ self._bars_gated = 0
136
+
137
+ def use[T: Indicator](self, indicator: T) -> T:
138
+ """Register an indicator: auto-updated on every matching bar and
139
+
140
+ counted toward the ready-gate. Registration order == update order —
141
+ ``use()`` a ``Cross``'s inputs BEFORE the ``Cross`` that reads them
142
+ (enforced: a ``Cross`` with unregistered inputs would read one-bar-
143
+ stale values with no error, so it is refused here instead). A
144
+ ``TalibLine`` input counts as registered once the indicator that OWNS
145
+ it is, since one owner computes all of its lines together.
146
+ """
147
+ if isinstance(indicator, TalibLine):
148
+ raise ValueError(
149
+ "a TalibLine cannot be use()-registered — it has no state of "
150
+ "its own and would never advance; register the indicator that "
151
+ "owns it and keep the line only as a Cross input"
152
+ )
153
+ _require_indicator(indicator)
154
+ if isinstance(indicator, Cross):
155
+ _check_cross_inputs(indicator, {id(known) for known in self._indicators})
156
+ indicator.mark_registered()
157
+ self._indicators.append(indicator)
158
+ return indicator
159
+
160
+ @property
161
+ def warmup(self) -> int:
162
+ """Declared warmup: the explicit override, else the max indicator lookback."""
163
+ if self._warmup_override is not None:
164
+ return self._warmup_override
165
+ return max((indicator.lookback for indicator in self._indicators), default=0)
166
+
167
+ @property
168
+ def position(self) -> NetPosition:
169
+ """This contract's net position, folded from fills and snapshots."""
170
+ return self._position
171
+
172
+ @property
173
+ def working_orders(self) -> tuple[OrderModel, ...]:
174
+ """Non-terminal orders on this contract, ascending order id."""
175
+ return self._orders.working_for(self.contract_id)
176
+
177
+ @property
178
+ def spec(self) -> InstrumentSpec:
179
+ """Instrument economics for this contract."""
180
+ return self.ctx.instrument(self.contract_id)
181
+
182
+ @property
183
+ def bars_gated(self) -> int:
184
+ """Matching bars swallowed by the ready-gate so far."""
185
+ return self._bars_gated
186
+
187
+ async def handle_bar(self, bar: Bar) -> None:
188
+ if bar.bar_type.contract_id != self.contract_id:
189
+ return
190
+ for indicator in self._indicators:
191
+ indicator.update(bar)
192
+ self._bars_seen += 1
193
+ if self._gated():
194
+ self._bars_gated += 1
195
+ return
196
+ await self.on_bar(bar)
197
+
198
+ def _gated(self) -> bool:
199
+ # An explicit warmup override gates even when require_ready is off —
200
+ # the declared warmup must never silently understate the actual gate.
201
+ if self._warmup_override is not None and self._bars_seen < self._warmup_override:
202
+ return True
203
+ return self._require_ready and any(not indicator.ready for indicator in self._indicators)
204
+
205
+ async def handle_order(self, order: OrderModel) -> None:
206
+ if order.contract_id != self.contract_id:
207
+ return
208
+ self._orders.apply(order)
209
+ await self.on_order(order)
210
+
211
+ async def handle_fill(self, trade: HalfTradeModel) -> None:
212
+ if trade.contract_id != self.contract_id:
213
+ return
214
+ self._position.apply_fill(trade)
215
+ await self.on_fill(trade)
216
+
217
+ async def handle_position(self, position: PositionModel) -> None:
218
+ if position.contract_id != self.contract_id:
219
+ return
220
+ self._position.apply_snapshot(position)
221
+ await self.on_position(position)
222
+
223
+ async def on_reject(self, error: APIError) -> None:
224
+ """Called with the ``APIError`` when a sugar order call is rejected.
225
+
226
+ Default: ignore (the sugar call returns ``None``).
227
+ """
228
+
229
+ async def buy(
230
+ self,
231
+ size: int,
232
+ *,
233
+ stop_loss_ticks: int | None = None,
234
+ take_profit_ticks: int | None = None,
235
+ limit_price: Decimal | None = None,
236
+ stop_price: Decimal | None = None,
237
+ custom_tag: str | None = None,
238
+ ) -> int | None:
239
+ """Buy this contract (market unless a price kwarg implies otherwise);
240
+
241
+ returns the order id — latched into ``working_orders`` at submit time —
242
+ or ``None`` on rejection (see ``on_reject``).
243
+ """
244
+ order_type = _implied_type(limit_price, stop_price)
245
+ try:
246
+ order_id = await self.ctx.orders.buy(
247
+ self.ctx.account_id,
248
+ self.contract_id,
249
+ size,
250
+ type=order_type,
251
+ limit_price=limit_price,
252
+ stop_price=stop_price,
253
+ custom_tag=custom_tag,
254
+ stop_loss_ticks=stop_loss_ticks,
255
+ take_profit_ticks=take_profit_ticks,
256
+ )
257
+ except APIError as error:
258
+ await self.on_reject(error)
259
+ return None
260
+ self._latch(order_id, OrderSide.BUY, order_type, size, limit_price, stop_price, custom_tag)
261
+ return order_id
262
+
263
+ async def sell(
264
+ self,
265
+ size: int,
266
+ *,
267
+ stop_loss_ticks: int | None = None,
268
+ take_profit_ticks: int | None = None,
269
+ limit_price: Decimal | None = None,
270
+ stop_price: Decimal | None = None,
271
+ custom_tag: str | None = None,
272
+ ) -> int | None:
273
+ """Sell this contract (market unless a price kwarg implies otherwise);
274
+
275
+ returns the order id — latched into ``working_orders`` at submit time —
276
+ or ``None`` on rejection (see ``on_reject``).
277
+ """
278
+ order_type = _implied_type(limit_price, stop_price)
279
+ try:
280
+ order_id = await self.ctx.orders.sell(
281
+ self.ctx.account_id,
282
+ self.contract_id,
283
+ size,
284
+ type=order_type,
285
+ limit_price=limit_price,
286
+ stop_price=stop_price,
287
+ custom_tag=custom_tag,
288
+ stop_loss_ticks=stop_loss_ticks,
289
+ take_profit_ticks=take_profit_ticks,
290
+ )
291
+ except APIError as error:
292
+ await self.on_reject(error)
293
+ return None
294
+ self._latch(order_id, OrderSide.SELL, order_type, size, limit_price, stop_price, custom_tag)
295
+ return order_id
296
+
297
+ def _latch(
298
+ self,
299
+ order_id: int,
300
+ side: OrderSide,
301
+ order_type: OrderType,
302
+ size: int,
303
+ limit_price: Decimal | None,
304
+ stop_price: Decimal | None,
305
+ custom_tag: str | None,
306
+ ) -> None:
307
+ """Fold a synthetic PENDING order for a just-submitted id, so the
308
+
309
+ working view is truthful the instant the REST call returns — live, the
310
+ hub ``OrderModel`` may lag past the next bar's ``on_bar``. The first
311
+ real event for the id supersedes (or, if terminal, pre-empts) it.
312
+ """
313
+ self._orders.latch(
314
+ OrderModel(
315
+ id=order_id,
316
+ account_id=self.ctx.account_id,
317
+ contract_id=self.contract_id,
318
+ status=OrderStatus.PENDING,
319
+ type=order_type,
320
+ side=side,
321
+ size=size,
322
+ limit_price=limit_price,
323
+ stop_price=stop_price,
324
+ custom_tag=custom_tag,
325
+ )
326
+ )
327
+
328
+ async def close(self) -> None:
329
+ """Flatten this contract's position; a rejection goes to ``on_reject``."""
330
+ try:
331
+ await self.ctx.positions.close(self.ctx.account_id, self.contract_id)
332
+ except APIError as error:
333
+ await self.on_reject(error)
334
+
335
+ async def cancel_working(self) -> None:
336
+ """Cancel every working order on this contract, one cancel per order;
337
+
338
+ each rejection goes to ``on_reject`` and the remaining cancels proceed.
339
+ """
340
+ for order in self.working_orders:
341
+ try:
342
+ await self.ctx.orders.cancel(self.ctx.account_id, order.id)
343
+ except APIError as error:
344
+ await self.on_reject(error)
@@ -0,0 +1,151 @@
1
+ """Pure event-fold state trackers over the SDK's user-event models.
2
+
3
+ Everything here folds ONLY ``topstep_sdk`` models (``HalfTradeModel``,
4
+ ``PositionModel``, ``OrderModel``) — the exact payloads BOTH the ``SimBroker``
5
+ and the live user hub emit — so a strategy's view of its own position and
6
+ working orders is built identically in sim and live. That is the parity
7
+ argument: no broker internals, no engine imports, just event folding.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from topstep_sdk import (
13
+ HalfTradeModel,
14
+ OrderModel,
15
+ OrderSide,
16
+ OrderStatus,
17
+ PositionModel,
18
+ PositionType,
19
+ )
20
+
21
+ __all__ = [
22
+ "TERMINAL_ORDER_STATUSES",
23
+ "NetPosition",
24
+ "OrderTracker",
25
+ "PositionTracker",
26
+ ]
27
+
28
+ TERMINAL_ORDER_STATUSES: frozenset[OrderStatus] = frozenset(
29
+ {
30
+ OrderStatus.FILLED,
31
+ OrderStatus.CANCELLED,
32
+ OrderStatus.EXPIRED,
33
+ OrderStatus.REJECTED,
34
+ }
35
+ )
36
+ """Statuses after which an order can never execute again. Must match the sim
37
+ broker's terminal set (asserted in tests) so ``OrderTracker.working`` mirrors
38
+ ``orders.search_open()`` in both sim and live."""
39
+
40
+
41
+ class NetPosition:
42
+ """Signed net contracts for one instrument (positive = long).
43
+
44
+ ``apply_fill`` is the incremental path (one ``GatewayUserTrade`` at a
45
+ time); ``apply_snapshot`` is the authoritative reconcile — a broker
46
+ ``PositionModel`` overrides any fill-derived drift.
47
+
48
+ Folding assumes per-contract events arrive in execution order (each trade
49
+ before the snapshot that already includes it) — the sim guarantees this;
50
+ a live driver must serialize the trade/position hub callbacks per
51
+ contract. Neither model carries a sequence field, so a snapshot that
52
+ outruns its own trade double-counts that trade until the next snapshot
53
+ reconciles.
54
+ """
55
+
56
+ __slots__ = ("net",)
57
+
58
+ def __init__(self, net: int = 0) -> None:
59
+ self.net = net
60
+
61
+ @property
62
+ def is_long(self) -> bool:
63
+ return self.net > 0
64
+
65
+ @property
66
+ def is_short(self) -> bool:
67
+ return self.net < 0
68
+
69
+ @property
70
+ def flat(self) -> bool:
71
+ return self.net == 0
72
+
73
+ def __bool__(self) -> bool:
74
+ return self.net != 0
75
+
76
+ def __repr__(self) -> str:
77
+ return f"NetPosition(net={self.net})"
78
+
79
+ def apply_fill(self, trade: HalfTradeModel) -> None:
80
+ if trade.voided: # a busted trade never changed the position
81
+ return
82
+ direction = 1 if trade.side == OrderSide.BID else -1 # BUY==BID==0
83
+ self.net += direction * trade.size
84
+
85
+ def apply_snapshot(self, position: PositionModel) -> None:
86
+ if position.type == PositionType.LONG:
87
+ self.net = position.size
88
+ elif position.type == PositionType.SHORT:
89
+ self.net = -position.size
90
+ else: # closed/undefined snapshot: flat
91
+ self.net = 0
92
+
93
+
94
+ class PositionTracker:
95
+ """Per-contract ``NetPosition``s, routed by ``contract_id``."""
96
+
97
+ __slots__ = ("_by_contract",)
98
+
99
+ def __init__(self) -> None:
100
+ self._by_contract: dict[str, NetPosition] = {}
101
+
102
+ def net(self, contract_id: str) -> NetPosition:
103
+ found = self._by_contract.get(contract_id)
104
+ if found is None:
105
+ found = NetPosition()
106
+ self._by_contract[contract_id] = found
107
+ return found
108
+
109
+ def apply_fill(self, trade: HalfTradeModel) -> None:
110
+ self.net(trade.contract_id).apply_fill(trade)
111
+
112
+ def apply_snapshot(self, position: PositionModel) -> None:
113
+ self.net(position.contract_id).apply_snapshot(position)
114
+
115
+
116
+ class OrderTracker:
117
+ """Latest ``OrderModel`` per order id, folded from ``on_order`` events.
118
+
119
+ ``working`` mirrors what ``orders.search_open()`` would return once all
120
+ queued events are drained — without a broker round-trip. Terminal orders
121
+ are pruned on arrival (only their ids are retained), so a long live
122
+ session holds state proportional to its working orders, not its history.
123
+ """
124
+
125
+ __slots__ = ("_by_id", "_terminal_ids")
126
+
127
+ def __init__(self) -> None:
128
+ self._by_id: dict[int, OrderModel] = {}
129
+ self._terminal_ids: set[int] = set()
130
+
131
+ def apply(self, order: OrderModel) -> None:
132
+ if order.status in TERMINAL_ORDER_STATUSES:
133
+ self._by_id.pop(order.id, None)
134
+ self._terminal_ids.add(order.id)
135
+ elif order.id not in self._terminal_ids: # reordered stale event: never resurrect
136
+ self._by_id[order.id] = order
137
+
138
+ def latch(self, order: OrderModel) -> None:
139
+ """Fold a submit-time synthetic order, closing the live window between
140
+ the REST return and the hub confirmation; any already-seen event for
141
+ the id (the hub can outrun the REST response) wins over the synthetic.
142
+ """
143
+ if order.id not in self._terminal_ids and order.id not in self._by_id:
144
+ self._by_id[order.id] = order
145
+
146
+ @property
147
+ def working(self) -> tuple[OrderModel, ...]:
148
+ return tuple(order for _, order in sorted(self._by_id.items()))
149
+
150
+ def working_for(self, contract_id: str) -> tuple[OrderModel, ...]:
151
+ return tuple(order for order in self.working if order.contract_id == contract_id)