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,153 @@
1
+ """Combine-centric summary statistics derived from a finished backtest run.
2
+
3
+ Every metric's basis is stated explicitly because most admit two honest bases
4
+ (gross vs net of fees, bar-close vs intrabar) and mixing them silently is how
5
+ reports lie:
6
+
7
+ * A "closing half-turn" is a broker trade record whose ``profit_and_loss`` is
8
+ not ``None`` and that is not voided. ``profit_and_loss`` is the GROSS
9
+ realized P&L of the closed portion; fees and commissions are charged on
10
+ EVERY half-turn (open and close) and deducted from balance separately. So
11
+ per-close classification (win rate, expectancy, profit factor) is gross,
12
+ while the aggregate net figure is ``net_pnl`` (ending minus starting
13
+ balance, all fees included). Re-attributing opening fees to round trips
14
+ would need new FIFO pairing whose live-gateway equivalence is unverified
15
+ (docs/topstep-rules.md §9) — deliberately not offered.
16
+ * Drawdown is measured on the per-bar CLOSE equity curve, closed with one
17
+ terminal mark at ``ending_balance``: the engine's final session roll (16:10
18
+ flatten slippage + liquidation fees) lands AFTER the last curve point, and
19
+ without the terminal mark ``max_drawdown`` could sit below the net loss
20
+ printed beside it. Intrabar excursions are not observable from
21
+ ``BacktestResult`` and no proxy is attempted.
22
+ * Consistency (docs/topstep-rules.md §4): passing requires
23
+ ``best_day <= consistency_pct x total_profit``, where ``best_day`` is the
24
+ largest traded-day EOD-balance delta (net of fees, never below zero) and
25
+ ``total_profit`` is the last closed balance minus start.
26
+ ``consistency_pct`` is not a ``BacktestResult`` field — the caller passes
27
+ it from the ``CombineParams`` the run used.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from decimal import Decimal
33
+ from typing import TYPE_CHECKING
34
+
35
+ import msgspec
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Sequence
39
+
40
+ from topstep_sdk import HalfTradeModel
41
+
42
+ from ..engine.backtest import BacktestResult
43
+
44
+ __all__ = ["SummaryStats", "compute_summary"]
45
+
46
+ _ZERO = Decimal("0")
47
+
48
+
49
+ class SummaryStats(msgspec.Struct, frozen=True):
50
+ """Combine-centric summary metrics for one finished backtest run.
51
+
52
+ Empty-run conventions: with zero closing half-turns, ``win_rate``,
53
+ ``expectancy`` and ``profit_factor`` are ``None`` (undefined, not 0);
54
+ ``profit_factor`` is also ``None`` when there are no losing closes (the
55
+ ratio would be infinite). ``max_drawdown`` over an empty equity curve
56
+ reduces to the terminal mark alone: ``max(0, starting - ending)``, which
57
+ is 0 for a run with no bars (the balance never moved).
58
+ """
59
+
60
+ closed_trades: int
61
+ """Closing half-turns: trade records with ``profit_and_loss`` set and not
62
+ voided. NOT round trips — a flip's single half-turn closes one position
63
+ and opens the next."""
64
+
65
+ win_rate: Decimal | None
66
+ """Fraction of closing half-turns with gross ``profit_and_loss`` > 0
67
+ (fees are charged per half-turn separately, so this is a GROSS stat).
68
+ ``None`` when there are no closing half-turns."""
69
+
70
+ expectancy: Decimal | None
71
+ """Mean gross ``profit_and_loss`` per closing half-turn. ``None`` when
72
+ there are no closing half-turns; the aggregate NET counterpart is
73
+ ``net_pnl / closed_trades``."""
74
+
75
+ profit_factor: Decimal | None
76
+ """Sum of gross winning closes / |sum of gross losing closes|. ``None``
77
+ when undefined: no closing half-turns, or no losing closes."""
78
+
79
+ max_drawdown: Decimal
80
+ """Largest peak-to-trough decline of the per-bar CLOSE equity curve plus
81
+ one terminal mark at ``ending_balance`` (the final session roll's flatten
82
+ costs land after the last curve point), with the peak seeded at the
83
+ starting balance (always >= 0, and never below ``-net_pnl``). Close-basis
84
+ only: intrabar excursions are not in ``BacktestResult.equity_curve``."""
85
+
86
+ final_balance: Decimal
87
+ """Ending realized balance (``BacktestResult.ending_balance``)."""
88
+
89
+ net_pnl: Decimal
90
+ """``ending_balance - starting_balance``, net of ALL fees and commissions
91
+ (the engine's session roll flattens at end of run, so nothing is open)."""
92
+
93
+ distance_to_floor: Decimal
94
+ """``ending_balance - floor``: dollars of room above the trailing MLL
95
+ floor at end of run."""
96
+
97
+ consistency_headroom: Decimal
98
+ """``consistency_pct x total_profit - best_day`` — dollar slack in the
99
+ consistency rule (docs/topstep-rules.md §4). Negative means the best day
100
+ is currently too large: the effective target inflates until
101
+ ``best_day <= consistency_pct x total_profit`` holds."""
102
+
103
+ days_traded: int
104
+ """Closed trading days with trade activity (``BacktestResult.days_traded``)."""
105
+
106
+
107
+ def compute_summary(
108
+ result: BacktestResult,
109
+ *,
110
+ trades: Sequence[HalfTradeModel],
111
+ consistency_pct: Decimal,
112
+ ) -> SummaryStats:
113
+ """Derive ``SummaryStats`` from a run's frozen result and trade list.
114
+
115
+ Pure and deterministic: a function of its arguments only (``Decimal``
116
+ arithmetic at the default context), no clock reads, no randomness.
117
+ ``trades`` is the broker's half-turn list (``SimBroker.trades``);
118
+ ``consistency_pct`` comes from the ``CombineParams`` the run used.
119
+ """
120
+ closes = [
121
+ trade.profit_and_loss
122
+ for trade in trades
123
+ if trade.profit_and_loss is not None and not trade.voided
124
+ ]
125
+ closed = len(closes)
126
+ wins = sum(1 for pnl in closes if pnl > _ZERO)
127
+ gross_profit = sum((pnl for pnl in closes if pnl > _ZERO), _ZERO)
128
+ gross_loss = sum((-pnl for pnl in closes if pnl < _ZERO), _ZERO)
129
+
130
+ peak = result.starting_balance
131
+ max_drawdown = _ZERO
132
+ # The final ending_balance is one more equity mark: the terminal session
133
+ # roll's flatten costs land after the last curve point (module docstring).
134
+ marks = [equity for _ts_ns, equity in result.equity_curve]
135
+ marks.append(result.ending_balance)
136
+ for equity in marks:
137
+ if equity > peak:
138
+ peak = equity
139
+ elif peak - equity > max_drawdown:
140
+ max_drawdown = peak - equity
141
+
142
+ return SummaryStats(
143
+ closed_trades=closed,
144
+ win_rate=Decimal(wins) / closed if closed else None,
145
+ expectancy=sum(closes, _ZERO) / closed if closed else None,
146
+ profit_factor=gross_profit / gross_loss if closed and gross_loss > _ZERO else None,
147
+ max_drawdown=max_drawdown,
148
+ final_balance=result.ending_balance,
149
+ net_pnl=result.ending_balance - result.starting_balance,
150
+ distance_to_floor=result.ending_balance - result.floor,
151
+ consistency_headroom=consistency_pct * result.total_profit - result.best_day,
152
+ days_traded=result.days_traded,
153
+ )
@@ -0,0 +1,473 @@
1
+ """THE canonical interface module — every subsystem imports from here.
2
+
3
+ This file is the frozen contract that resolves cross-subsystem interface
4
+ drift (the #1 composition risk identified at design time). It defines:
5
+
6
+ - the time contract (``Clock``, ``TimeEvent``)
7
+ - the data contract (``Bar``, ``BarType``, ``DataFeed`` — bars stamped at
8
+ CLOSE; feeds yield in non-decreasing ``ts_init`` order)
9
+ - the parity seam (``OrderApi`` / ``PositionApi`` / ``HistoryApi`` /
10
+ ``Broker`` — method surfaces copied verbatim from the topstep-sdk
11
+ resources, so ``AsyncTopstepClient`` satisfies ``Broker`` structurally
12
+ and ``SimBroker`` implements the identical protocol)
13
+ - the fill contract (``MarketContext``, ``PricePath``, ``WorkingOrder``,
14
+ ``Fill``, ``FillModel``, ``FeeModel`` — the only realism component that
15
+ changes across data tiers; strategies never import it)
16
+
17
+ Conventions (binding):
18
+ - Timestamps are ``int`` nanoseconds since the UTC epoch. Every event
19
+ carries ``ts_event`` (venue occurrence) and ``ts_init`` (engine ingest);
20
+ dispatch order is strictly non-decreasing ``ts_init``.
21
+ - A ``Bar``'s ``ts_init`` equals its CLOSE time — a strategy physically
22
+ cannot act on an unfinished bar.
23
+ - All prices are ``Decimal`` on the instrument's tick grid.
24
+ - Backtest data feeds are synchronous iterators (deterministic pull);
25
+ live feeds adapt the SDK market hub separately.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import Callable, Iterator, Sequence
31
+ from datetime import datetime
32
+ from decimal import Decimal
33
+ from enum import IntEnum
34
+ from typing import Protocol, runtime_checkable
35
+
36
+ import msgspec
37
+ from topstep_sdk import (
38
+ AggregateBarUnit,
39
+ OrderModel,
40
+ OrderSide,
41
+ OrderType,
42
+ PlaceOrderBracket,
43
+ PositionModel,
44
+ )
45
+ from topstep_sdk.models.history import AggregateBarModel
46
+ from topstep_sdk.models.realtime import MarketTradeData, QuoteData
47
+
48
+ from .core.instruments import InstrumentSpec
49
+
50
+ __all__ = [
51
+ "Bar",
52
+ "BarType",
53
+ "Broker",
54
+ "Clock",
55
+ "DataFeed",
56
+ "FeeModel",
57
+ "Fill",
58
+ "FillModel",
59
+ "HistoryApi",
60
+ "Liquidity",
61
+ "MarketContext",
62
+ "OrderApi",
63
+ "PathPoint",
64
+ "PositionApi",
65
+ "PricePath",
66
+ "TimeEvent",
67
+ "WorkingOrder",
68
+ ]
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Time
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ class TimeEvent(msgspec.Struct, frozen=True):
77
+ """A named timer/alert firing at ``ts_ns``."""
78
+
79
+ name: str
80
+ ts_ns: int
81
+
82
+
83
+ @runtime_checkable
84
+ class Clock(Protocol):
85
+ """Swappable time source. ALL time-based logic flows through this —
86
+
87
+ never ``datetime.now()``. ``TestClock`` advances only as the engine drains
88
+ events; ``LiveClock`` is wall time. Identical API in both, so time-driven
89
+ strategy/rule logic is parity-safe.
90
+ """
91
+
92
+ def now_ns(self) -> int: ...
93
+
94
+ def now(self) -> datetime:
95
+ """Current time as a tz-aware UTC datetime."""
96
+ ...
97
+
98
+ def set_time_alert(self, name: str, at_ns: int, cb: Callable[[TimeEvent], None]) -> None: ...
99
+
100
+ def set_timer(self, name: str, interval_ns: int, cb: Callable[[TimeEvent], None]) -> None: ...
101
+
102
+ def cancel_timer(self, name: str) -> None: ...
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Data
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ class BarType(msgspec.Struct, frozen=True):
111
+ """Identifies a bar stream: instrument + step + unit.
112
+
113
+ ``contract_id`` is the gateway contract id (e.g. ``"CON.F.US.MNQ.U26"``).
114
+ """
115
+
116
+ contract_id: str
117
+ unit: AggregateBarUnit
118
+ unit_number: int
119
+
120
+
121
+ class Bar(msgspec.Struct, frozen=True):
122
+ """An OHLCV bar. ``ts_init`` == bar CLOSE time (the no-look-ahead anchor);
123
+
124
+ ``ts_event`` == bar OPEN time (when the bar's window began at the venue).
125
+ """
126
+
127
+ bar_type: BarType
128
+ ts_event: int
129
+ ts_init: int
130
+ open: Decimal
131
+ high: Decimal
132
+ low: Decimal
133
+ close: Decimal
134
+ volume: int
135
+
136
+
137
+ @runtime_checkable
138
+ class DataFeed(Protocol):
139
+ """A time-ordered source of bars for backtests.
140
+
141
+ MUST yield bars in non-decreasing ``ts_init`` order across ALL instruments
142
+ (one merged stream). The engine asserts this invariant on dispatch.
143
+ """
144
+
145
+ def __iter__(self) -> Iterator[Bar]: ...
146
+
147
+ def instruments(self) -> Sequence[str]:
148
+ """The contract ids this feed emits."""
149
+ ...
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Parity seam: Broker (method surfaces copied verbatim from topstep-sdk)
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ @runtime_checkable
158
+ class OrderApi(Protocol):
159
+ """Order surface — matches ``topstep_sdk.resources.order.OrderResource``."""
160
+
161
+ async def place(
162
+ self,
163
+ account_id: int,
164
+ contract_id: str,
165
+ *,
166
+ side: OrderSide | int,
167
+ type: OrderType | int,
168
+ size: int,
169
+ limit_price: float | Decimal | None = None,
170
+ stop_price: float | Decimal | None = None,
171
+ trail_price: float | Decimal | None = None,
172
+ custom_tag: str | None = None,
173
+ stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None,
174
+ take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None,
175
+ stop_loss_ticks: int | None = None,
176
+ take_profit_ticks: int | None = None,
177
+ ) -> int: ...
178
+
179
+ async def buy(
180
+ self,
181
+ account_id: int,
182
+ contract_id: str,
183
+ size: int,
184
+ *,
185
+ type: OrderType | int = OrderType.MARKET,
186
+ limit_price: float | Decimal | None = None,
187
+ stop_price: float | Decimal | None = None,
188
+ trail_price: float | Decimal | None = None,
189
+ custom_tag: str | None = None,
190
+ stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None,
191
+ take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None,
192
+ stop_loss_ticks: int | None = None,
193
+ take_profit_ticks: int | None = None,
194
+ ) -> int: ...
195
+
196
+ async def sell(
197
+ self,
198
+ account_id: int,
199
+ contract_id: str,
200
+ size: int,
201
+ *,
202
+ type: OrderType | int = OrderType.MARKET,
203
+ limit_price: float | Decimal | None = None,
204
+ stop_price: float | Decimal | None = None,
205
+ trail_price: float | Decimal | None = None,
206
+ custom_tag: str | None = None,
207
+ stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None,
208
+ take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None,
209
+ stop_loss_ticks: int | None = None,
210
+ take_profit_ticks: int | None = None,
211
+ ) -> int: ...
212
+
213
+ async def modify(
214
+ self,
215
+ account_id: int,
216
+ order_id: int,
217
+ *,
218
+ size: int | None = None,
219
+ limit_price: float | Decimal | None = None,
220
+ stop_price: float | Decimal | None = None,
221
+ trail_price: float | Decimal | None = None,
222
+ ) -> None: ...
223
+
224
+ async def cancel(self, account_id: int, order_id: int) -> None: ...
225
+
226
+ async def cancel_all(self, account_id: int) -> list[int]: ...
227
+
228
+ async def search_open(self, account_id: int) -> list[OrderModel]: ...
229
+
230
+ async def get(self, account_id: int, order_id: int) -> OrderModel | None: ...
231
+
232
+ async def wait_for_fill(
233
+ self,
234
+ account_id: int,
235
+ order_id: int,
236
+ *,
237
+ timeout: float = 30.0, # noqa: ASYNC109 - mirrors the SDK signature
238
+ poll_interval: float = 1.0,
239
+ ) -> OrderModel: ...
240
+
241
+
242
+ @runtime_checkable
243
+ class PositionApi(Protocol):
244
+ """Position surface — matches ``topstep_sdk.resources.position.PositionResource``."""
245
+
246
+ async def search_open(self, account_id: int) -> list[PositionModel]: ...
247
+
248
+ async def close(self, account_id: int, contract_id: str) -> None: ...
249
+
250
+ async def partial_close(self, account_id: int, contract_id: str, size: int) -> None: ...
251
+
252
+ async def close_all(self, account_id: int) -> list[str]: ...
253
+
254
+
255
+ @runtime_checkable
256
+ class HistoryApi(Protocol):
257
+ """History surface — matches ``topstep_sdk.resources.history.HistoryResource``."""
258
+
259
+ async def retrieve_bars(
260
+ self,
261
+ contract_id: str,
262
+ *,
263
+ unit: AggregateBarUnit | int,
264
+ unit_number: int,
265
+ start_time: datetime | str,
266
+ end_time: datetime | str,
267
+ limit: int = 1000,
268
+ live: bool = False,
269
+ include_partial_bar: bool = False,
270
+ ) -> list[AggregateBarModel]: ...
271
+
272
+
273
+ @runtime_checkable
274
+ class Broker(Protocol):
275
+ """The write-once seam: ``AsyncTopstepClient`` satisfies this structurally
276
+
277
+ (its ``.orders``/``.positions``/``.history`` resources match the protocols
278
+ above verbatim) and ``SimBroker`` implements the identical surface —
279
+ swapping sim <-> live is pure wiring, with zero strategy change.
280
+ """
281
+
282
+ @property
283
+ def orders(self) -> OrderApi: ...
284
+
285
+ @property
286
+ def positions(self) -> PositionApi: ...
287
+
288
+ @property
289
+ def history(self) -> HistoryApi: ...
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # Fills (the only realism component; strategies never import this section)
294
+ # ---------------------------------------------------------------------------
295
+
296
+
297
+ class Liquidity(IntEnum):
298
+ MAKER = 0
299
+ TAKER = 1
300
+
301
+
302
+ class PointKind(IntEnum):
303
+ """Kind of a point on the deterministic intrabar price path."""
304
+
305
+ OPEN = 0
306
+ EXTREME_FIRST = 1
307
+ EXTREME_SECOND = 2
308
+ CLOSE = 3
309
+
310
+
311
+ class PathPoint(msgspec.Struct, frozen=True):
312
+ """One waypoint of the intrabar path; consecutive points bound a monotonic
313
+
314
+ price segment. ``seq`` orders all intrabar happenings (fills, rule-breach
315
+ liquidations) deterministically along the path.
316
+ """
317
+
318
+ seq: int
319
+ kind: PointKind
320
+ price: Decimal
321
+
322
+
323
+ PricePath = tuple[PathPoint, ...]
324
+ """The deterministic intrabar price path: OPEN -> first extreme -> second
325
+ extreme -> CLOSE. Pessimistic ordering: with an open position, the ADVERSE
326
+ extreme comes first (long -> low first, short -> high first); flat defaults to
327
+ the extreme nearer the open. Built once per bar by ``fills.path.build_path``
328
+ and shared by the fill model AND the rule engine's breach check so their
329
+ relative ordering within a bar is decided by ONE walk, never two opinions.
330
+ """
331
+
332
+
333
+ class MarketContext(msgspec.Struct, frozen=True):
334
+ """Tier-polymorphic market slice the engine feeds a ``FillModel``.
335
+
336
+ Exactly one of the tier fields is populated per event (Tier 0: ``bar``).
337
+ A fill model may read only data at/before ``ts_event`` — never ahead.
338
+ """
339
+
340
+ ts_event: int
341
+ ts_init: int
342
+ instrument: InstrumentSpec
343
+ bar: Bar | None = None
344
+ quote: QuoteData | None = None
345
+ last_trade: MarketTradeData | None = None
346
+ prev_close: Decimal | None = None
347
+
348
+
349
+ class WorkingOrder:
350
+ """Mutable engine-owned order lifecycle state (NOT part of any message).
351
+
352
+ ``accepted_ts`` is the no-look-ahead firewall: an order participates in a
353
+ bar only if ``accepted_ts <= bar.ts_event`` (i.e. it existed at or before
354
+ the bar's open) — a close-signal order can never fill inside its own bar.
355
+ """
356
+
357
+ __slots__ = (
358
+ "accepted_ts",
359
+ "account_id",
360
+ "avg_fill_price",
361
+ "contract_id",
362
+ "custom_tag",
363
+ "filled_qty",
364
+ "limit_price",
365
+ "linked_order_id",
366
+ "order_id",
367
+ "parent_order_id",
368
+ "reduce_only",
369
+ "side",
370
+ "size",
371
+ "status",
372
+ "stop_price",
373
+ "trail_distance_ticks",
374
+ "trail_stop_price",
375
+ "type",
376
+ )
377
+
378
+ def __init__(
379
+ self,
380
+ *,
381
+ order_id: int,
382
+ account_id: int,
383
+ contract_id: str,
384
+ side: OrderSide,
385
+ type: OrderType,
386
+ size: int,
387
+ accepted_ts: int,
388
+ limit_price: Decimal | None = None,
389
+ stop_price: Decimal | None = None,
390
+ trail_stop_price: Decimal | None = None,
391
+ trail_distance_ticks: int | None = None,
392
+ custom_tag: str | None = None,
393
+ parent_order_id: int | None = None,
394
+ linked_order_id: int | None = None,
395
+ reduce_only: bool = False,
396
+ ) -> None:
397
+ from topstep_sdk import OrderStatus # local import avoids cycle at module load
398
+
399
+ self.order_id = order_id
400
+ self.account_id = account_id
401
+ self.contract_id = contract_id
402
+ self.side = side
403
+ self.type = type
404
+ self.size = size
405
+ self.accepted_ts = accepted_ts
406
+ self.limit_price = limit_price
407
+ self.stop_price = stop_price
408
+ self.trail_stop_price = trail_stop_price
409
+ self.trail_distance_ticks = trail_distance_ticks
410
+ self.custom_tag = custom_tag
411
+ self.parent_order_id = parent_order_id
412
+ self.linked_order_id = linked_order_id
413
+ self.reduce_only = reduce_only
414
+ self.filled_qty = 0
415
+ self.avg_fill_price: Decimal | None = None
416
+ self.status = OrderStatus.OPEN
417
+
418
+ @property
419
+ def remaining(self) -> int:
420
+ return self.size - self.filled_qty
421
+
422
+
423
+ class Fill(msgspec.Struct, frozen=True):
424
+ """One execution produced by a fill model.
425
+
426
+ ``seq`` is the path point starting the segment where the fill triggers;
427
+ ``trigger_price`` is the LEVEL that was touched (stop/limit level, or the
428
+ open) — the broker orders intrabar events by (seq, |trigger - seg_start|),
429
+ NOT by the slippage-adjusted fill ``price``. Ties resolve by order id.
430
+ """
431
+
432
+ order_id: int
433
+ price: Decimal
434
+ qty: int
435
+ ts_event: int
436
+ seq: int
437
+ liquidity: Liquidity
438
+ trigger_price: Decimal | None = None
439
+ note: str = ""
440
+
441
+
442
+ @runtime_checkable
443
+ class FillModel(Protocol):
444
+ """The ONLY component that changes across data tiers (bar -> L1 -> L2 -> MBO).
445
+
446
+ Given one order and the current market slice + shared intrabar path, decide
447
+ whether/where it fills. Must be deterministic (any randomness seeded) and
448
+ must never read past ``ctx.ts_event``.
449
+ """
450
+
451
+ def try_fill(
452
+ self,
453
+ order: WorkingOrder,
454
+ ctx: MarketContext,
455
+ path: PricePath,
456
+ ) -> list[Fill]: ...
457
+
458
+
459
+ @runtime_checkable
460
+ class FeeModel(Protocol):
461
+ """Per-side, per-instrument cost, charged on entry AND exit.
462
+
463
+ Returns ``(exchange_and_nfa_fees, broker_commission)`` per the SDK's
464
+ ``HalfTradeModel`` split (``fees`` vs ``commissions``).
465
+ """
466
+
467
+ def fee(
468
+ self,
469
+ instrument: InstrumentSpec,
470
+ side: OrderSide,
471
+ qty: int,
472
+ liquidity: Liquidity,
473
+ ) -> tuple[Decimal, Decimal]: ...
File without changes
@@ -0,0 +1 @@
1
+ """topstep_backtest.rules"""