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,281 @@
1
+ """The Topstep Combine rule kernel — the canonical combine-rulebook state machine.
2
+
3
+ A PURE state machine (docs/topstep-rules.md §§1-5): no I/O, no clock reads, no
4
+ randomness. Callers push int-ns UTC timestamps and exact ``Decimal`` balances
5
+ in; the kernel answers with breaches and a verdict.
6
+
7
+ The single biggest correctness item is the **two-state trailing Maximum Loss
8
+ Limit** (§2):
9
+
10
+ * State A — floor ratchet, END OF DAY ONLY: the floor starts at
11
+ ``starting_balance - mll_buffer`` and ratchets up only on end-of-day CLOSED
12
+ balance, never intraday, never down. Once the ratcheted floor would reach
13
+ the starting balance it **locks there permanently**.
14
+ * State B — breach check, REAL TIME: every tick the caller pushes live equity
15
+ (realized + unrealized) into :meth:`CombineKernel.check_equity`; touching
16
+ the floor (``equity <= floor``) fails the account immediately (terminal).
17
+
18
+ The optional Daily Loss Limit (§3) is a same-day lockout, not a violation:
19
+ tripping it returns a :class:`Breach` of kind ``DLL`` and sets ``day_locked``
20
+ until the next session close. An MLL breach takes precedence when both trip
21
+ on the same tick. The stored ``breach`` property holds only the terminal MLL
22
+ breach; DLL breaches are returned to the caller but never stored.
23
+
24
+ Breach ``limit`` semantics: for MLL it is the floor; for DLL it is the equity
25
+ threshold ``day_start_balance - dll`` (the level at which the day locks).
26
+
27
+ Pass evaluation (§§1, 4) happens only in :meth:`CombineKernel.on_session_close`
28
+ and only while IN_PROGRESS: the closed balance must reach
29
+ ``starting_balance + profit_target``, total profit must be positive, and the
30
+ best traded day must satisfy ``best_day <= consistency_pct * total_profit``
31
+ (Topstep's target inflation: a too-big best day forces more total profit,
32
+ implying the ~2-day minimum). ``best_day`` only considers days on which
33
+ :meth:`CombineKernel.on_trade_activity` was called; losing days never reset it.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from datetime import date
39
+ from decimal import Decimal
40
+ from enum import IntEnum
41
+
42
+ import msgspec
43
+
44
+ from ..core.time import trading_day_of
45
+ from .params import CombineParams
46
+
47
+ __all__ = ["Breach", "BreachKind", "CombineKernel", "DayRecord", "Verdict"]
48
+
49
+
50
+ class Verdict(IntEnum):
51
+ """Combine outcome. Transitions only ``IN_PROGRESS -> {PASSED, FAILED}``."""
52
+
53
+ IN_PROGRESS = 0
54
+ PASSED = 1
55
+ FAILED = 2
56
+
57
+
58
+ class BreachKind(IntEnum):
59
+ """Which limit was breached."""
60
+
61
+ MLL = 1
62
+ DLL = 2
63
+
64
+
65
+ class Breach(msgspec.Struct, frozen=True):
66
+ """A limit breach at ``ts_ns``. ``limit`` is the equity threshold breached:
67
+
68
+ the trailing floor for MLL, ``day_start_balance - dll`` for DLL.
69
+ """
70
+
71
+ kind: BreachKind
72
+ ts_ns: int
73
+ equity: Decimal
74
+ limit: Decimal
75
+
76
+
77
+ class DayRecord(msgspec.Struct, frozen=True):
78
+ """One closed trading day. ``floor_after`` is the MLL floor in effect after
79
+
80
+ this close's end-of-day ratchet; ``had_trade`` records whether trade
81
+ activity occurred during the day.
82
+ """
83
+
84
+ day: date
85
+ eod_balance: Decimal
86
+ day_pnl: Decimal
87
+ floor_after: Decimal
88
+ had_trade: bool
89
+
90
+
91
+ _ZERO = Decimal("0")
92
+
93
+
94
+ class CombineKernel:
95
+ """The single canonical implementation of the Topstep Combine rulebook.
96
+
97
+ Drive it with three calls: :meth:`on_trade_activity` when a fill happens,
98
+ :meth:`check_equity` on every tick with live equity (realized +
99
+ unrealized), and :meth:`on_session_close` at the 17:00 ET Globex close
100
+ with the day's closed balance. Once the verdict is terminal (PASSED or
101
+ FAILED) all mutating calls become no-ops.
102
+ """
103
+
104
+ __slots__ = (
105
+ "_best_day",
106
+ "_breach",
107
+ "_day_locked",
108
+ "_day_records",
109
+ "_day_start_balance",
110
+ "_days_traded",
111
+ "_floor",
112
+ "_had_trade",
113
+ "_last_closed_balance",
114
+ "_locked",
115
+ "_params",
116
+ "_peak_eod",
117
+ "_verdict",
118
+ )
119
+
120
+ def __init__(self, params: CombineParams) -> None:
121
+ self._params = params
122
+ self._floor = params.starting_balance - params.mll_buffer
123
+ self._peak_eod = params.starting_balance
124
+ self._locked = False
125
+ self._verdict = Verdict.IN_PROGRESS
126
+ self._breach: Breach | None = None
127
+ self._best_day = _ZERO
128
+ self._days_traded = 0
129
+ self._day_records: list[DayRecord] = []
130
+ self._day_start_balance = params.starting_balance
131
+ self._last_closed_balance = params.starting_balance
132
+ self._had_trade = False
133
+ self._day_locked = False
134
+
135
+ # -- read-only state ---------------------------------------------------
136
+
137
+ @property
138
+ def params(self) -> CombineParams:
139
+ return self._params
140
+
141
+ @property
142
+ def floor(self) -> Decimal:
143
+ """The current MLL floor (equity at/below this level is a fail)."""
144
+ return self._floor
145
+
146
+ @property
147
+ def locked(self) -> bool:
148
+ """Whether the floor has permanently locked at the starting balance."""
149
+ return self._locked
150
+
151
+ @property
152
+ def verdict(self) -> Verdict:
153
+ return self._verdict
154
+
155
+ @property
156
+ def best_day(self) -> Decimal:
157
+ """Largest ``day_pnl`` over traded days so far (never below zero)."""
158
+ return self._best_day
159
+
160
+ @property
161
+ def total_profit(self) -> Decimal:
162
+ """Last end-of-day closed balance minus the starting balance."""
163
+ return self._last_closed_balance - self._params.starting_balance
164
+
165
+ @property
166
+ def days_traded(self) -> int:
167
+ """Number of closed days on which trade activity occurred."""
168
+ return self._days_traded
169
+
170
+ @property
171
+ def day_records(self) -> tuple[DayRecord, ...]:
172
+ return tuple(self._day_records)
173
+
174
+ @property
175
+ def breach(self) -> Breach | None:
176
+ """The terminal MLL breach, if the combine has failed."""
177
+ return self._breach
178
+
179
+ @property
180
+ def day_start_balance(self) -> Decimal:
181
+ """Balance at the current trading day's start (prior session's close)."""
182
+ return self._day_start_balance
183
+
184
+ @property
185
+ def day_locked(self) -> bool:
186
+ """Whether the DLL has locked out trading for the rest of the day."""
187
+ return self._day_locked
188
+
189
+ # -- transitions -------------------------------------------------------
190
+
191
+ def on_trade_activity(self, ts_ns: int) -> None:
192
+ """Mark that a trade happened; makes the current day a traded day."""
193
+ if self._verdict is not Verdict.IN_PROGRESS:
194
+ return
195
+ self._had_trade = True
196
+
197
+ def check_equity(self, ts_ns: int, equity: Decimal) -> Breach | None:
198
+ """Real-time breach check on live equity (realized + unrealized).
199
+
200
+ Touching the MLL floor (``equity <= floor``) fails the account
201
+ terminally and returns (and stores) the MLL breach. Otherwise, with a
202
+ DLL configured, a day loss at/past the limit returns a DLL breach and
203
+ sets ``day_locked`` (not terminal; MLL takes precedence when both
204
+ trip). Once terminal, returns the stored breach without mutating.
205
+ """
206
+ if self._verdict is not Verdict.IN_PROGRESS:
207
+ return self._breach
208
+ if equity <= self._floor:
209
+ breach = Breach(kind=BreachKind.MLL, ts_ns=ts_ns, equity=equity, limit=self._floor)
210
+ self._breach = breach
211
+ self._verdict = Verdict.FAILED
212
+ return breach
213
+ dll = self._params.dll
214
+ if dll is not None and not self._day_locked:
215
+ dll_floor = self._day_start_balance - dll
216
+ if equity <= dll_floor:
217
+ self._day_locked = True
218
+ return Breach(kind=BreachKind.DLL, ts_ns=ts_ns, equity=equity, limit=dll_floor)
219
+ return None
220
+
221
+ def on_session_close(self, ts_ns: int, closed_balance: Decimal) -> None:
222
+ """Close the trading day at ``closed_balance`` (the 17:00 ET snapshot).
223
+
224
+ Closes the day's record first (``day_pnl`` against the day's starting
225
+ balance), then applies the end-of-day floor ratchet, then evaluates
226
+ the pass condition, then rolls day state (DLL lockout clears; the next
227
+ day's starting balance becomes ``closed_balance``). No-op once the
228
+ verdict is terminal.
229
+ """
230
+ if self._verdict is not Verdict.IN_PROGRESS:
231
+ return
232
+ params = self._params
233
+ if closed_balance <= self._floor:
234
+ # A closed balance at/below the floor is an MLL breach even if no
235
+ # intraday check ever observed it (e.g. flatten fees dropped the
236
+ # balance after the last equity check of the day).
237
+ self._breach = Breach(
238
+ kind=BreachKind.MLL, ts_ns=ts_ns, equity=closed_balance, limit=self._floor
239
+ )
240
+ self._verdict = Verdict.FAILED
241
+ return
242
+ day_pnl = closed_balance - self._day_start_balance
243
+ had_trade = self._had_trade
244
+ if had_trade:
245
+ self._days_traded += 1
246
+ if day_pnl > self._best_day:
247
+ self._best_day = day_pnl
248
+ # End-of-day ratchet: closed balance only, never intraday, never down.
249
+ if not self._locked and closed_balance > self._peak_eod:
250
+ self._peak_eod = closed_balance
251
+ new_floor = closed_balance - params.mll_buffer
252
+ if new_floor >= params.starting_balance:
253
+ self._floor = params.starting_balance
254
+ self._locked = True # permanent
255
+ else:
256
+ self._floor = new_floor
257
+ self._day_records.append(
258
+ DayRecord(
259
+ day=trading_day_of(ts_ns),
260
+ eod_balance=closed_balance,
261
+ day_pnl=day_pnl,
262
+ floor_after=self._floor,
263
+ had_trade=had_trade,
264
+ )
265
+ )
266
+ self._last_closed_balance = closed_balance
267
+ total_profit = closed_balance - params.starting_balance
268
+ if (
269
+ closed_balance >= params.starting_balance + params.profit_target
270
+ and total_profit > _ZERO
271
+ and self._best_day <= params.consistency_pct * total_profit
272
+ ):
273
+ self._verdict = Verdict.PASSED
274
+ # Roll to the next trading day.
275
+ self._day_start_balance = closed_balance
276
+ self._had_trade = False
277
+ self._day_locked = False
278
+
279
+ def max_position_micro_units(self) -> int:
280
+ """The fixed position cap in micro-units for the whole Combine."""
281
+ return self._params.max_cap_micro_units
@@ -0,0 +1,74 @@
1
+ """Trading Combine parameters per account size (docs/topstep-rules.md §1).
2
+
3
+ These numbers are **cited config, not constants**: they come from Topstep's
4
+ help center as researched July 2026. The DOLLAR FIGURES are canonical — no
5
+ cross-size ratio holds (the oft-cited 1.5x/0.5x ratios are true only at $50K;
6
+ $100K/$150K have target = 2 x MLL and DLL = 2/3 x MLL). The DLL is OPTIONAL
7
+ and off by default (Topstep removed the default DLL in Aug 2024);
8
+ enable it with ``dll_enabled=True`` to model a Personal Daily Loss Limit.
9
+
10
+ All money values are exact ``Decimal``; the position cap is counted in
11
+ micro-units (mini = 10, micro = 1) so cap math stays pure integer arithmetic.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from decimal import Decimal
17
+ from enum import StrEnum
18
+
19
+ import msgspec
20
+
21
+ __all__ = ["AccountSize", "CombineParams", "combine_params"]
22
+
23
+
24
+ class AccountSize(StrEnum):
25
+ """The three Trading Combine account sizes Topstep offers."""
26
+
27
+ S50K = "50K"
28
+ S100K = "100K"
29
+ S150K = "150K"
30
+
31
+
32
+ class CombineParams(msgspec.Struct, frozen=True):
33
+ """Frozen rule parameters for one Trading Combine account.
34
+
35
+ ``dll`` is ``None`` when no Personal Daily Loss Limit is set (the default
36
+ since Aug 2024); when set, it is the positive dollar amount of allowed
37
+ daily loss. ``max_cap_micro_units`` is the fixed position cap for the
38
+ whole Combine, in micro-units (5/10/15 minis x 10).
39
+ """
40
+
41
+ size: AccountSize
42
+ starting_balance: Decimal
43
+ profit_target: Decimal
44
+ mll_buffer: Decimal
45
+ dll: Decimal | None
46
+ max_cap_micro_units: int
47
+ consistency_pct: Decimal
48
+
49
+
50
+ # starting_balance, profit_target, mll_buffer, dll (when enabled), cap micro-units
51
+ _TABLE: dict[AccountSize, tuple[str, str, str, str, int]] = {
52
+ AccountSize.S50K: ("50000", "3000", "2000", "1000", 50),
53
+ AccountSize.S100K: ("100000", "6000", "3000", "2000", 100),
54
+ AccountSize.S150K: ("150000", "9000", "4500", "3000", 150),
55
+ }
56
+
57
+
58
+ def combine_params(size: AccountSize, *, dll_enabled: bool = False) -> CombineParams:
59
+ """Build the canonical ``CombineParams`` for an account size.
60
+
61
+ ``dll_enabled`` models a Personal Daily Loss Limit at the table's dollar
62
+ amount for the size; the default (``False``) matches Topstep's
63
+ post-Aug-2024 behavior of no DLL.
64
+ """
65
+ start, target, buffer, dll, cap = _TABLE[size]
66
+ return CombineParams(
67
+ size=size,
68
+ starting_balance=Decimal(start),
69
+ profit_target=Decimal(target),
70
+ mll_buffer=Decimal(buffer),
71
+ dll=Decimal(dll) if dll_enabled else None,
72
+ max_cap_micro_units=cap,
73
+ consistency_pct=Decimal("0.5"),
74
+ )
@@ -0,0 +1,20 @@
1
+ """topstep_backtest.strategy"""
2
+
3
+ from .base import Strategy, StrategyContext
4
+ from .symbol import SymbolStrategy
5
+ from .tracker import (
6
+ TERMINAL_ORDER_STATUSES,
7
+ NetPosition,
8
+ OrderTracker,
9
+ PositionTracker,
10
+ )
11
+
12
+ __all__ = [
13
+ "TERMINAL_ORDER_STATUSES",
14
+ "NetPosition",
15
+ "OrderTracker",
16
+ "PositionTracker",
17
+ "Strategy",
18
+ "StrategyContext",
19
+ "SymbolStrategy",
20
+ ]
@@ -0,0 +1,118 @@
1
+ """The write-once Strategy base class and its injected context.
2
+
3
+ A strategy subclasses :class:`Strategy` and talks ONLY to ``self.ctx`` —
4
+ protocol-typed edges (``OrderApi``/``PositionApi``/``HistoryApi``/``Clock``).
5
+ The SAME subclass runs in backtest (wired to a ``SimBroker``) and live (wired
6
+ to ``topstep_sdk.AsyncTopstepClient``), because both satisfy the identical
7
+ structural protocols. Never import a concrete broker or fill model here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING
14
+
15
+ from topstep_sdk import HalfTradeModel, OrderModel, PositionModel
16
+
17
+ from ..core.instruments import InstrumentSpec, spec_for_symbol, symbol_of_contract_id
18
+ from ..protocols import Bar, Broker, Clock, HistoryApi, OrderApi, PositionApi
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Mapping
22
+
23
+ __all__ = ["Strategy", "StrategyContext"]
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class StrategyContext:
28
+ """Everything a strategy may touch. Protocol-typed: sim and live inject
29
+
30
+ different concretions behind the same names.
31
+ """
32
+
33
+ orders: OrderApi
34
+ positions: PositionApi
35
+ history: HistoryApi
36
+ clock: Clock
37
+ account_id: int
38
+ instruments: Mapping[str, InstrumentSpec] = field(default_factory=dict[str, InstrumentSpec])
39
+
40
+ @classmethod
41
+ def from_broker(
42
+ cls,
43
+ broker: Broker,
44
+ *,
45
+ clock: Clock,
46
+ account_id: int,
47
+ instruments: Mapping[str, InstrumentSpec] | None = None,
48
+ ) -> StrategyContext:
49
+ return cls(
50
+ orders=broker.orders,
51
+ positions=broker.positions,
52
+ history=broker.history,
53
+ clock=clock,
54
+ account_id=account_id,
55
+ instruments=dict(instruments or {}),
56
+ )
57
+
58
+ def instrument(self, contract_id: str) -> InstrumentSpec:
59
+ """Spec for a contract id (registered mapping first, table fallback)."""
60
+ found = self.instruments.get(contract_id)
61
+ if found is not None:
62
+ return found
63
+ return spec_for_symbol(symbol_of_contract_id(contract_id))
64
+
65
+
66
+ class Strategy:
67
+ """Base strategy. Override the hooks you need; all are optional.
68
+
69
+ Lifecycle: ``on_start`` -> (``on_bar`` / ``on_order`` / ``on_fill`` /
70
+ ``on_position``)* -> ``on_stop``. Order-state changes arrive via
71
+ ``on_order``; executions via ``on_fill`` (the parity-safe pattern in both
72
+ sim and live — never busy-poll ``wait_for_fill`` in a backtest).
73
+
74
+ Drivers (the ``BacktestEngine``, the future live runner) deliver events
75
+ through ``handle_*`` — pure pass-throughs here. User strategies override
76
+ ``on_*``; framework base classes interpose in ``handle_*``, so overriding
77
+ a user hook can never sever framework bookkeeping.
78
+ """
79
+
80
+ ctx: StrategyContext
81
+
82
+ def bind(self, ctx: StrategyContext) -> None:
83
+ self.ctx = ctx
84
+
85
+ def on_start(self) -> None:
86
+ pass
87
+
88
+ async def on_bar(self, bar: Bar) -> None:
89
+ pass
90
+
91
+ async def on_order(self, order: OrderModel) -> None:
92
+ pass
93
+
94
+ async def on_fill(self, trade: HalfTradeModel) -> None:
95
+ pass
96
+
97
+ async def on_position(self, position: PositionModel) -> None:
98
+ pass
99
+
100
+ def on_stop(self) -> None:
101
+ pass
102
+
103
+ async def handle_bar(self, bar: Bar) -> None:
104
+ """Driver entry point for a completed bar (drivers call ``handle_*``,
105
+ never ``on_*``); the default simply awaits the user hook."""
106
+ await self.on_bar(bar)
107
+
108
+ async def handle_order(self, order: OrderModel) -> None:
109
+ """Driver entry point for an order-state event (see ``handle_bar``)."""
110
+ await self.on_order(order)
111
+
112
+ async def handle_fill(self, trade: HalfTradeModel) -> None:
113
+ """Driver entry point for an execution event (see ``handle_bar``)."""
114
+ await self.on_fill(trade)
115
+
116
+ async def handle_position(self, position: PositionModel) -> None:
117
+ """Driver entry point for a position event (see ``handle_bar``)."""
118
+ await self.on_position(position)