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