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,344 @@
|
|
|
1
|
+
"""Single-contract ergonomic strategy base (the backtesting.py-style dialect).
|
|
2
|
+
|
|
3
|
+
``SymbolStrategy`` narrows the raw ``Strategy`` seam to one contract: events
|
|
4
|
+
for other instruments never reach the ``on_*`` hooks, ``use()``-registered
|
|
5
|
+
indicators are auto-updated once per matching bar (registration order ==
|
|
6
|
+
update order), and ``on_bar`` is gated until every indicator is ready. The
|
|
7
|
+
position/working-order views fold ONLY the SDK models both brokers emit (plus
|
|
8
|
+
a submit-time latch of each sugar-placed order id, superseded by its first
|
|
9
|
+
real event), and the order sugar targets the ``ctx`` protocol surfaces with
|
|
10
|
+
rejections routed to ``on_reject`` — a rejection is normal combine control
|
|
11
|
+
flow (position cap,
|
|
12
|
+
no-trade window), not an exception. The raw raising path remains
|
|
13
|
+
``self.ctx.orders``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
from typing import cast
|
|
20
|
+
|
|
21
|
+
from topstep_sdk import (
|
|
22
|
+
APIError,
|
|
23
|
+
HalfTradeModel,
|
|
24
|
+
OrderModel,
|
|
25
|
+
OrderSide,
|
|
26
|
+
OrderStatus,
|
|
27
|
+
OrderType,
|
|
28
|
+
PositionModel,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from ..core.instruments import InstrumentSpec
|
|
32
|
+
from ..indicators import Cross, Indicator, TalibLine, ValueSource
|
|
33
|
+
from ..protocols import Bar
|
|
34
|
+
from .base import Strategy
|
|
35
|
+
from .tracker import NetPosition, OrderTracker
|
|
36
|
+
|
|
37
|
+
__all__ = ["SymbolStrategy"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _implied_type(limit_price: Decimal | None, stop_price: Decimal | None) -> OrderType:
|
|
41
|
+
"""The order type the price kwargs imply.
|
|
42
|
+
|
|
43
|
+
Both together would imply STOP_LIMIT, which the Tier-0 sim rejects while
|
|
44
|
+
live places — a strategy validated in sim would carry a silently dead
|
|
45
|
+
branch — so the combination is refused loudly at the seam.
|
|
46
|
+
"""
|
|
47
|
+
if limit_price is not None and stop_price is not None:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"limit_price and stop_price together imply STOP_LIMIT, which the "
|
|
50
|
+
"Tier-0 sim cannot fill (sim would reject what live would place); "
|
|
51
|
+
"place it explicitly via self.ctx.orders if you mean it"
|
|
52
|
+
)
|
|
53
|
+
if limit_price is not None:
|
|
54
|
+
return OrderType.LIMIT
|
|
55
|
+
if stop_price is not None:
|
|
56
|
+
return OrderType.STOP
|
|
57
|
+
return OrderType.MARKET
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _owner_of(source: object) -> object:
|
|
61
|
+
"""A ``Cross`` input's registerable identity: a line resolves to its owner."""
|
|
62
|
+
return source.owner if isinstance(source, TalibLine) else source
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_cross_inputs(cross: Cross, registered_ids: set[int]) -> None:
|
|
66
|
+
"""Both hazards a ``Cross`` can be wired into, refused at registration.
|
|
67
|
+
|
|
68
|
+
Inputs are viewed as plain objects because the checks exist for callers the
|
|
69
|
+
type system never saw — a ``Cross`` over another ``Cross`` type-checks (a
|
|
70
|
+
Cross has lookback/ready) but has no ``.value``, and would die with an
|
|
71
|
+
``AttributeError`` only on the first bar both inner inputs happened to be
|
|
72
|
+
ready, potentially hours into a run.
|
|
73
|
+
"""
|
|
74
|
+
for source in cast("tuple[object, object]", cross.inputs):
|
|
75
|
+
if not isinstance(source, ValueSource):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Cross input {source!r} exposes no `.value`, so the Cross would "
|
|
78
|
+
f"die mid-run on the first bar both inputs are ready"
|
|
79
|
+
)
|
|
80
|
+
if id(_owner_of(source)) not in registered_ids:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"Cross inputs must be use()-registered before the Cross "
|
|
83
|
+
"that reads them (update the Cross manually if an input "
|
|
84
|
+
"is maintained outside use())"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _require_indicator(candidate: object) -> None:
|
|
89
|
+
"""Reject a non-``Indicator`` at registration rather than on the first bar.
|
|
90
|
+
|
|
91
|
+
Typed callers cannot reach this (``use`` is bound to ``Indicator``), but an
|
|
92
|
+
untyped one would otherwise get a bare ``AttributeError`` a bar later.
|
|
93
|
+
"""
|
|
94
|
+
if not isinstance(candidate, Indicator):
|
|
95
|
+
raise TypeError(
|
|
96
|
+
f"{candidate!r} is not an Indicator (needs lookback/ready/update); "
|
|
97
|
+
f"registering it would fail on the first bar instead of here"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SymbolStrategy(Strategy):
|
|
102
|
+
"""Base for strategies trading exactly one contract.
|
|
103
|
+
|
|
104
|
+
Subclasses register indicators with ``use()`` in ``__init__`` and override
|
|
105
|
+
``on_bar`` — invoked only for ``contract_id``, only once every registered
|
|
106
|
+
indicator is ready (and, when ``warmup`` is explicitly overridden, at
|
|
107
|
+
least that many matching bars have been seen; an explicit ``warmup``
|
|
108
|
+
gates even under ``require_ready=False``), with every indicator already
|
|
109
|
+
updated for that bar. ``position`` and ``working_orders`` are folded from
|
|
110
|
+
the same user events live emits, and ``buy``/``sell`` latch the returned
|
|
111
|
+
order id into ``working_orders`` at submit time — so entry guards like
|
|
112
|
+
``position.flat and not self.working_orders`` hold even when live hub
|
|
113
|
+
confirmations lag the REST return past the next bar.
|
|
114
|
+
|
|
115
|
+
One known hook-cadence gap: the sim emits no ``PositionModel`` event on a
|
|
116
|
+
full close (live sends a closed/size-0 snapshot), so detect flatness from
|
|
117
|
+
``position.flat`` in ``on_fill`` — never by overriding ``on_position``.
|
|
118
|
+
The views themselves stay correct on both sides.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
contract_id: str,
|
|
124
|
+
*,
|
|
125
|
+
require_ready: bool = True,
|
|
126
|
+
warmup: int | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self.contract_id = contract_id
|
|
129
|
+
self._require_ready = require_ready
|
|
130
|
+
self._warmup_override = warmup
|
|
131
|
+
self._indicators: list[Indicator] = []
|
|
132
|
+
self._position = NetPosition()
|
|
133
|
+
self._orders = OrderTracker()
|
|
134
|
+
self._bars_seen = 0
|
|
135
|
+
self._bars_gated = 0
|
|
136
|
+
|
|
137
|
+
def use[T: Indicator](self, indicator: T) -> T:
|
|
138
|
+
"""Register an indicator: auto-updated on every matching bar and
|
|
139
|
+
|
|
140
|
+
counted toward the ready-gate. Registration order == update order —
|
|
141
|
+
``use()`` a ``Cross``'s inputs BEFORE the ``Cross`` that reads them
|
|
142
|
+
(enforced: a ``Cross`` with unregistered inputs would read one-bar-
|
|
143
|
+
stale values with no error, so it is refused here instead). A
|
|
144
|
+
``TalibLine`` input counts as registered once the indicator that OWNS
|
|
145
|
+
it is, since one owner computes all of its lines together.
|
|
146
|
+
"""
|
|
147
|
+
if isinstance(indicator, TalibLine):
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"a TalibLine cannot be use()-registered — it has no state of "
|
|
150
|
+
"its own and would never advance; register the indicator that "
|
|
151
|
+
"owns it and keep the line only as a Cross input"
|
|
152
|
+
)
|
|
153
|
+
_require_indicator(indicator)
|
|
154
|
+
if isinstance(indicator, Cross):
|
|
155
|
+
_check_cross_inputs(indicator, {id(known) for known in self._indicators})
|
|
156
|
+
indicator.mark_registered()
|
|
157
|
+
self._indicators.append(indicator)
|
|
158
|
+
return indicator
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def warmup(self) -> int:
|
|
162
|
+
"""Declared warmup: the explicit override, else the max indicator lookback."""
|
|
163
|
+
if self._warmup_override is not None:
|
|
164
|
+
return self._warmup_override
|
|
165
|
+
return max((indicator.lookback for indicator in self._indicators), default=0)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def position(self) -> NetPosition:
|
|
169
|
+
"""This contract's net position, folded from fills and snapshots."""
|
|
170
|
+
return self._position
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def working_orders(self) -> tuple[OrderModel, ...]:
|
|
174
|
+
"""Non-terminal orders on this contract, ascending order id."""
|
|
175
|
+
return self._orders.working_for(self.contract_id)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def spec(self) -> InstrumentSpec:
|
|
179
|
+
"""Instrument economics for this contract."""
|
|
180
|
+
return self.ctx.instrument(self.contract_id)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def bars_gated(self) -> int:
|
|
184
|
+
"""Matching bars swallowed by the ready-gate so far."""
|
|
185
|
+
return self._bars_gated
|
|
186
|
+
|
|
187
|
+
async def handle_bar(self, bar: Bar) -> None:
|
|
188
|
+
if bar.bar_type.contract_id != self.contract_id:
|
|
189
|
+
return
|
|
190
|
+
for indicator in self._indicators:
|
|
191
|
+
indicator.update(bar)
|
|
192
|
+
self._bars_seen += 1
|
|
193
|
+
if self._gated():
|
|
194
|
+
self._bars_gated += 1
|
|
195
|
+
return
|
|
196
|
+
await self.on_bar(bar)
|
|
197
|
+
|
|
198
|
+
def _gated(self) -> bool:
|
|
199
|
+
# An explicit warmup override gates even when require_ready is off —
|
|
200
|
+
# the declared warmup must never silently understate the actual gate.
|
|
201
|
+
if self._warmup_override is not None and self._bars_seen < self._warmup_override:
|
|
202
|
+
return True
|
|
203
|
+
return self._require_ready and any(not indicator.ready for indicator in self._indicators)
|
|
204
|
+
|
|
205
|
+
async def handle_order(self, order: OrderModel) -> None:
|
|
206
|
+
if order.contract_id != self.contract_id:
|
|
207
|
+
return
|
|
208
|
+
self._orders.apply(order)
|
|
209
|
+
await self.on_order(order)
|
|
210
|
+
|
|
211
|
+
async def handle_fill(self, trade: HalfTradeModel) -> None:
|
|
212
|
+
if trade.contract_id != self.contract_id:
|
|
213
|
+
return
|
|
214
|
+
self._position.apply_fill(trade)
|
|
215
|
+
await self.on_fill(trade)
|
|
216
|
+
|
|
217
|
+
async def handle_position(self, position: PositionModel) -> None:
|
|
218
|
+
if position.contract_id != self.contract_id:
|
|
219
|
+
return
|
|
220
|
+
self._position.apply_snapshot(position)
|
|
221
|
+
await self.on_position(position)
|
|
222
|
+
|
|
223
|
+
async def on_reject(self, error: APIError) -> None:
|
|
224
|
+
"""Called with the ``APIError`` when a sugar order call is rejected.
|
|
225
|
+
|
|
226
|
+
Default: ignore (the sugar call returns ``None``).
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
async def buy(
|
|
230
|
+
self,
|
|
231
|
+
size: int,
|
|
232
|
+
*,
|
|
233
|
+
stop_loss_ticks: int | None = None,
|
|
234
|
+
take_profit_ticks: int | None = None,
|
|
235
|
+
limit_price: Decimal | None = None,
|
|
236
|
+
stop_price: Decimal | None = None,
|
|
237
|
+
custom_tag: str | None = None,
|
|
238
|
+
) -> int | None:
|
|
239
|
+
"""Buy this contract (market unless a price kwarg implies otherwise);
|
|
240
|
+
|
|
241
|
+
returns the order id — latched into ``working_orders`` at submit time —
|
|
242
|
+
or ``None`` on rejection (see ``on_reject``).
|
|
243
|
+
"""
|
|
244
|
+
order_type = _implied_type(limit_price, stop_price)
|
|
245
|
+
try:
|
|
246
|
+
order_id = await self.ctx.orders.buy(
|
|
247
|
+
self.ctx.account_id,
|
|
248
|
+
self.contract_id,
|
|
249
|
+
size,
|
|
250
|
+
type=order_type,
|
|
251
|
+
limit_price=limit_price,
|
|
252
|
+
stop_price=stop_price,
|
|
253
|
+
custom_tag=custom_tag,
|
|
254
|
+
stop_loss_ticks=stop_loss_ticks,
|
|
255
|
+
take_profit_ticks=take_profit_ticks,
|
|
256
|
+
)
|
|
257
|
+
except APIError as error:
|
|
258
|
+
await self.on_reject(error)
|
|
259
|
+
return None
|
|
260
|
+
self._latch(order_id, OrderSide.BUY, order_type, size, limit_price, stop_price, custom_tag)
|
|
261
|
+
return order_id
|
|
262
|
+
|
|
263
|
+
async def sell(
|
|
264
|
+
self,
|
|
265
|
+
size: int,
|
|
266
|
+
*,
|
|
267
|
+
stop_loss_ticks: int | None = None,
|
|
268
|
+
take_profit_ticks: int | None = None,
|
|
269
|
+
limit_price: Decimal | None = None,
|
|
270
|
+
stop_price: Decimal | None = None,
|
|
271
|
+
custom_tag: str | None = None,
|
|
272
|
+
) -> int | None:
|
|
273
|
+
"""Sell this contract (market unless a price kwarg implies otherwise);
|
|
274
|
+
|
|
275
|
+
returns the order id — latched into ``working_orders`` at submit time —
|
|
276
|
+
or ``None`` on rejection (see ``on_reject``).
|
|
277
|
+
"""
|
|
278
|
+
order_type = _implied_type(limit_price, stop_price)
|
|
279
|
+
try:
|
|
280
|
+
order_id = await self.ctx.orders.sell(
|
|
281
|
+
self.ctx.account_id,
|
|
282
|
+
self.contract_id,
|
|
283
|
+
size,
|
|
284
|
+
type=order_type,
|
|
285
|
+
limit_price=limit_price,
|
|
286
|
+
stop_price=stop_price,
|
|
287
|
+
custom_tag=custom_tag,
|
|
288
|
+
stop_loss_ticks=stop_loss_ticks,
|
|
289
|
+
take_profit_ticks=take_profit_ticks,
|
|
290
|
+
)
|
|
291
|
+
except APIError as error:
|
|
292
|
+
await self.on_reject(error)
|
|
293
|
+
return None
|
|
294
|
+
self._latch(order_id, OrderSide.SELL, order_type, size, limit_price, stop_price, custom_tag)
|
|
295
|
+
return order_id
|
|
296
|
+
|
|
297
|
+
def _latch(
|
|
298
|
+
self,
|
|
299
|
+
order_id: int,
|
|
300
|
+
side: OrderSide,
|
|
301
|
+
order_type: OrderType,
|
|
302
|
+
size: int,
|
|
303
|
+
limit_price: Decimal | None,
|
|
304
|
+
stop_price: Decimal | None,
|
|
305
|
+
custom_tag: str | None,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Fold a synthetic PENDING order for a just-submitted id, so the
|
|
308
|
+
|
|
309
|
+
working view is truthful the instant the REST call returns — live, the
|
|
310
|
+
hub ``OrderModel`` may lag past the next bar's ``on_bar``. The first
|
|
311
|
+
real event for the id supersedes (or, if terminal, pre-empts) it.
|
|
312
|
+
"""
|
|
313
|
+
self._orders.latch(
|
|
314
|
+
OrderModel(
|
|
315
|
+
id=order_id,
|
|
316
|
+
account_id=self.ctx.account_id,
|
|
317
|
+
contract_id=self.contract_id,
|
|
318
|
+
status=OrderStatus.PENDING,
|
|
319
|
+
type=order_type,
|
|
320
|
+
side=side,
|
|
321
|
+
size=size,
|
|
322
|
+
limit_price=limit_price,
|
|
323
|
+
stop_price=stop_price,
|
|
324
|
+
custom_tag=custom_tag,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
async def close(self) -> None:
|
|
329
|
+
"""Flatten this contract's position; a rejection goes to ``on_reject``."""
|
|
330
|
+
try:
|
|
331
|
+
await self.ctx.positions.close(self.ctx.account_id, self.contract_id)
|
|
332
|
+
except APIError as error:
|
|
333
|
+
await self.on_reject(error)
|
|
334
|
+
|
|
335
|
+
async def cancel_working(self) -> None:
|
|
336
|
+
"""Cancel every working order on this contract, one cancel per order;
|
|
337
|
+
|
|
338
|
+
each rejection goes to ``on_reject`` and the remaining cancels proceed.
|
|
339
|
+
"""
|
|
340
|
+
for order in self.working_orders:
|
|
341
|
+
try:
|
|
342
|
+
await self.ctx.orders.cancel(self.ctx.account_id, order.id)
|
|
343
|
+
except APIError as error:
|
|
344
|
+
await self.on_reject(error)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Pure event-fold state trackers over the SDK's user-event models.
|
|
2
|
+
|
|
3
|
+
Everything here folds ONLY ``topstep_sdk`` models (``HalfTradeModel``,
|
|
4
|
+
``PositionModel``, ``OrderModel``) — the exact payloads BOTH the ``SimBroker``
|
|
5
|
+
and the live user hub emit — so a strategy's view of its own position and
|
|
6
|
+
working orders is built identically in sim and live. That is the parity
|
|
7
|
+
argument: no broker internals, no engine imports, just event folding.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from topstep_sdk import (
|
|
13
|
+
HalfTradeModel,
|
|
14
|
+
OrderModel,
|
|
15
|
+
OrderSide,
|
|
16
|
+
OrderStatus,
|
|
17
|
+
PositionModel,
|
|
18
|
+
PositionType,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"TERMINAL_ORDER_STATUSES",
|
|
23
|
+
"NetPosition",
|
|
24
|
+
"OrderTracker",
|
|
25
|
+
"PositionTracker",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
TERMINAL_ORDER_STATUSES: frozenset[OrderStatus] = frozenset(
|
|
29
|
+
{
|
|
30
|
+
OrderStatus.FILLED,
|
|
31
|
+
OrderStatus.CANCELLED,
|
|
32
|
+
OrderStatus.EXPIRED,
|
|
33
|
+
OrderStatus.REJECTED,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
"""Statuses after which an order can never execute again. Must match the sim
|
|
37
|
+
broker's terminal set (asserted in tests) so ``OrderTracker.working`` mirrors
|
|
38
|
+
``orders.search_open()`` in both sim and live."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NetPosition:
|
|
42
|
+
"""Signed net contracts for one instrument (positive = long).
|
|
43
|
+
|
|
44
|
+
``apply_fill`` is the incremental path (one ``GatewayUserTrade`` at a
|
|
45
|
+
time); ``apply_snapshot`` is the authoritative reconcile — a broker
|
|
46
|
+
``PositionModel`` overrides any fill-derived drift.
|
|
47
|
+
|
|
48
|
+
Folding assumes per-contract events arrive in execution order (each trade
|
|
49
|
+
before the snapshot that already includes it) — the sim guarantees this;
|
|
50
|
+
a live driver must serialize the trade/position hub callbacks per
|
|
51
|
+
contract. Neither model carries a sequence field, so a snapshot that
|
|
52
|
+
outruns its own trade double-counts that trade until the next snapshot
|
|
53
|
+
reconciles.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("net",)
|
|
57
|
+
|
|
58
|
+
def __init__(self, net: int = 0) -> None:
|
|
59
|
+
self.net = net
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_long(self) -> bool:
|
|
63
|
+
return self.net > 0
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_short(self) -> bool:
|
|
67
|
+
return self.net < 0
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def flat(self) -> bool:
|
|
71
|
+
return self.net == 0
|
|
72
|
+
|
|
73
|
+
def __bool__(self) -> bool:
|
|
74
|
+
return self.net != 0
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
return f"NetPosition(net={self.net})"
|
|
78
|
+
|
|
79
|
+
def apply_fill(self, trade: HalfTradeModel) -> None:
|
|
80
|
+
if trade.voided: # a busted trade never changed the position
|
|
81
|
+
return
|
|
82
|
+
direction = 1 if trade.side == OrderSide.BID else -1 # BUY==BID==0
|
|
83
|
+
self.net += direction * trade.size
|
|
84
|
+
|
|
85
|
+
def apply_snapshot(self, position: PositionModel) -> None:
|
|
86
|
+
if position.type == PositionType.LONG:
|
|
87
|
+
self.net = position.size
|
|
88
|
+
elif position.type == PositionType.SHORT:
|
|
89
|
+
self.net = -position.size
|
|
90
|
+
else: # closed/undefined snapshot: flat
|
|
91
|
+
self.net = 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PositionTracker:
|
|
95
|
+
"""Per-contract ``NetPosition``s, routed by ``contract_id``."""
|
|
96
|
+
|
|
97
|
+
__slots__ = ("_by_contract",)
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._by_contract: dict[str, NetPosition] = {}
|
|
101
|
+
|
|
102
|
+
def net(self, contract_id: str) -> NetPosition:
|
|
103
|
+
found = self._by_contract.get(contract_id)
|
|
104
|
+
if found is None:
|
|
105
|
+
found = NetPosition()
|
|
106
|
+
self._by_contract[contract_id] = found
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
def apply_fill(self, trade: HalfTradeModel) -> None:
|
|
110
|
+
self.net(trade.contract_id).apply_fill(trade)
|
|
111
|
+
|
|
112
|
+
def apply_snapshot(self, position: PositionModel) -> None:
|
|
113
|
+
self.net(position.contract_id).apply_snapshot(position)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class OrderTracker:
|
|
117
|
+
"""Latest ``OrderModel`` per order id, folded from ``on_order`` events.
|
|
118
|
+
|
|
119
|
+
``working`` mirrors what ``orders.search_open()`` would return once all
|
|
120
|
+
queued events are drained — without a broker round-trip. Terminal orders
|
|
121
|
+
are pruned on arrival (only their ids are retained), so a long live
|
|
122
|
+
session holds state proportional to its working orders, not its history.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
__slots__ = ("_by_id", "_terminal_ids")
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
self._by_id: dict[int, OrderModel] = {}
|
|
129
|
+
self._terminal_ids: set[int] = set()
|
|
130
|
+
|
|
131
|
+
def apply(self, order: OrderModel) -> None:
|
|
132
|
+
if order.status in TERMINAL_ORDER_STATUSES:
|
|
133
|
+
self._by_id.pop(order.id, None)
|
|
134
|
+
self._terminal_ids.add(order.id)
|
|
135
|
+
elif order.id not in self._terminal_ids: # reordered stale event: never resurrect
|
|
136
|
+
self._by_id[order.id] = order
|
|
137
|
+
|
|
138
|
+
def latch(self, order: OrderModel) -> None:
|
|
139
|
+
"""Fold a submit-time synthetic order, closing the live window between
|
|
140
|
+
the REST return and the hub confirmation; any already-seen event for
|
|
141
|
+
the id (the hub can outrun the REST response) wins over the synthetic.
|
|
142
|
+
"""
|
|
143
|
+
if order.id not in self._terminal_ids and order.id not in self._by_id:
|
|
144
|
+
self._by_id[order.id] = order
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def working(self) -> tuple[OrderModel, ...]:
|
|
148
|
+
return tuple(order for _, order in sorted(self._by_id.items()))
|
|
149
|
+
|
|
150
|
+
def working_for(self, contract_id: str) -> tuple[OrderModel, ...]:
|
|
151
|
+
return tuple(order for order in self.working if order.contract_id == contract_id)
|