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 @@
1
+ """topstep_backtest.fills"""
@@ -0,0 +1,268 @@
1
+ """Tier-0 bar-based fill model — the pessimistic execution realism layer.
2
+
3
+ Anti-optimism is the whole point: whenever a bar leaves the fill ambiguous,
4
+ this model resolves it AGAINST the trader. Concretely:
5
+
6
+ * **Participation firewall (no look-ahead):** an order interacts with a bar
7
+ only if it was accepted at/before the bar's OPEN (``accepted_ts <=
8
+ bar.ts_event``). An order created on a bar's close signal therefore first
9
+ participates in the NEXT bar — the classic backtest look-ahead bug is
10
+ structurally impossible.
11
+ * **Limits need trade-THROUGH:** by default a resting limit does not fill on
12
+ an exact touch of its level — the bar must trade at least one tick past it
13
+ (an exact-touch extreme is exactly where real queues don't get filled).
14
+ This applies at the OPEN too: a bar opening exactly at the level is a touch,
15
+ not price improvement, so it does not fill at the open under the default;
16
+ the order can still fill at its level if the bar later trades through.
17
+ ``fill_limit_on_touch=True`` relaxes both for sensitivity analysis.
18
+ * **Stops always pay slippage:** a triggered stop fills ``stop_slippage_ticks``
19
+ ADVERSE of its trigger, and a gap through the stop fills at the (worse) open
20
+ plus slippage — never at the stop price itself. The slipped price MAY exceed
21
+ the bar's high/low: real stop slippage escapes the bar's printed range, and
22
+ clamping it back inside would be optimistic. That is intentional.
23
+ * **Marketable-at-open limits DO take the improvement:** an open STRICTLY
24
+ better than the level is genuine price improvement, not optimism — a resting
25
+ buy limit above the open would fill at the open in reality too.
26
+ * **Honest timestamps:** open-instant fills are stamped with the bar's OPEN
27
+ time (``bar.ts_event``); every intrabar fill is stamped with the bar's CLOSE
28
+ time (``bar.ts_init``) — the honest upper bound for an unknown intrabar time.
29
+ * **Trigger levels:** every fill carries ``trigger_price`` — the LEVEL that
30
+ triggered it (stop level, limit level, or the open), never the
31
+ slippage-adjusted price. The broker orders intrabar events by trigger.
32
+
33
+ All prices are produced by integer-tick arithmetic on the instrument grid;
34
+ a fill price can never land off-grid. Fills are for the FULL remaining
35
+ quantity (Tier-0 models no partials).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from decimal import Decimal
41
+
42
+ import msgspec
43
+ from topstep_sdk import OrderSide, OrderStatus, OrderType
44
+
45
+ from ..core.money import from_ticks, to_ticks
46
+ from ..protocols import Bar, Fill, Liquidity, MarketContext, PricePath, WorkingOrder
47
+
48
+ __all__ = ["BarFillConfig", "BarFillModel"]
49
+
50
+
51
+ class BarFillConfig(msgspec.Struct, frozen=True):
52
+ """Knobs of the Tier-0 pessimism model (all default to the conservative side)."""
53
+
54
+ stop_slippage_ticks: int = 1
55
+ """Adverse ticks added to EVERY triggered stop fill (gap or intrabar)."""
56
+
57
+ market_slippage_ticks: int = 0
58
+ """Adverse ticks on market fills (0: the modeled micros are liquid enough)."""
59
+
60
+ fill_limit_on_touch: bool = False
61
+ """False (default) = a limit fills only if the bar trades >= 1 tick THROUGH
62
+ its level; True = an exact touch of the level suffices."""
63
+
64
+
65
+ class _Execution(msgspec.Struct, frozen=True):
66
+ """Internal: where/when along the path an order executed."""
67
+
68
+ price: Decimal
69
+ trigger: Decimal # the LEVEL that triggered the fill (see Fill.trigger_price)
70
+ seq: int
71
+ at_open: bool # True -> executed at the bar's open instant
72
+
73
+
74
+ class BarFillModel:
75
+ """Tier-0 ``FillModel``: decides fills from OHLC bars + the shared path.
76
+
77
+ Conforms structurally to ``protocols.FillModel``. Deterministic — no
78
+ randomness at all at this tier.
79
+ """
80
+
81
+ def __init__(self, config: BarFillConfig | None = None) -> None:
82
+ self._config = config if config is not None else BarFillConfig()
83
+
84
+ @property
85
+ def config(self) -> BarFillConfig:
86
+ return self._config
87
+
88
+ def try_fill(
89
+ self,
90
+ order: WorkingOrder,
91
+ ctx: MarketContext,
92
+ path: PricePath,
93
+ ) -> list[Fill]:
94
+ bar = ctx.bar
95
+ if bar is None: # Tier-0 always sets it; refuse to guess otherwise
96
+ return []
97
+ if order.status != OrderStatus.OPEN or order.remaining <= 0:
98
+ return []
99
+ # Participation firewall: the order must have existed at/before this
100
+ # bar's OPEN. An order accepted at a bar's close (ts_init) first
101
+ # participates in the NEXT bar.
102
+ if order.accepted_ts > bar.ts_event:
103
+ return []
104
+
105
+ tick = ctx.instrument.tick_size
106
+ otype = OrderType(order.type)
107
+ if otype == OrderType.MARKET:
108
+ execution = self._market(order, bar, tick)
109
+ elif otype == OrderType.LIMIT:
110
+ execution = self._limit(order, bar, path, tick)
111
+ elif otype == OrderType.STOP:
112
+ if order.stop_price is None:
113
+ return []
114
+ execution = self._stop(order, bar, path, tick, order.stop_price)
115
+ elif otype == OrderType.TRAILING_STOP:
116
+ # The broker maintains trail_stop_price; without it there is no
117
+ # trigger to evaluate.
118
+ if order.trail_stop_price is None:
119
+ return []
120
+ execution = self._stop(order, bar, path, tick, order.trail_stop_price)
121
+ else:
122
+ # STOP_LIMIT (and any other type) is unsupported at Tier-0; the
123
+ # broker rejects placement, and we never fill it here.
124
+ return []
125
+
126
+ if execution is None:
127
+ return []
128
+
129
+ liquidity = Liquidity.MAKER if otype == OrderType.LIMIT else Liquidity.TAKER
130
+ # Open-instant fills happen at the bar's OPEN time; an intrabar fill's
131
+ # true time is unknown, so it is stamped at the bar's CLOSE (ts_init) —
132
+ # the honest upper bound. Never ctx.ts_event: the broker sets that to
133
+ # the bar OPEN, which would make every fill look like an open fill.
134
+ return [
135
+ Fill(
136
+ order_id=order.order_id,
137
+ price=execution.price,
138
+ qty=order.remaining, # Tier-0: always the full remaining qty
139
+ ts_event=bar.ts_event if execution.at_open else bar.ts_init,
140
+ seq=execution.seq,
141
+ liquidity=liquidity,
142
+ trigger_price=execution.trigger,
143
+ )
144
+ ]
145
+
146
+ # -- per-type semantics -------------------------------------------------
147
+
148
+ def _market(self, order: WorkingOrder, bar: Bar, tick: Decimal) -> _Execution:
149
+ """MARKET: fill at the open, slipped ADVERSE (buy up / sell down)."""
150
+ slip = self._config.market_slippage_ticks
151
+ open_ticks = to_ticks(bar.open, tick)
152
+ if _is_buy(order.side):
153
+ price = from_ticks(open_ticks + slip, tick)
154
+ else:
155
+ price = from_ticks(open_ticks - slip, tick)
156
+ # Trigger is the OPEN (the touch point), not the slipped price.
157
+ return _Execution(price=price, trigger=from_ticks(open_ticks, tick), seq=0, at_open=True)
158
+
159
+ def _limit(
160
+ self,
161
+ order: WorkingOrder,
162
+ bar: Bar,
163
+ path: PricePath,
164
+ tick: Decimal,
165
+ ) -> _Execution | None:
166
+ """LIMIT: an open STRICTLY through the level takes the improvement; an
167
+ open exactly AT the level is a touch (fills at the open only in touch
168
+ mode); else require the bar to reach (touch mode) or trade one tick
169
+ THROUGH (default) the level."""
170
+ level = order.limit_price
171
+ if level is None:
172
+ return None
173
+ level = from_ticks(to_ticks(level, tick), tick) # enforce on-grid
174
+ open_price = from_ticks(to_ticks(bar.open, tick), tick) # enforce on-grid
175
+ touch = self._config.fill_limit_on_touch
176
+
177
+ if _is_buy(order.side):
178
+ if bar.open < level or (touch and bar.open == level):
179
+ # Marketable at the open: genuine price improvement (or an
180
+ # exact open-touch in touch mode) — fill at the open.
181
+ return _Execution(price=open_price, trigger=open_price, seq=0, at_open=True)
182
+ # An open exactly at the level under the default is only a touch:
183
+ # fall through to the resting logic, which still fills at the
184
+ # level if the bar trades a tick THROUGH it.
185
+ required = level if touch else level - tick
186
+ if bar.low > required:
187
+ return None
188
+ seq = _first_seq_reaching_down(path, level)
189
+ else:
190
+ if bar.open > level or (touch and bar.open == level):
191
+ return _Execution(price=open_price, trigger=open_price, seq=0, at_open=True)
192
+ required = level if touch else level + tick
193
+ if bar.high < required:
194
+ return None
195
+ seq = _first_seq_reaching_up(path, level)
196
+
197
+ if seq is None: # bar range says reachable but path disagrees: no fill
198
+ return None
199
+ return _Execution(price=level, trigger=level, seq=seq, at_open=False)
200
+
201
+ def _stop(
202
+ self,
203
+ order: WorkingOrder,
204
+ bar: Bar,
205
+ path: PricePath,
206
+ tick: Decimal,
207
+ trigger: Decimal,
208
+ ) -> _Execution | None:
209
+ """STOP / TRAILING_STOP: gap-through fills at the open + slippage
210
+ (never at the trigger); an intrabar trigger fills at trigger +
211
+ slippage. The slipped price MAY escape the bar's high/low — real
212
+ slippage does, and clamping would be optimistic."""
213
+ slip = self._config.stop_slippage_ticks
214
+ trigger_ticks = to_ticks(trigger, tick) # also enforces on-grid
215
+ level = from_ticks(trigger_ticks, tick) # the on-grid stop level S
216
+ open_price = from_ticks(to_ticks(bar.open, tick), tick) # enforce on-grid
217
+
218
+ if _is_buy(order.side):
219
+ if bar.open >= trigger:
220
+ # Gapped through the stop: pay the (worse) open, plus slippage.
221
+ # The trigger (touch point) is the OPEN, not the stop level.
222
+ price = from_ticks(to_ticks(bar.open, tick) + slip, tick)
223
+ return _Execution(price=price, trigger=open_price, seq=0, at_open=True)
224
+ seq = _first_seq_reaching_up(path, trigger)
225
+ if seq is None:
226
+ return None
227
+ # Trigger is the stop level S; the PRICE is S slipped adverse.
228
+ return _Execution(
229
+ price=from_ticks(trigger_ticks + slip, tick),
230
+ trigger=level,
231
+ seq=seq,
232
+ at_open=False,
233
+ )
234
+
235
+ if bar.open <= trigger:
236
+ price = from_ticks(to_ticks(bar.open, tick) - slip, tick)
237
+ return _Execution(price=price, trigger=open_price, seq=0, at_open=True)
238
+ seq = _first_seq_reaching_down(path, trigger)
239
+ if seq is None:
240
+ return None
241
+ return _Execution(
242
+ price=from_ticks(trigger_ticks - slip, tick),
243
+ trigger=level,
244
+ seq=seq,
245
+ at_open=False,
246
+ )
247
+
248
+
249
+ def _is_buy(side: OrderSide | int) -> bool:
250
+ return int(side) == int(OrderSide.BUY)
251
+
252
+
253
+ def _first_seq_reaching_down(path: PricePath, level: Decimal) -> int | None:
254
+ """Index of the first path segment on which price reaches ``level`` from
255
+ above (the segment's lower end is at/below the level)."""
256
+ for i in range(len(path) - 1):
257
+ if min(path[i].price, path[i + 1].price) <= level:
258
+ return i
259
+ return None
260
+
261
+
262
+ def _first_seq_reaching_up(path: PricePath, level: Decimal) -> int | None:
263
+ """Index of the first path segment on which price reaches ``level`` from
264
+ below (the segment's upper end is at/above the level)."""
265
+ for i in range(len(path) - 1):
266
+ if max(path[i].price, path[i + 1].price) >= level:
267
+ return i
268
+ return None
@@ -0,0 +1,120 @@
1
+ """Topstep fee model: per-side commission + exchange + NFA costs.
2
+
3
+ Conforms to ``protocols.FeeModel``. Costs are charged PER SIDE (entry AND
4
+ exit), split per the SDK ``HalfTradeModel`` convention::
5
+
6
+ fees = (exchange + nfa) * qty (venue + regulatory)
7
+ commissions = commission * qty (broker)
8
+
9
+ Rates follow docs/topstep-rules.md §7 (researched July 2026, medium
10
+ confidence — reconcile against a real account blotter before trusting
11
+ micro-scalp verdicts; for micros the round-turn cost can rival the edge).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from decimal import Decimal
17
+
18
+ import msgspec
19
+ from topstep_sdk import OrderSide
20
+
21
+ from ..core.instruments import SPECS, InstrumentSpec
22
+ from ..protocols import Liquidity
23
+
24
+ __all__ = ["FeeSchedule", "TopstepFees"]
25
+
26
+
27
+ class FeeSchedule(msgspec.Struct, frozen=True):
28
+ """Per-side cost components for one product (all USD Decimals)."""
29
+
30
+ commission_per_side: Decimal
31
+ exchange_per_side: Decimal
32
+ nfa_per_side: Decimal
33
+
34
+
35
+ _MINI_COMMISSION = Decimal("0.50")
36
+ _MICRO_COMMISSION = Decimal("0.25")
37
+
38
+ # NFA regulatory fee per side. Topstep publishes ~$0.01-0.02; we take the
39
+ # conservative (higher) end so fee drag is never understated.
40
+ _NFA_PER_SIDE = Decimal("0.02")
41
+
42
+ # CME Group exchange + clearing, non-member, per side (docs §7).
43
+ # NG, SI and SIL are ESTIMATES inferred from their asset-class siblings
44
+ # (CL-class energy mini; GC/MGC-class metals) — not from a published rate
45
+ # card. Verify against the CME Fee Finder before trusting their verdicts.
46
+ _EXCHANGE_PER_SIDE: dict[str, Decimal] = {
47
+ "ES": Decimal("1.38"),
48
+ "NQ": Decimal("1.38"),
49
+ "YM": Decimal("1.38"),
50
+ "RTY": Decimal("1.38"),
51
+ "MES": Decimal("0.35"),
52
+ "MNQ": Decimal("0.35"),
53
+ "MYM": Decimal("0.35"),
54
+ "M2K": Decimal("0.35"),
55
+ "CL": Decimal("1.50"),
56
+ "MCL": Decimal("0.50"),
57
+ "NG": Decimal("1.50"), # estimate (CL-class energy mini)
58
+ "GC": Decimal("1.65"),
59
+ "MGC": Decimal("1.10"),
60
+ "SI": Decimal("1.65"), # estimate (GC-class metals mini)
61
+ "SIL": Decimal("1.10"), # estimate (MGC-class metals micro)
62
+ }
63
+
64
+ _DEFAULT_SCHEDULES: dict[str, FeeSchedule] = {
65
+ symbol: FeeSchedule(
66
+ commission_per_side=_MICRO_COMMISSION if spec.is_micro else _MINI_COMMISSION,
67
+ exchange_per_side=_EXCHANGE_PER_SIDE[symbol],
68
+ nfa_per_side=_NFA_PER_SIDE,
69
+ )
70
+ for symbol, spec in SPECS.items()
71
+ }
72
+
73
+ # Unknown symbol -> conservative MINI-level default: assume the expensive
74
+ # (mini commission, CL-class exchange rate) case so an unrecognized product
75
+ # never gets its costs accidentally understated.
76
+ _UNKNOWN_SCHEDULE = FeeSchedule(
77
+ commission_per_side=Decimal("0.50"),
78
+ exchange_per_side=Decimal("1.50"),
79
+ nfa_per_side=Decimal("0.02"),
80
+ )
81
+
82
+
83
+ class TopstepFees:
84
+ """Per-side Topstep fee model (``protocols.FeeModel``).
85
+
86
+ ``overrides`` (keyed by product symbol) replace the built-in schedule for
87
+ those symbols — the calibration hook for reconciling against a real
88
+ account blotter.
89
+ """
90
+
91
+ def __init__(self, overrides: dict[str, FeeSchedule] | None = None) -> None:
92
+ self._overrides: dict[str, FeeSchedule] = dict(overrides) if overrides else {}
93
+
94
+ def schedule_for(self, symbol: str) -> FeeSchedule:
95
+ """The effective schedule for ``symbol``: override > built-in > conservative default."""
96
+ override = self._overrides.get(symbol)
97
+ if override is not None:
98
+ return override
99
+ return _DEFAULT_SCHEDULES.get(symbol, _UNKNOWN_SCHEDULE)
100
+
101
+ def fee(
102
+ self,
103
+ instrument: InstrumentSpec,
104
+ side: OrderSide,
105
+ qty: int,
106
+ liquidity: Liquidity,
107
+ ) -> tuple[Decimal, Decimal]:
108
+ """Cost of one side of ``qty`` contracts: ``(fees, commissions)``.
109
+
110
+ ``fees = (exchange + nfa) * qty``; ``commissions = commission * qty``
111
+ (the SDK ``HalfTradeModel`` split). Topstep's schedule does not vary
112
+ by side or maker/taker at this tier, so ``side``/``liquidity`` are
113
+ accepted for protocol conformance but do not change the amounts.
114
+ """
115
+ if qty <= 0:
116
+ raise ValueError(f"qty must be positive, got {qty}")
117
+ schedule = self.schedule_for(instrument.symbol)
118
+ fees = (schedule.exchange_per_side + schedule.nfa_per_side) * qty
119
+ commissions = schedule.commission_per_side * qty
120
+ return fees, commissions
@@ -0,0 +1,59 @@
1
+ """Deterministic intrabar price path construction (Tier-0).
2
+
3
+ One path is built per bar and shared by the fill model AND the rule engine's
4
+ breach check, so the relative ordering of fills vs. rule breaches inside a bar
5
+ is decided by ONE walk of the bar — never two competing opinions.
6
+
7
+ The path is always four points, ``seq`` 0..3::
8
+
9
+ OPEN -> EXTREME_FIRST -> EXTREME_SECOND -> CLOSE
10
+
11
+ The only degree of freedom is which extreme comes first, and it is resolved
12
+ PESSIMISTICALLY against the trader's open position:
13
+
14
+ * net long -> the LOW comes first (adverse move hits stops/breaches earliest)
15
+ * net short -> the HIGH comes first
16
+ * flat -> the extreme NEARER the open comes first (the statistically more
17
+ likely first touch); an exact tie breaks to the LOW, deterministically.
18
+
19
+ Degenerate bars (``high == low``, doji, single-print) still yield a valid
20
+ 4-point path — points may repeat a price, and zero-length segments are fine.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from ..protocols import Bar, PathPoint, PointKind, PricePath
26
+
27
+ __all__ = ["build_path"]
28
+
29
+
30
+ def build_path(bar: Bar, position_dir: int) -> PricePath:
31
+ """Build the pessimistic 4-point intrabar path for ``bar``.
32
+
33
+ Args:
34
+ bar: The completed OHLCV bar to walk.
35
+ position_dir: The trader's net position direction while this bar
36
+ prints: ``+1`` net long, ``-1`` net short, ``0`` flat.
37
+
38
+ Returns:
39
+ The 4-point ``PricePath`` (seq 0..3): OPEN, the adverse-first ordering
40
+ of the two extremes, then CLOSE.
41
+ """
42
+ if position_dir > 0:
43
+ # Long: the low is the adverse extreme — visit it first.
44
+ first, second = bar.low, bar.high
45
+ elif position_dir < 0:
46
+ # Short: the high is the adverse extreme — visit it first.
47
+ first, second = bar.high, bar.low
48
+ elif bar.open - bar.low <= bar.high - bar.open:
49
+ # Flat: extreme nearer the open first; exact tie -> low first.
50
+ first, second = bar.low, bar.high
51
+ else:
52
+ first, second = bar.high, bar.low
53
+
54
+ return (
55
+ PathPoint(seq=0, kind=PointKind.OPEN, price=bar.open),
56
+ PathPoint(seq=1, kind=PointKind.EXTREME_FIRST, price=first),
57
+ PathPoint(seq=2, kind=PointKind.EXTREME_SECOND, price=second),
58
+ PathPoint(seq=3, kind=PointKind.CLOSE, price=bar.close),
59
+ )