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,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"""
|