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,1436 @@
|
|
|
1
|
+
"""SimBroker: deterministic execution simulation behind the ``Broker`` protocol.
|
|
2
|
+
|
|
3
|
+
Implements the SAME structural protocol ``AsyncTopstepClient`` satisfies, so a
|
|
4
|
+
strategy wired to a SimBroker runs unchanged live. The broker owns order
|
|
5
|
+
lifecycle, OCO/bracket linkage, trailing-stop recomputation, position netting,
|
|
6
|
+
P&L (exact Decimal), and wires every equity change through the Combine rule
|
|
7
|
+
kernel — including intrabar breach detection along the deterministic price
|
|
8
|
+
path, and forced liquidation with its own (worse) slippage plus Topstep's
|
|
9
|
+
$10/contract automatic-liquidation fee.
|
|
10
|
+
|
|
11
|
+
Accounting: positions are FIFO lots with an exact Decimal COST BASIS. Every
|
|
12
|
+
stored lot price is on the tick grid; unrealized P&L is division-free
|
|
13
|
+
(``core.money.position_unrealized``), so scale-ins can never produce an
|
|
14
|
+
off-grid average or rounding dust. Per-half-turn ``profit_and_loss`` uses FIFO
|
|
15
|
+
lot attribution (documented assumption pending gateway calibration —
|
|
16
|
+
docs/DESIGN.md §15).
|
|
17
|
+
|
|
18
|
+
Key correctness properties:
|
|
19
|
+
- No look-ahead: orders participate in a bar only if accepted at/before its
|
|
20
|
+
open (the fill model's ``accepted_ts`` firewall); trailing stops likewise
|
|
21
|
+
only ratchet from bars the order actually lived through; bracket children
|
|
22
|
+
created on an intrabar fill first participate the NEXT bar.
|
|
23
|
+
- Deterministic intrabar resolution: fills and rule-breach liquidations are
|
|
24
|
+
ordered along ONE shared pessimistic price path by TRIGGER level (never by
|
|
25
|
+
slippage-adjusted fill prices), and equity is re-checked AT each fill
|
|
26
|
+
point after it applies (slippage + fees can themselves breach).
|
|
27
|
+
- Every rejection is the SDK's ``APIError`` with the gateway's error code.
|
|
28
|
+
|
|
29
|
+
Documented Tier-0 divergences from live (see docs/DESIGN.md §13.8): market
|
|
30
|
+
orders — including ``positions.close``/``partial_close`` — fill at the NEXT
|
|
31
|
+
bar's open (not instantly); stored bars are not live tape-built bars;
|
|
32
|
+
``wait_for_fill`` raises ``UnsupportedInBacktestError``; STOP_LIMIT and
|
|
33
|
+
JOIN_BID/JOIN_ASK are rejected (they need quote data, Tier-1+).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# pyright: reportPrivateUsage=false
|
|
37
|
+
# (the Sim*Api facades cooperate with SimBroker internals within this module by design)
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
from decimal import Decimal
|
|
43
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
44
|
+
|
|
45
|
+
from topstep_sdk import (
|
|
46
|
+
AggregateBarUnit,
|
|
47
|
+
APIError,
|
|
48
|
+
HalfTradeModel,
|
|
49
|
+
OrderModel,
|
|
50
|
+
OrderSide,
|
|
51
|
+
OrderStatus,
|
|
52
|
+
OrderType,
|
|
53
|
+
PlaceOrderBracket,
|
|
54
|
+
PositionModel,
|
|
55
|
+
PositionType,
|
|
56
|
+
)
|
|
57
|
+
from topstep_sdk.models.history import AggregateBarModel
|
|
58
|
+
|
|
59
|
+
from ..core.ids import IdGenerator
|
|
60
|
+
from ..core.instruments import InstrumentSpec
|
|
61
|
+
from ..core.money import is_on_grid, position_unrealized, round_to_tick, to_ticks
|
|
62
|
+
from ..core.time import TOPSTEP_SESSION, SessionTimes, dt_to_ns, ns_to_dt, trading_day_of
|
|
63
|
+
from ..fills.path import build_path
|
|
64
|
+
from ..protocols import (
|
|
65
|
+
Bar,
|
|
66
|
+
Fill,
|
|
67
|
+
FillModel,
|
|
68
|
+
Liquidity,
|
|
69
|
+
MarketContext,
|
|
70
|
+
PricePath,
|
|
71
|
+
WorkingOrder,
|
|
72
|
+
)
|
|
73
|
+
from ..rules.kernel import Breach, BreachKind, CombineKernel, Verdict
|
|
74
|
+
from .rejections import UnsupportedInBacktestError, reject_cancel, reject_modify, reject_place
|
|
75
|
+
|
|
76
|
+
if TYPE_CHECKING:
|
|
77
|
+
from collections.abc import Mapping
|
|
78
|
+
|
|
79
|
+
from ..protocols import Clock, FeeModel
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"SimBroker",
|
|
83
|
+
"SimBrokerConfig",
|
|
84
|
+
"SimHistoryApi",
|
|
85
|
+
"SimOrderApi",
|
|
86
|
+
"SimPositionApi",
|
|
87
|
+
"UserEvent",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
UserEvent = OrderModel | HalfTradeModel | PositionModel
|
|
91
|
+
"""Events queued for strategy dispatch (mirrors the SDK user hub payloads)."""
|
|
92
|
+
|
|
93
|
+
_TERMINAL = {
|
|
94
|
+
OrderStatus.FILLED,
|
|
95
|
+
OrderStatus.CANCELLED,
|
|
96
|
+
OrderStatus.REJECTED,
|
|
97
|
+
OrderStatus.EXPIRED,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SimBrokerConfig:
|
|
102
|
+
"""Tunables that are broker-level (not fill-model-level)."""
|
|
103
|
+
|
|
104
|
+
__slots__ = (
|
|
105
|
+
"forced_liq_slippage_ticks",
|
|
106
|
+
"history_depth",
|
|
107
|
+
"liquidation_fee_per_contract",
|
|
108
|
+
"max_trail_ticks",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
forced_liq_slippage_ticks: int = 2,
|
|
115
|
+
liquidation_fee_per_contract: Decimal = Decimal("10"),
|
|
116
|
+
max_trail_ticks: int = 1000,
|
|
117
|
+
history_depth: int = 20_000,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.forced_liq_slippage_ticks = forced_liq_slippage_ticks
|
|
120
|
+
self.liquidation_fee_per_contract = liquidation_fee_per_contract
|
|
121
|
+
self.max_trail_ticks = max_trail_ticks
|
|
122
|
+
self.history_depth = history_depth
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _Lot:
|
|
126
|
+
"""One FIFO entry lot; ``price`` is always on the tick grid."""
|
|
127
|
+
|
|
128
|
+
__slots__ = ("price", "qty")
|
|
129
|
+
|
|
130
|
+
def __init__(self, price: Decimal, qty: int) -> None:
|
|
131
|
+
self.price = price
|
|
132
|
+
self.qty = qty
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _Position:
|
|
136
|
+
"""Netted position as FIFO lots with an exact cost basis (no divisions)."""
|
|
137
|
+
|
|
138
|
+
__slots__ = ("contract_id", "direction", "lots", "opened_ts", "position_id")
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
position_id: int,
|
|
144
|
+
contract_id: str,
|
|
145
|
+
direction: int,
|
|
146
|
+
opened_ts: int,
|
|
147
|
+
) -> None:
|
|
148
|
+
self.position_id = position_id
|
|
149
|
+
self.contract_id = contract_id
|
|
150
|
+
self.direction = direction # +1 long, -1 short
|
|
151
|
+
self.lots: list[_Lot] = []
|
|
152
|
+
self.opened_ts = opened_ts
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def qty(self) -> int:
|
|
156
|
+
return sum(lot.qty for lot in self.lots)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def cost(self) -> Decimal:
|
|
160
|
+
"""Exact sum(entry_price * qty) over open lots."""
|
|
161
|
+
return sum((lot.price * lot.qty for lot in self.lots), Decimal(0))
|
|
162
|
+
|
|
163
|
+
def display_avg(self, spec: InstrumentSpec) -> Decimal:
|
|
164
|
+
"""Average entry for display (``PositionModel.average_price``).
|
|
165
|
+
|
|
166
|
+
Quantized to tick/100 so a repeating decimal (three lots averaging to
|
|
167
|
+
thirds) never leaks into a reported field; NEVER used in P&L math.
|
|
168
|
+
"""
|
|
169
|
+
quantum = spec.tick_size / 100
|
|
170
|
+
return (self.cost / self.qty).quantize(quantum)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _PendingBrackets:
|
|
174
|
+
__slots__ = ("sl_ticks", "tp_ticks")
|
|
175
|
+
|
|
176
|
+
def __init__(self, sl_ticks: int | None, tp_ticks: int | None) -> None:
|
|
177
|
+
self.sl_ticks = sl_ticks # signed offset from entry
|
|
178
|
+
self.tp_ticks = tp_ticks
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _as_decimal(value: float | Decimal) -> Decimal:
|
|
182
|
+
return value if isinstance(value, Decimal) else Decimal(str(value))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _side_dir(side: OrderSide | int) -> int:
|
|
186
|
+
return 1 if int(side) == int(OrderSide.BUY) else -1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class SimBroker:
|
|
190
|
+
"""Deterministic simulated venue satisfying the ``Broker`` protocol."""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
account_id: int,
|
|
196
|
+
instruments: Mapping[str, InstrumentSpec],
|
|
197
|
+
fill_model: FillModel,
|
|
198
|
+
fee_model: FeeModel,
|
|
199
|
+
kernel: CombineKernel,
|
|
200
|
+
clock: Clock,
|
|
201
|
+
ids: IdGenerator | None = None,
|
|
202
|
+
config: SimBrokerConfig | None = None,
|
|
203
|
+
session: SessionTimes = TOPSTEP_SESSION,
|
|
204
|
+
) -> None:
|
|
205
|
+
self._account_id = account_id
|
|
206
|
+
self._instruments = dict(instruments)
|
|
207
|
+
self._fill_model = fill_model
|
|
208
|
+
self._fee_model = fee_model
|
|
209
|
+
self._kernel = kernel
|
|
210
|
+
self._clock = clock
|
|
211
|
+
self._ids = ids or IdGenerator()
|
|
212
|
+
self._config = config or SimBrokerConfig()
|
|
213
|
+
self._session = session
|
|
214
|
+
|
|
215
|
+
self._balance: Decimal = kernel.params.starting_balance
|
|
216
|
+
self._orders: dict[int, WorkingOrder] = {}
|
|
217
|
+
self._brackets: dict[int, _PendingBrackets] = {}
|
|
218
|
+
self._oco: dict[int, int] = {} # order id -> OCO sibling id (both directions)
|
|
219
|
+
self._positions: dict[str, _Position] = {}
|
|
220
|
+
self._last_bar: dict[str, Bar] = {}
|
|
221
|
+
self._history: dict[str, list[Bar]] = {}
|
|
222
|
+
self._events: list[UserEvent] = []
|
|
223
|
+
self._trades: list[HalfTradeModel] = []
|
|
224
|
+
# Rejected placements, by gateway error_code. A strategy that silently
|
|
225
|
+
# swallows APIError (the SymbolStrategy sugar routes it to on_reject,
|
|
226
|
+
# whose default is a no-op) otherwise produces a clean-looking report
|
|
227
|
+
# in which nothing was ever executed. Surfaced on BacktestResult.
|
|
228
|
+
self._rejections: dict[int, int] = {}
|
|
229
|
+
self._used_tags: set[str] = set()
|
|
230
|
+
self._halted = False
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
# Broker protocol surface
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def orders(self) -> SimOrderApi:
|
|
238
|
+
return SimOrderApi(self)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def positions(self) -> SimPositionApi:
|
|
242
|
+
return SimPositionApi(self)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def history(self) -> SimHistoryApi:
|
|
246
|
+
return SimHistoryApi(self)
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def rejections(self) -> dict[int, int]:
|
|
250
|
+
"""Count of rejected placements, keyed by gateway ``error_code``."""
|
|
251
|
+
return dict(self._rejections)
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
# Engine-facing surface
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def account_id(self) -> int:
|
|
259
|
+
return self._account_id
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def instruments(self) -> dict[str, InstrumentSpec]:
|
|
263
|
+
return dict(self._instruments)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def balance(self) -> Decimal:
|
|
267
|
+
return self._balance
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def kernel(self) -> CombineKernel:
|
|
271
|
+
return self._kernel
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def trades(self) -> tuple[HalfTradeModel, ...]:
|
|
275
|
+
return tuple(self._trades)
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def dead(self) -> bool:
|
|
279
|
+
return self._kernel.verdict is Verdict.FAILED or self._halted
|
|
280
|
+
|
|
281
|
+
def drain_events(self) -> list[UserEvent]:
|
|
282
|
+
events, self._events = self._events, []
|
|
283
|
+
return events
|
|
284
|
+
|
|
285
|
+
def equity(self) -> Decimal:
|
|
286
|
+
"""Realized balance + open P&L marked at each contract's last close."""
|
|
287
|
+
total = self._balance
|
|
288
|
+
for cid, pos in self._positions.items():
|
|
289
|
+
last = self._last_bar.get(cid)
|
|
290
|
+
if last is not None:
|
|
291
|
+
total += self._unrealized(pos, last.close)
|
|
292
|
+
return total
|
|
293
|
+
|
|
294
|
+
def on_bar(self, bar: Bar) -> None:
|
|
295
|
+
"""Phase-1 matching: trailing ratchet, path walk with interleaved
|
|
296
|
+
|
|
297
|
+
fill/breach resolution, then the end-of-bar equity check.
|
|
298
|
+
"""
|
|
299
|
+
cid = bar.bar_type.contract_id
|
|
300
|
+
spec = self._instruments.get(cid)
|
|
301
|
+
if spec is None:
|
|
302
|
+
raise KeyError(f"bar for unknown contract {cid!r}: register it in `instruments`")
|
|
303
|
+
|
|
304
|
+
prev = self._last_bar.get(cid)
|
|
305
|
+
self._ratchet_trailing(cid, prev, spec)
|
|
306
|
+
|
|
307
|
+
if self._kernel.verdict is not Verdict.FAILED:
|
|
308
|
+
self._walk_bar(bar, spec, prev)
|
|
309
|
+
|
|
310
|
+
self._last_bar[cid] = bar
|
|
311
|
+
bucket = self._history.setdefault(cid, [])
|
|
312
|
+
bucket.append(bar)
|
|
313
|
+
if len(bucket) > self._config.history_depth:
|
|
314
|
+
del bucket[: len(bucket) - self._config.history_depth]
|
|
315
|
+
|
|
316
|
+
def flatten_all(self, ts_ns: int, *, reason: str) -> None:
|
|
317
|
+
"""Topstep auto-flatten enforcement (16:10 ET / day roll): market-dump
|
|
318
|
+
|
|
319
|
+
every position with forced slippage AND the automatic-liquidation fee,
|
|
320
|
+
then cancel every working order. Strategy-initiated exits should
|
|
321
|
+
happen earlier via ``positions.close`` (which does NOT pay this fee).
|
|
322
|
+
"""
|
|
323
|
+
for cid in list(self._positions):
|
|
324
|
+
last = self._last_bar.get(cid)
|
|
325
|
+
if last is None: # pragma: no cover - position implies a seen bar
|
|
326
|
+
continue
|
|
327
|
+
self._close_position_at(
|
|
328
|
+
cid,
|
|
329
|
+
last.close,
|
|
330
|
+
ts_ns,
|
|
331
|
+
note=reason,
|
|
332
|
+
slippage_ticks=self._config.forced_liq_slippage_ticks,
|
|
333
|
+
liquidation=True,
|
|
334
|
+
)
|
|
335
|
+
self._cancel_all_working(ts_ns)
|
|
336
|
+
|
|
337
|
+
def session_close(self, ts_ns: int) -> None:
|
|
338
|
+
"""EOD hook: report the closed balance to the rule kernel (MLL ratchet)."""
|
|
339
|
+
self._kernel.on_session_close(ts_ns, self._balance)
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# Bar walk: deterministic intrabar fill/breach interleaving
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def _walk_bar(self, bar: Bar, spec: InstrumentSpec, prev: Bar | None) -> None:
|
|
346
|
+
cid = bar.bar_type.contract_id
|
|
347
|
+
pos = self._positions.get(cid)
|
|
348
|
+
path = build_path(bar, 0 if pos is None else pos.direction)
|
|
349
|
+
ctx = MarketContext(
|
|
350
|
+
ts_event=bar.ts_event,
|
|
351
|
+
ts_init=bar.ts_init,
|
|
352
|
+
instrument=spec,
|
|
353
|
+
bar=bar,
|
|
354
|
+
prev_close=None if prev is None else prev.close,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Candidate fills are computed once against the bar-start order book
|
|
358
|
+
# (no new orders can join mid-bar: children created on an intrabar fill
|
|
359
|
+
# carry accepted_ts stamps that defer them to the next bar). Ordering
|
|
360
|
+
# is by TRIGGER level along the path — never slippage-adjusted prices.
|
|
361
|
+
candidates: list[tuple[tuple[int, Decimal], Fill]] = []
|
|
362
|
+
for order in self._orders_on(cid):
|
|
363
|
+
for fill in self._fill_model.try_fill(order, ctx, path):
|
|
364
|
+
trigger = fill.trigger_price if fill.trigger_price is not None else fill.price
|
|
365
|
+
candidates.append((self._path_key(path, fill.seq, trigger), fill))
|
|
366
|
+
candidates.sort(key=lambda item: (item[0][0], item[0][1], item[1].order_id))
|
|
367
|
+
|
|
368
|
+
cursor: tuple[int, Decimal] = (0, Decimal(0))
|
|
369
|
+
for key, fill in candidates:
|
|
370
|
+
order = self._orders.get(fill.order_id)
|
|
371
|
+
if order is None or order.status is not OrderStatus.OPEN:
|
|
372
|
+
continue # cancelled mid-walk (OCO sibling)
|
|
373
|
+
if self._breach_before(bar, spec, path, cursor, key):
|
|
374
|
+
return # account liquidated (MLL) or day-locked flatten (DLL)
|
|
375
|
+
self._apply_fill(order, fill, spec)
|
|
376
|
+
cursor = key
|
|
377
|
+
if self.dead:
|
|
378
|
+
return
|
|
379
|
+
# Slippage + fees from the fill itself can push equity through a
|
|
380
|
+
# threshold AT this very point — check before walking on.
|
|
381
|
+
if self._breach_at_price(bar, spec, fill.price):
|
|
382
|
+
return
|
|
383
|
+
end_key = (len(path) - 1, Decimal(0))
|
|
384
|
+
self._breach_before(bar, spec, path, cursor, end_key)
|
|
385
|
+
|
|
386
|
+
def _path_key(self, path: PricePath, seq: int, price: Decimal) -> tuple[int, Decimal]:
|
|
387
|
+
"""Total order along the path: (segment index, distance from segment start)."""
|
|
388
|
+
start = path[seq].price
|
|
389
|
+
return (seq, abs(price - start))
|
|
390
|
+
|
|
391
|
+
def _breach_at_price(self, bar: Bar, spec: InstrumentSpec, mark: Decimal) -> bool:
|
|
392
|
+
"""Equity check with the CURRENT state marking this contract at ``mark``."""
|
|
393
|
+
cid = bar.bar_type.contract_id
|
|
394
|
+
pos = self._positions.get(cid)
|
|
395
|
+
equity = self._balance + self._unrealized_except(cid)
|
|
396
|
+
if pos is not None:
|
|
397
|
+
equity += self._unrealized(pos, mark)
|
|
398
|
+
breach = self._kernel.check_equity(bar.ts_event, equity)
|
|
399
|
+
if breach is not None:
|
|
400
|
+
self._execute_breach(breach, cid, mark if pos is not None else None, bar.ts_event)
|
|
401
|
+
return True
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
def _breach_before(
|
|
405
|
+
self,
|
|
406
|
+
bar: Bar,
|
|
407
|
+
spec: InstrumentSpec,
|
|
408
|
+
path: PricePath,
|
|
409
|
+
cursor: tuple[int, Decimal],
|
|
410
|
+
until: tuple[int, Decimal],
|
|
411
|
+
) -> bool:
|
|
412
|
+
"""Detect and execute the FIRST rule breach at/before ``until``.
|
|
413
|
+
|
|
414
|
+
Walks path segments from ``cursor`` to ``until`` with the CURRENT
|
|
415
|
+
position/balance, finds the earliest grid price where equity reaches
|
|
416
|
+
the MLL floor (or DLL threshold), and executes the forced action
|
|
417
|
+
there. Ties at ``until`` (a fill at the exact breach price) resolve to
|
|
418
|
+
the BREACH — Topstep liquidates the moment equity touches the limit
|
|
419
|
+
(documented conservative choice). Returns True if action was taken.
|
|
420
|
+
"""
|
|
421
|
+
cid = bar.bar_type.contract_id
|
|
422
|
+
pos = self._positions.get(cid)
|
|
423
|
+
|
|
424
|
+
if pos is None:
|
|
425
|
+
# Flat in this contract: equity is constant along the path.
|
|
426
|
+
return self._breach_at_price(bar, spec, bar.open)
|
|
427
|
+
|
|
428
|
+
other_unreal = self._unrealized_except(cid)
|
|
429
|
+
last_seg = min(until[0], len(path) - 2)
|
|
430
|
+
for seg in range(cursor[0], last_seg + 1):
|
|
431
|
+
seg_start = path[seg].price
|
|
432
|
+
seg_end = path[seg + 1].price
|
|
433
|
+
hit = self._segment_trigger(pos, spec, other_unreal, seg_start, seg_end)
|
|
434
|
+
if hit is None:
|
|
435
|
+
continue
|
|
436
|
+
distance = abs(hit - seg_start)
|
|
437
|
+
key = (seg, distance)
|
|
438
|
+
if key < cursor:
|
|
439
|
+
continue # already walked past this point
|
|
440
|
+
if key > until:
|
|
441
|
+
return False # first breach lies beyond the next fill
|
|
442
|
+
equity = self._balance + other_unreal + self._unrealized(pos, hit)
|
|
443
|
+
breach = self._kernel.check_equity(bar.ts_event, equity)
|
|
444
|
+
if breach is not None:
|
|
445
|
+
self._execute_breach(breach, cid, hit, bar.ts_event)
|
|
446
|
+
return True
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
def _active_thresholds(self) -> list[Decimal]:
|
|
450
|
+
"""Equity levels at which a rule action fires (MLL floor, DLL line)."""
|
|
451
|
+
levels = [self._kernel.floor]
|
|
452
|
+
dll = self._kernel.params.dll
|
|
453
|
+
if dll is not None and not self._kernel.day_locked:
|
|
454
|
+
levels.append(self._kernel.day_start_balance - dll)
|
|
455
|
+
return levels
|
|
456
|
+
|
|
457
|
+
def _segment_trigger(
|
|
458
|
+
self,
|
|
459
|
+
pos: _Position,
|
|
460
|
+
spec: InstrumentSpec,
|
|
461
|
+
other_unreal: Decimal,
|
|
462
|
+
seg_start: Decimal,
|
|
463
|
+
seg_end: Decimal,
|
|
464
|
+
) -> Decimal | None:
|
|
465
|
+
"""Earliest price along [seg_start -> seg_end] where equity reaches an
|
|
466
|
+
|
|
467
|
+
active threshold, or None. Prices print on the tick grid, so the
|
|
468
|
+
trigger is the first grid level whose equity is at/below a threshold.
|
|
469
|
+
Solved from the exact cost basis with a SINGLE Decimal division, so a
|
|
470
|
+
threshold landing exactly on the grid is computed exactly.
|
|
471
|
+
"""
|
|
472
|
+
point_value = spec.point_value
|
|
473
|
+
qty = pos.qty
|
|
474
|
+
cost = pos.cost
|
|
475
|
+
direction = pos.direction
|
|
476
|
+
best: Decimal | None = None
|
|
477
|
+
descending = seg_end < seg_start
|
|
478
|
+
for level in self._active_thresholds():
|
|
479
|
+
# equity(p) = balance + other + (p*qty - cost)*dir*pv <= level
|
|
480
|
+
# => p* = (level - balance - other + cost*dir*pv) / (qty*dir*pv)
|
|
481
|
+
numerator = level - self._balance - other_unreal + cost * direction * point_value
|
|
482
|
+
raw = numerator / (qty * direction * point_value)
|
|
483
|
+
if direction > 0:
|
|
484
|
+
trigger = round_to_tick(raw, spec.tick_size, mode="down")
|
|
485
|
+
if seg_start <= trigger:
|
|
486
|
+
hit = seg_start # already at/below the threshold entering the segment
|
|
487
|
+
elif descending and seg_end <= trigger:
|
|
488
|
+
hit = trigger # crossed while moving down
|
|
489
|
+
else:
|
|
490
|
+
continue
|
|
491
|
+
else:
|
|
492
|
+
trigger = round_to_tick(raw, spec.tick_size, mode="up")
|
|
493
|
+
if seg_start >= trigger:
|
|
494
|
+
hit = seg_start
|
|
495
|
+
elif not descending and seg_end >= trigger:
|
|
496
|
+
hit = trigger
|
|
497
|
+
else:
|
|
498
|
+
continue
|
|
499
|
+
if best is None or abs(hit - seg_start) < abs(best - seg_start):
|
|
500
|
+
best = hit
|
|
501
|
+
return best
|
|
502
|
+
|
|
503
|
+
def _execute_breach(
|
|
504
|
+
self, breach: Breach, cid: str, trigger_price: Decimal | None, ts_ns: int
|
|
505
|
+
) -> None:
|
|
506
|
+
"""Forced action: MLL -> liquidate everything (account dead);
|
|
507
|
+
|
|
508
|
+
DLL -> flatten and lock the day (account survives). Both are market
|
|
509
|
+
dumps: forced slippage + the automatic-liquidation fee apply.
|
|
510
|
+
"""
|
|
511
|
+
slip = self._config.forced_liq_slippage_ticks
|
|
512
|
+
note = "forced_liquidation" if breach.kind is BreachKind.MLL else "dll_flatten"
|
|
513
|
+
pos = self._positions.get(cid)
|
|
514
|
+
if pos is not None and trigger_price is not None:
|
|
515
|
+
self._close_position_at(
|
|
516
|
+
cid, trigger_price, ts_ns, note=note, slippage_ticks=slip, liquidation=True
|
|
517
|
+
)
|
|
518
|
+
for other_cid in list(self._positions):
|
|
519
|
+
last = self._last_bar.get(other_cid)
|
|
520
|
+
if last is not None:
|
|
521
|
+
self._close_position_at(
|
|
522
|
+
other_cid, last.close, ts_ns, note=note, slippage_ticks=slip, liquidation=True
|
|
523
|
+
)
|
|
524
|
+
self._cancel_all_working(ts_ns)
|
|
525
|
+
if breach.kind is BreachKind.MLL:
|
|
526
|
+
self._halted = True
|
|
527
|
+
|
|
528
|
+
# ------------------------------------------------------------------
|
|
529
|
+
# Fill application, netting, P&L
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
def _apply_fill(self, order: WorkingOrder, fill: Fill, spec: InstrumentSpec) -> None:
|
|
533
|
+
if not is_on_grid(fill.price, spec.tick_size): # hard invariant
|
|
534
|
+
raise AssertionError(f"fill off tick grid: {fill.price} tick={spec.tick_size}")
|
|
535
|
+
|
|
536
|
+
qty = fill.qty
|
|
537
|
+
if order.reduce_only:
|
|
538
|
+
# Reduce-only orders (bracket children, position closes) can never
|
|
539
|
+
# open or flip exposure: clamp to the live position, cancel if none.
|
|
540
|
+
pos = self._positions.get(order.contract_id)
|
|
541
|
+
if pos is None or pos.direction == _side_dir(order.side):
|
|
542
|
+
self._cancel_order_internal(order.order_id, fill.ts_event)
|
|
543
|
+
return
|
|
544
|
+
qty = min(qty, pos.qty)
|
|
545
|
+
|
|
546
|
+
fees, commissions = self._fee_model.fee(spec, order.side, qty, fill.liquidity)
|
|
547
|
+
realized = self._net_into_position(order, fill.price, qty, fill.ts_event, spec)
|
|
548
|
+
if realized is not None:
|
|
549
|
+
self._balance += realized
|
|
550
|
+
self._balance -= fees + commissions
|
|
551
|
+
|
|
552
|
+
order.filled_qty += qty
|
|
553
|
+
if order.avg_fill_price is None:
|
|
554
|
+
order.avg_fill_price = fill.price
|
|
555
|
+
else: # Tier-0 emits single fills; weighted for future partial-fill tiers
|
|
556
|
+
total = order.filled_qty
|
|
557
|
+
prior = order.avg_fill_price * (total - qty)
|
|
558
|
+
order.avg_fill_price = ((prior + fill.price * qty) / total).quantize(
|
|
559
|
+
spec.tick_size / 100
|
|
560
|
+
)
|
|
561
|
+
if order.remaining == 0 or order.reduce_only:
|
|
562
|
+
order.status = OrderStatus.FILLED
|
|
563
|
+
self._emit_order(order)
|
|
564
|
+
|
|
565
|
+
trade = HalfTradeModel(
|
|
566
|
+
id=self._ids.next(),
|
|
567
|
+
account_id=self._account_id,
|
|
568
|
+
contract_id=order.contract_id,
|
|
569
|
+
price=fill.price,
|
|
570
|
+
fees=fees,
|
|
571
|
+
side=order.side,
|
|
572
|
+
size=qty,
|
|
573
|
+
voided=False,
|
|
574
|
+
order_id=order.order_id,
|
|
575
|
+
creation_timestamp=ns_to_dt(fill.ts_event),
|
|
576
|
+
profit_and_loss=realized,
|
|
577
|
+
commissions=commissions,
|
|
578
|
+
)
|
|
579
|
+
self._trades.append(trade)
|
|
580
|
+
self._events.append(trade)
|
|
581
|
+
self._kernel.on_trade_activity(fill.ts_event)
|
|
582
|
+
|
|
583
|
+
sibling_id = self._oco.get(order.order_id)
|
|
584
|
+
if sibling_id is not None:
|
|
585
|
+
self._cancel_order_internal(sibling_id, fill.ts_event)
|
|
586
|
+
|
|
587
|
+
pending = self._brackets.pop(order.order_id, None)
|
|
588
|
+
if pending is not None and order.status is OrderStatus.FILLED:
|
|
589
|
+
self._create_bracket_children(order, fill, spec, pending)
|
|
590
|
+
|
|
591
|
+
pos = self._positions.get(order.contract_id)
|
|
592
|
+
if pos is None:
|
|
593
|
+
self._cancel_reduce_only(order.contract_id, fill.ts_event)
|
|
594
|
+
else:
|
|
595
|
+
self._emit_position(pos, spec)
|
|
596
|
+
|
|
597
|
+
def _net_into_position(
|
|
598
|
+
self, order: WorkingOrder, price: Decimal, qty: int, ts_event: int, spec: InstrumentSpec
|
|
599
|
+
) -> Decimal | None:
|
|
600
|
+
"""Apply a fill to the netted FIFO position. Returns gross realized P&L
|
|
601
|
+
|
|
602
|
+
for the closing portion (None for a pure opening fill). FIFO lot
|
|
603
|
+
attribution keeps every stored price on-grid and every P&L exact.
|
|
604
|
+
"""
|
|
605
|
+
cid = order.contract_id
|
|
606
|
+
fill_dir = _side_dir(order.side)
|
|
607
|
+
pos = self._positions.get(cid)
|
|
608
|
+
|
|
609
|
+
if pos is None or pos.direction == fill_dir:
|
|
610
|
+
if pos is None:
|
|
611
|
+
pos = _Position(
|
|
612
|
+
position_id=self._ids.next(),
|
|
613
|
+
contract_id=cid,
|
|
614
|
+
direction=fill_dir,
|
|
615
|
+
opened_ts=ts_event,
|
|
616
|
+
)
|
|
617
|
+
self._positions[cid] = pos
|
|
618
|
+
pos.lots.append(_Lot(price, qty))
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
realized = Decimal(0)
|
|
622
|
+
remaining = qty
|
|
623
|
+
point_value = spec.point_value
|
|
624
|
+
while remaining > 0 and pos.lots:
|
|
625
|
+
lot = pos.lots[0]
|
|
626
|
+
closed = min(remaining, lot.qty)
|
|
627
|
+
realized += (price - lot.price) * pos.direction * closed * point_value
|
|
628
|
+
lot.qty -= closed
|
|
629
|
+
remaining -= closed
|
|
630
|
+
if lot.qty == 0:
|
|
631
|
+
pos.lots.pop(0)
|
|
632
|
+
if not pos.lots:
|
|
633
|
+
del self._positions[cid]
|
|
634
|
+
if remaining > 0: # flip: remainder opens the other way at fill price
|
|
635
|
+
flipped = _Position(
|
|
636
|
+
position_id=self._ids.next(),
|
|
637
|
+
contract_id=cid,
|
|
638
|
+
direction=fill_dir,
|
|
639
|
+
opened_ts=ts_event,
|
|
640
|
+
)
|
|
641
|
+
flipped.lots.append(_Lot(price, remaining))
|
|
642
|
+
self._positions[cid] = flipped
|
|
643
|
+
return realized
|
|
644
|
+
|
|
645
|
+
def _close_position_at(
|
|
646
|
+
self,
|
|
647
|
+
cid: str,
|
|
648
|
+
ref_price: Decimal,
|
|
649
|
+
ts_ns: int,
|
|
650
|
+
*,
|
|
651
|
+
note: str,
|
|
652
|
+
slippage_ticks: int = 0,
|
|
653
|
+
liquidation: bool = False,
|
|
654
|
+
) -> None:
|
|
655
|
+
"""Immediate market close of the whole position, ``slippage_ticks``
|
|
656
|
+
|
|
657
|
+
adverse to the reference price. ``liquidation=True`` adds Topstep's
|
|
658
|
+
$10/contract automatic-liquidation fee. Used ONLY by enforcement paths
|
|
659
|
+
(16:10 flatten, MLL/DLL breach) — strategy closes go through resting
|
|
660
|
+
reduce-only market orders like any other order.
|
|
661
|
+
"""
|
|
662
|
+
pos = self._positions.get(cid)
|
|
663
|
+
if pos is None:
|
|
664
|
+
return
|
|
665
|
+
spec = self._instruments[cid]
|
|
666
|
+
mode: Literal["down", "up"] = "down" if pos.direction > 0 else "up"
|
|
667
|
+
px = round_to_tick(ref_price, spec.tick_size, mode=mode)
|
|
668
|
+
px -= pos.direction * slippage_ticks * spec.tick_size
|
|
669
|
+
side = OrderSide.SELL if pos.direction > 0 else OrderSide.BUY
|
|
670
|
+
qty = pos.qty
|
|
671
|
+
order = WorkingOrder(
|
|
672
|
+
order_id=self._ids.next(),
|
|
673
|
+
account_id=self._account_id,
|
|
674
|
+
contract_id=cid,
|
|
675
|
+
side=side,
|
|
676
|
+
type=OrderType.MARKET,
|
|
677
|
+
size=qty,
|
|
678
|
+
accepted_ts=ts_ns,
|
|
679
|
+
reduce_only=True,
|
|
680
|
+
custom_tag=note,
|
|
681
|
+
)
|
|
682
|
+
self._orders[order.order_id] = order
|
|
683
|
+
|
|
684
|
+
fees, commissions = self._fee_model.fee(spec, side, qty, Liquidity.TAKER)
|
|
685
|
+
if liquidation:
|
|
686
|
+
fees += self._config.liquidation_fee_per_contract * qty
|
|
687
|
+
realized = self._net_into_position(order, px, qty, ts_ns, spec)
|
|
688
|
+
if realized is not None:
|
|
689
|
+
self._balance += realized
|
|
690
|
+
self._balance -= fees + commissions
|
|
691
|
+
order.filled_qty = qty
|
|
692
|
+
order.avg_fill_price = px
|
|
693
|
+
order.status = OrderStatus.FILLED
|
|
694
|
+
self._emit_order(order)
|
|
695
|
+
trade = HalfTradeModel(
|
|
696
|
+
id=self._ids.next(),
|
|
697
|
+
account_id=self._account_id,
|
|
698
|
+
contract_id=cid,
|
|
699
|
+
price=px,
|
|
700
|
+
fees=fees,
|
|
701
|
+
side=side,
|
|
702
|
+
size=qty,
|
|
703
|
+
voided=False,
|
|
704
|
+
order_id=order.order_id,
|
|
705
|
+
creation_timestamp=ns_to_dt(ts_ns),
|
|
706
|
+
profit_and_loss=realized,
|
|
707
|
+
commissions=commissions,
|
|
708
|
+
)
|
|
709
|
+
self._trades.append(trade)
|
|
710
|
+
self._events.append(trade)
|
|
711
|
+
self._kernel.on_trade_activity(ts_ns)
|
|
712
|
+
self._cancel_reduce_only(cid, ts_ns)
|
|
713
|
+
|
|
714
|
+
# ------------------------------------------------------------------
|
|
715
|
+
# Brackets, trailing, cancellation
|
|
716
|
+
# ------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
def _create_bracket_children(
|
|
719
|
+
self, entry: WorkingOrder, fill: Fill, spec: InstrumentSpec, pending: _PendingBrackets
|
|
720
|
+
) -> None:
|
|
721
|
+
"""OCO stop-loss / take-profit children at signed-tick offsets from the
|
|
722
|
+
|
|
723
|
+
actual entry fill price. accepted_ts = fill time -> active next bar.
|
|
724
|
+
Gateway shape: only the TP carries ``linked_order_id`` (-> its SL);
|
|
725
|
+
the OCO pairing itself lives in the broker's internal map.
|
|
726
|
+
"""
|
|
727
|
+
sl_id = self._ids.next() if pending.sl_ticks is not None else None
|
|
728
|
+
tp_id = self._ids.next() if pending.tp_ticks is not None else None
|
|
729
|
+
exit_side = OrderSide.SELL if _side_dir(entry.side) > 0 else OrderSide.BUY
|
|
730
|
+
|
|
731
|
+
if pending.sl_ticks is not None and sl_id is not None:
|
|
732
|
+
sl = WorkingOrder(
|
|
733
|
+
order_id=sl_id,
|
|
734
|
+
account_id=self._account_id,
|
|
735
|
+
contract_id=entry.contract_id,
|
|
736
|
+
side=exit_side,
|
|
737
|
+
type=OrderType.STOP,
|
|
738
|
+
size=fill.qty,
|
|
739
|
+
accepted_ts=fill.ts_event,
|
|
740
|
+
stop_price=fill.price + pending.sl_ticks * spec.tick_size,
|
|
741
|
+
parent_order_id=entry.order_id,
|
|
742
|
+
reduce_only=True,
|
|
743
|
+
)
|
|
744
|
+
self._orders[sl_id] = sl
|
|
745
|
+
self._emit_order(sl)
|
|
746
|
+
if pending.tp_ticks is not None and tp_id is not None:
|
|
747
|
+
tp = WorkingOrder(
|
|
748
|
+
order_id=tp_id,
|
|
749
|
+
account_id=self._account_id,
|
|
750
|
+
contract_id=entry.contract_id,
|
|
751
|
+
side=exit_side,
|
|
752
|
+
type=OrderType.LIMIT,
|
|
753
|
+
size=fill.qty,
|
|
754
|
+
accepted_ts=fill.ts_event,
|
|
755
|
+
limit_price=fill.price + pending.tp_ticks * spec.tick_size,
|
|
756
|
+
parent_order_id=entry.order_id,
|
|
757
|
+
linked_order_id=sl_id, # TP -> SL only (live-verified shape)
|
|
758
|
+
reduce_only=True,
|
|
759
|
+
)
|
|
760
|
+
self._orders[tp_id] = tp
|
|
761
|
+
self._emit_order(tp)
|
|
762
|
+
if sl_id is not None and tp_id is not None:
|
|
763
|
+
self._oco[sl_id] = tp_id
|
|
764
|
+
self._oco[tp_id] = sl_id
|
|
765
|
+
|
|
766
|
+
def _ratchet_trailing(self, cid: str, prev: Bar | None, spec: InstrumentSpec) -> None:
|
|
767
|
+
"""Ratchet trailing stops from the PREVIOUS bar's extremes — but only
|
|
768
|
+
|
|
769
|
+
for orders that existed during that bar (``accepted_ts <=
|
|
770
|
+
prev.ts_event``); extremes from before the order was placed must
|
|
771
|
+
never move its stop (that would be look-back, not trailing).
|
|
772
|
+
"""
|
|
773
|
+
if prev is None:
|
|
774
|
+
return
|
|
775
|
+
for order in self._orders_on(cid):
|
|
776
|
+
if order.type is not OrderType.TRAILING_STOP or order.trail_distance_ticks is None:
|
|
777
|
+
continue
|
|
778
|
+
if order.accepted_ts > prev.ts_event:
|
|
779
|
+
continue # the order did not live through `prev`
|
|
780
|
+
distance = order.trail_distance_ticks * spec.tick_size
|
|
781
|
+
current = order.trail_stop_price
|
|
782
|
+
if order.side is OrderSide.SELL: # protects a long: trail below highs
|
|
783
|
+
candidate = prev.high - distance
|
|
784
|
+
if current is None or candidate > current:
|
|
785
|
+
order.trail_stop_price = candidate
|
|
786
|
+
else: # protects a short: trail above lows
|
|
787
|
+
candidate = prev.low + distance
|
|
788
|
+
if current is None or candidate < current:
|
|
789
|
+
order.trail_stop_price = candidate
|
|
790
|
+
|
|
791
|
+
def _cancel_order_internal(self, order_id: int, ts_ns: int) -> None:
|
|
792
|
+
order = self._orders.get(order_id)
|
|
793
|
+
if order is None or order.status in _TERMINAL:
|
|
794
|
+
return
|
|
795
|
+
order.status = OrderStatus.CANCELLED
|
|
796
|
+
self._brackets.pop(order_id, None)
|
|
797
|
+
sibling = self._oco.pop(order_id, None)
|
|
798
|
+
if sibling is not None:
|
|
799
|
+
self._oco.pop(sibling, None)
|
|
800
|
+
self._emit_order(order)
|
|
801
|
+
|
|
802
|
+
def _cancel_all_working(self, ts_ns: int) -> list[int]:
|
|
803
|
+
cancelled: list[int] = []
|
|
804
|
+
for oid, order in list(self._orders.items()):
|
|
805
|
+
if order.status is OrderStatus.OPEN:
|
|
806
|
+
self._cancel_order_internal(oid, ts_ns)
|
|
807
|
+
cancelled.append(oid)
|
|
808
|
+
return cancelled
|
|
809
|
+
|
|
810
|
+
def _cancel_reduce_only(self, cid: str, ts_ns: int) -> None:
|
|
811
|
+
"""Position fully closed: protective/reduce-only orders die with it."""
|
|
812
|
+
for oid, order in list(self._orders.items()):
|
|
813
|
+
if order.contract_id == cid and order.reduce_only and order.status is OrderStatus.OPEN:
|
|
814
|
+
self._cancel_order_internal(oid, ts_ns)
|
|
815
|
+
|
|
816
|
+
# ------------------------------------------------------------------
|
|
817
|
+
# Marks, events, model construction
|
|
818
|
+
# ------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
def _unrealized(self, pos: _Position, mark: Decimal) -> Decimal:
|
|
821
|
+
spec = self._instruments[pos.contract_id]
|
|
822
|
+
return position_unrealized(
|
|
823
|
+
cost=pos.cost,
|
|
824
|
+
qty=pos.qty,
|
|
825
|
+
mark=mark,
|
|
826
|
+
direction=pos.direction,
|
|
827
|
+
point_value=spec.point_value,
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
def _unrealized_except(self, cid: str) -> Decimal:
|
|
831
|
+
total = Decimal(0)
|
|
832
|
+
for other_cid, pos in self._positions.items():
|
|
833
|
+
if other_cid == cid:
|
|
834
|
+
continue
|
|
835
|
+
last = self._last_bar.get(other_cid)
|
|
836
|
+
if last is not None:
|
|
837
|
+
total += self._unrealized(pos, last.close)
|
|
838
|
+
return total
|
|
839
|
+
|
|
840
|
+
def _orders_on(self, cid: str) -> list[WorkingOrder]:
|
|
841
|
+
return [
|
|
842
|
+
o
|
|
843
|
+
for o in self._orders.values()
|
|
844
|
+
if o.contract_id == cid and o.status is OrderStatus.OPEN
|
|
845
|
+
]
|
|
846
|
+
|
|
847
|
+
def _emit_order(self, order: WorkingOrder) -> None:
|
|
848
|
+
self._events.append(self._order_model(order))
|
|
849
|
+
|
|
850
|
+
def _emit_position(self, pos: _Position, spec: InstrumentSpec) -> None:
|
|
851
|
+
self._events.append(self._position_model(pos, spec))
|
|
852
|
+
|
|
853
|
+
def _position_model(self, pos: _Position, spec: InstrumentSpec) -> PositionModel:
|
|
854
|
+
return PositionModel(
|
|
855
|
+
id=pos.position_id,
|
|
856
|
+
account_id=self._account_id,
|
|
857
|
+
contract_id=pos.contract_id,
|
|
858
|
+
type=PositionType.LONG if pos.direction > 0 else PositionType.SHORT,
|
|
859
|
+
size=pos.qty,
|
|
860
|
+
average_price=pos.display_avg(spec),
|
|
861
|
+
creation_timestamp=ns_to_dt(pos.opened_ts),
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
def _order_model(self, order: WorkingOrder) -> OrderModel:
|
|
865
|
+
spec = self._instruments[order.contract_id]
|
|
866
|
+
trail_ticks = order.trail_distance_ticks
|
|
867
|
+
return OrderModel(
|
|
868
|
+
id=order.order_id,
|
|
869
|
+
account_id=order.account_id,
|
|
870
|
+
contract_id=order.contract_id,
|
|
871
|
+
status=order.status,
|
|
872
|
+
type=order.type,
|
|
873
|
+
side=order.side,
|
|
874
|
+
size=order.size,
|
|
875
|
+
creation_timestamp=ns_to_dt(order.accepted_ts),
|
|
876
|
+
update_timestamp=self._clock.now(),
|
|
877
|
+
limit_price=order.limit_price,
|
|
878
|
+
stop_price=order.stop_price,
|
|
879
|
+
fill_volume=order.filled_qty,
|
|
880
|
+
filled_price=order.avg_fill_price,
|
|
881
|
+
custom_tag=order.custom_tag,
|
|
882
|
+
trail_distance=trail_ticks,
|
|
883
|
+
# Live gateway returns the trail DISTANCE as a price offset here,
|
|
884
|
+
# not the absolute stop level (SDK OrderModel field docs).
|
|
885
|
+
trail_price=None if trail_ticks is None else trail_ticks * spec.tick_size,
|
|
886
|
+
parent_order_id=order.parent_order_id,
|
|
887
|
+
linked_order_id=order.linked_order_id,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# ------------------------------------------------------------------
|
|
891
|
+
# Order entry (validation mirrors the gateway)
|
|
892
|
+
# ------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
def _pretrade_checks(self, contract_id: str, size: int) -> InstrumentSpec:
|
|
895
|
+
spec = self._instruments.get(contract_id)
|
|
896
|
+
if spec is None:
|
|
897
|
+
reject_place(8, f"unknown contract {contract_id!r}")
|
|
898
|
+
if size <= 0:
|
|
899
|
+
reject_place(2, f"size must be positive, got {size}")
|
|
900
|
+
if self.dead:
|
|
901
|
+
reject_place(4, "account failed the combine (MLL breached)")
|
|
902
|
+
if self._kernel.day_locked:
|
|
903
|
+
reject_place(4, "daily loss limit reached: trading locked until 18:00 ET")
|
|
904
|
+
now = self._clock.now_ns()
|
|
905
|
+
if self._session.in_no_trade_window(now):
|
|
906
|
+
reject_place(5, "no-trade window 16:10-18:00 ET")
|
|
907
|
+
if trading_day_of(now).weekday() >= 5:
|
|
908
|
+
reject_place(5, "market closed (weekend)")
|
|
909
|
+
return spec
|
|
910
|
+
|
|
911
|
+
def _check_position_cap(self, spec: InstrumentSpec, size: int) -> None:
|
|
912
|
+
"""Conservative account-wide cap: |net| + working opening orders."""
|
|
913
|
+
units = sum(
|
|
914
|
+
abs(p.qty) * self._instruments[cid].cap_units for cid, p in self._positions.items()
|
|
915
|
+
)
|
|
916
|
+
units += sum(
|
|
917
|
+
o.remaining * self._instruments[o.contract_id].cap_units
|
|
918
|
+
for o in self._orders.values()
|
|
919
|
+
if o.status is OrderStatus.OPEN and not o.reduce_only
|
|
920
|
+
)
|
|
921
|
+
if units + size * spec.cap_units > self._kernel.max_position_micro_units():
|
|
922
|
+
reject_place(
|
|
923
|
+
4,
|
|
924
|
+
f"position cap exceeded: {self._kernel.max_position_micro_units()} "
|
|
925
|
+
"micro-units (minis count 10, micros 1; working orders included)",
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
def _resolve_brackets(
|
|
929
|
+
self,
|
|
930
|
+
side: OrderSide,
|
|
931
|
+
stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None,
|
|
932
|
+
take_profit_bracket: PlaceOrderBracket | dict[str, int] | None,
|
|
933
|
+
stop_loss_ticks: int | None,
|
|
934
|
+
take_profit_ticks: int | None,
|
|
935
|
+
) -> _PendingBrackets | None:
|
|
936
|
+
def norm(
|
|
937
|
+
raw: PlaceOrderBracket | dict[str, int] | None,
|
|
938
|
+
magnitude: int | None,
|
|
939
|
+
*,
|
|
940
|
+
is_sl: bool,
|
|
941
|
+
name: str,
|
|
942
|
+
) -> int | None:
|
|
943
|
+
if raw is not None and magnitude is not None:
|
|
944
|
+
raise ValueError(f"pass either {name}_bracket or {name}_ticks, not both")
|
|
945
|
+
if magnitude is not None:
|
|
946
|
+
mag = abs(int(magnitude))
|
|
947
|
+
if is_sl:
|
|
948
|
+
return -mag if side is OrderSide.BUY else mag
|
|
949
|
+
return mag if side is OrderSide.BUY else -mag
|
|
950
|
+
if raw is None:
|
|
951
|
+
return None
|
|
952
|
+
ticks = raw.ticks if isinstance(raw, PlaceOrderBracket) else int(raw["ticks"])
|
|
953
|
+
if ticks == 0:
|
|
954
|
+
reject_place(2, f"{name} bracket ticks must be non-zero")
|
|
955
|
+
long = side is OrderSide.BUY
|
|
956
|
+
wrong_sign = (is_sl and ((long and ticks > 0) or (not long and ticks < 0))) or (
|
|
957
|
+
not is_sl and ((long and ticks < 0) or (not long and ticks > 0))
|
|
958
|
+
)
|
|
959
|
+
if wrong_sign:
|
|
960
|
+
reject_place(2, f"{name} bracket has the wrong sign for {side.name}")
|
|
961
|
+
return ticks
|
|
962
|
+
|
|
963
|
+
sl = norm(stop_loss_bracket, stop_loss_ticks, is_sl=True, name="stop_loss")
|
|
964
|
+
tp = norm(take_profit_bracket, take_profit_ticks, is_sl=False, name="take_profit")
|
|
965
|
+
if sl is None and tp is None:
|
|
966
|
+
return None
|
|
967
|
+
return _PendingBrackets(sl, tp)
|
|
968
|
+
|
|
969
|
+
def _validate_trailing(
|
|
970
|
+
self, contract_id: str, spec: InstrumentSpec, trail_d: Decimal
|
|
971
|
+
) -> tuple[int, Decimal]:
|
|
972
|
+
last = self._last_bar.get(contract_id)
|
|
973
|
+
if last is None:
|
|
974
|
+
reject_place(2, "TRAILING_STOP requires market data for the trail anchor")
|
|
975
|
+
distance_px = abs(last.close - trail_d)
|
|
976
|
+
trail_distance = to_ticks(distance_px, spec.tick_size)
|
|
977
|
+
if trail_distance == 0 or trail_distance > self._config.max_trail_ticks:
|
|
978
|
+
reject_place(
|
|
979
|
+
2,
|
|
980
|
+
f"trail distance {trail_distance} ticks outside (0, "
|
|
981
|
+
f"{self._config.max_trail_ticks}]",
|
|
982
|
+
)
|
|
983
|
+
return trail_distance, trail_d
|
|
984
|
+
|
|
985
|
+
def _place(self, account_id: int, contract_id: str, **kwargs: Any) -> int:
|
|
986
|
+
"""Count rejections at the one choke point every order path funnels through.
|
|
987
|
+
|
|
988
|
+
Every public entry (``place``/``buy``/``sell``, the sugar helpers, and
|
|
989
|
+
the bracket children) reaches the gateway mirror below. Tallying here —
|
|
990
|
+
rather than at each ``reject_place`` site — means a new rejection reason
|
|
991
|
+
cannot be added without being counted.
|
|
992
|
+
"""
|
|
993
|
+
try:
|
|
994
|
+
return self._place_checked(account_id, contract_id, **kwargs)
|
|
995
|
+
except APIError as error:
|
|
996
|
+
code = -1 if error.error_code is None else error.error_code
|
|
997
|
+
self._rejections[code] = self._rejections.get(code, 0) + 1
|
|
998
|
+
raise
|
|
999
|
+
|
|
1000
|
+
def _place_checked(
|
|
1001
|
+
self,
|
|
1002
|
+
account_id: int,
|
|
1003
|
+
contract_id: str,
|
|
1004
|
+
*,
|
|
1005
|
+
side: OrderSide | int,
|
|
1006
|
+
type: OrderType | int,
|
|
1007
|
+
size: int,
|
|
1008
|
+
limit_price: float | Decimal | None,
|
|
1009
|
+
stop_price: float | Decimal | None,
|
|
1010
|
+
trail_price: float | Decimal | None,
|
|
1011
|
+
custom_tag: str | None,
|
|
1012
|
+
stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None,
|
|
1013
|
+
take_profit_bracket: PlaceOrderBracket | dict[str, int] | None,
|
|
1014
|
+
stop_loss_ticks: int | None,
|
|
1015
|
+
take_profit_ticks: int | None,
|
|
1016
|
+
reduce_only: bool = False,
|
|
1017
|
+
) -> int:
|
|
1018
|
+
if account_id != self._account_id:
|
|
1019
|
+
reject_place(1, f"unknown account {account_id}")
|
|
1020
|
+
if int(side) not in (0, 1):
|
|
1021
|
+
reject_place(2, f"invalid order side {side!r} (BUY/BID=0, SELL/ASK=1)")
|
|
1022
|
+
spec = self._pretrade_checks(contract_id, size)
|
|
1023
|
+
side_e = OrderSide(int(side))
|
|
1024
|
+
type_e = OrderType(int(type))
|
|
1025
|
+
|
|
1026
|
+
if custom_tag is not None:
|
|
1027
|
+
if custom_tag in self._used_tags:
|
|
1028
|
+
reject_place(2, f"custom_tag {custom_tag!r} already used (must be unique)")
|
|
1029
|
+
self._used_tags.add(custom_tag)
|
|
1030
|
+
|
|
1031
|
+
if not reduce_only:
|
|
1032
|
+
self._check_position_cap(spec, size)
|
|
1033
|
+
|
|
1034
|
+
limit_d = None if limit_price is None else _as_decimal(limit_price)
|
|
1035
|
+
stop_d = None if stop_price is None else _as_decimal(stop_price)
|
|
1036
|
+
trail_d = None if trail_price is None else _as_decimal(trail_price)
|
|
1037
|
+
price_fields = (("limit_price", limit_d), ("stop_price", stop_d), ("trail_price", trail_d))
|
|
1038
|
+
for label, px in price_fields:
|
|
1039
|
+
if px is not None and not is_on_grid(px, spec.tick_size):
|
|
1040
|
+
reject_place(2, f"{label} {px} is off the {spec.tick_size} tick grid")
|
|
1041
|
+
|
|
1042
|
+
trail_distance: int | None = None
|
|
1043
|
+
trail_stop: Decimal | None = None
|
|
1044
|
+
if type_e is OrderType.LIMIT and limit_d is None:
|
|
1045
|
+
reject_place(2, "LIMIT order requires limit_price")
|
|
1046
|
+
elif type_e is OrderType.STOP and stop_d is None:
|
|
1047
|
+
reject_place(2, "STOP order requires stop_price")
|
|
1048
|
+
elif type_e is OrderType.STOP_LIMIT:
|
|
1049
|
+
reject_place(2, "STOP_LIMIT is not supported by the Tier-0 bar fill model")
|
|
1050
|
+
elif type_e in (OrderType.JOIN_BID, OrderType.JOIN_ASK):
|
|
1051
|
+
reject_place(
|
|
1052
|
+
2,
|
|
1053
|
+
"JOIN_BID/JOIN_ASK need live quote data (Tier-1+); "
|
|
1054
|
+
"unsupported at Tier-0 (documented divergence)",
|
|
1055
|
+
)
|
|
1056
|
+
elif type_e is OrderType.TRAILING_STOP:
|
|
1057
|
+
if trail_d is None:
|
|
1058
|
+
reject_place(2, "TRAILING_STOP requires trail_price (absolute anchor)")
|
|
1059
|
+
trail_distance, trail_stop = self._validate_trailing(contract_id, spec, trail_d)
|
|
1060
|
+
elif type_e not in (OrderType.MARKET, OrderType.LIMIT, OrderType.STOP):
|
|
1061
|
+
reject_place(2, f"order type {type_e.name} is not placeable")
|
|
1062
|
+
|
|
1063
|
+
pending = self._resolve_brackets(
|
|
1064
|
+
side_e, stop_loss_bracket, take_profit_bracket, stop_loss_ticks, take_profit_ticks
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
order = WorkingOrder(
|
|
1068
|
+
order_id=self._ids.next(),
|
|
1069
|
+
account_id=self._account_id,
|
|
1070
|
+
contract_id=contract_id,
|
|
1071
|
+
side=side_e,
|
|
1072
|
+
type=type_e,
|
|
1073
|
+
size=size,
|
|
1074
|
+
accepted_ts=self._clock.now_ns(),
|
|
1075
|
+
limit_price=limit_d,
|
|
1076
|
+
stop_price=stop_d,
|
|
1077
|
+
trail_stop_price=trail_stop,
|
|
1078
|
+
trail_distance_ticks=trail_distance,
|
|
1079
|
+
custom_tag=custom_tag,
|
|
1080
|
+
reduce_only=reduce_only,
|
|
1081
|
+
)
|
|
1082
|
+
self._orders[order.order_id] = order
|
|
1083
|
+
if pending is not None:
|
|
1084
|
+
self._brackets[order.order_id] = pending
|
|
1085
|
+
self._emit_order(order)
|
|
1086
|
+
return order.order_id
|
|
1087
|
+
|
|
1088
|
+
def _modify(
|
|
1089
|
+
self,
|
|
1090
|
+
account_id: int,
|
|
1091
|
+
order_id: int,
|
|
1092
|
+
*,
|
|
1093
|
+
size: int | None,
|
|
1094
|
+
limit_price: float | Decimal | None,
|
|
1095
|
+
stop_price: float | Decimal | None,
|
|
1096
|
+
trail_price: float | Decimal | None,
|
|
1097
|
+
) -> None:
|
|
1098
|
+
if account_id != self._account_id:
|
|
1099
|
+
reject_modify(1, f"unknown account {account_id}")
|
|
1100
|
+
order = self._orders.get(order_id)
|
|
1101
|
+
if order is None or order.status in _TERMINAL:
|
|
1102
|
+
reject_modify(2, f"order {order_id} not found or terminal")
|
|
1103
|
+
spec = self._instruments[order.contract_id]
|
|
1104
|
+
if size is not None:
|
|
1105
|
+
if size <= 0 or size < order.filled_qty:
|
|
1106
|
+
reject_modify(3, f"invalid size {size}")
|
|
1107
|
+
order.size = size
|
|
1108
|
+
for label, value in (("limit_price", limit_price), ("stop_price", stop_price)):
|
|
1109
|
+
if value is not None:
|
|
1110
|
+
px = _as_decimal(value)
|
|
1111
|
+
if not is_on_grid(px, spec.tick_size):
|
|
1112
|
+
reject_modify(3, f"{label} {px} is off the tick grid")
|
|
1113
|
+
if label == "limit_price":
|
|
1114
|
+
order.limit_price = px
|
|
1115
|
+
else:
|
|
1116
|
+
order.stop_price = px
|
|
1117
|
+
if trail_price is not None:
|
|
1118
|
+
px = _as_decimal(trail_price)
|
|
1119
|
+
if not is_on_grid(px, spec.tick_size):
|
|
1120
|
+
reject_modify(3, f"trail_price {px} is off the tick grid")
|
|
1121
|
+
last = self._last_bar.get(order.contract_id)
|
|
1122
|
+
if last is None:
|
|
1123
|
+
reject_modify(3, "no market data to re-anchor trailing stop")
|
|
1124
|
+
distance = to_ticks(abs(last.close - px), spec.tick_size)
|
|
1125
|
+
if distance == 0 or distance > self._config.max_trail_ticks:
|
|
1126
|
+
reject_modify(
|
|
1127
|
+
3,
|
|
1128
|
+
f"trail distance {distance} ticks outside (0, {self._config.max_trail_ticks}]",
|
|
1129
|
+
)
|
|
1130
|
+
order.trail_distance_ticks = distance
|
|
1131
|
+
order.trail_stop_price = px
|
|
1132
|
+
self._emit_order(order)
|
|
1133
|
+
|
|
1134
|
+
def _cancel(self, account_id: int, order_id: int) -> None:
|
|
1135
|
+
if account_id != self._account_id:
|
|
1136
|
+
reject_cancel(1, f"unknown account {account_id}")
|
|
1137
|
+
order = self._orders.get(order_id)
|
|
1138
|
+
if order is None or order.status in _TERMINAL:
|
|
1139
|
+
reject_cancel(2, f"order {order_id} not found or terminal")
|
|
1140
|
+
self._cancel_order_internal(order_id, self._clock.now_ns())
|
|
1141
|
+
|
|
1142
|
+
def _submit_reduce_market(self, contract_id: str, size: int) -> int:
|
|
1143
|
+
"""A resting reduce-only MARKET order (positions.close/partial_close):
|
|
1144
|
+
|
|
1145
|
+
fills at the next bar's open through the normal walk, exactly like any
|
|
1146
|
+
other market order — the documented Tier-0 execution model.
|
|
1147
|
+
"""
|
|
1148
|
+
pos = self._positions[contract_id]
|
|
1149
|
+
side = OrderSide.SELL if pos.direction > 0 else OrderSide.BUY
|
|
1150
|
+
return self._place(
|
|
1151
|
+
self._account_id,
|
|
1152
|
+
contract_id,
|
|
1153
|
+
side=side,
|
|
1154
|
+
type=OrderType.MARKET,
|
|
1155
|
+
size=size,
|
|
1156
|
+
limit_price=None,
|
|
1157
|
+
stop_price=None,
|
|
1158
|
+
trail_price=None,
|
|
1159
|
+
custom_tag=None,
|
|
1160
|
+
stop_loss_bracket=None,
|
|
1161
|
+
take_profit_bracket=None,
|
|
1162
|
+
stop_loss_ticks=None,
|
|
1163
|
+
take_profit_ticks=None,
|
|
1164
|
+
reduce_only=True,
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
# ---------------------------------------------------------------------------
|
|
1169
|
+
# Protocol-facing facades (thin async views over the broker internals)
|
|
1170
|
+
# ---------------------------------------------------------------------------
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
class SimOrderApi:
|
|
1174
|
+
__slots__ = ("_b",)
|
|
1175
|
+
|
|
1176
|
+
def __init__(self, broker: SimBroker) -> None:
|
|
1177
|
+
self._b = broker
|
|
1178
|
+
|
|
1179
|
+
async def place(
|
|
1180
|
+
self,
|
|
1181
|
+
account_id: int,
|
|
1182
|
+
contract_id: str,
|
|
1183
|
+
*,
|
|
1184
|
+
side: OrderSide | int,
|
|
1185
|
+
type: OrderType | int,
|
|
1186
|
+
size: int,
|
|
1187
|
+
limit_price: float | Decimal | None = None,
|
|
1188
|
+
stop_price: float | Decimal | None = None,
|
|
1189
|
+
trail_price: float | Decimal | None = None,
|
|
1190
|
+
custom_tag: str | None = None,
|
|
1191
|
+
stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None,
|
|
1192
|
+
take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None,
|
|
1193
|
+
stop_loss_ticks: int | None = None,
|
|
1194
|
+
take_profit_ticks: int | None = None,
|
|
1195
|
+
) -> int:
|
|
1196
|
+
return self._b._place(
|
|
1197
|
+
account_id,
|
|
1198
|
+
contract_id,
|
|
1199
|
+
side=side,
|
|
1200
|
+
type=type,
|
|
1201
|
+
size=size,
|
|
1202
|
+
limit_price=limit_price,
|
|
1203
|
+
stop_price=stop_price,
|
|
1204
|
+
trail_price=trail_price,
|
|
1205
|
+
custom_tag=custom_tag,
|
|
1206
|
+
stop_loss_bracket=stop_loss_bracket,
|
|
1207
|
+
take_profit_bracket=take_profit_bracket,
|
|
1208
|
+
stop_loss_ticks=stop_loss_ticks,
|
|
1209
|
+
take_profit_ticks=take_profit_ticks,
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
async def buy(
|
|
1213
|
+
self,
|
|
1214
|
+
account_id: int,
|
|
1215
|
+
contract_id: str,
|
|
1216
|
+
size: int,
|
|
1217
|
+
*,
|
|
1218
|
+
type: OrderType | int = OrderType.MARKET,
|
|
1219
|
+
limit_price: float | Decimal | None = None,
|
|
1220
|
+
stop_price: float | Decimal | None = None,
|
|
1221
|
+
trail_price: float | Decimal | None = None,
|
|
1222
|
+
custom_tag: str | None = None,
|
|
1223
|
+
stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None,
|
|
1224
|
+
take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None,
|
|
1225
|
+
stop_loss_ticks: int | None = None,
|
|
1226
|
+
take_profit_ticks: int | None = None,
|
|
1227
|
+
) -> int:
|
|
1228
|
+
return await self.place(
|
|
1229
|
+
account_id,
|
|
1230
|
+
contract_id,
|
|
1231
|
+
side=OrderSide.BUY,
|
|
1232
|
+
type=type,
|
|
1233
|
+
size=size,
|
|
1234
|
+
limit_price=limit_price,
|
|
1235
|
+
stop_price=stop_price,
|
|
1236
|
+
trail_price=trail_price,
|
|
1237
|
+
custom_tag=custom_tag,
|
|
1238
|
+
stop_loss_bracket=stop_loss_bracket,
|
|
1239
|
+
take_profit_bracket=take_profit_bracket,
|
|
1240
|
+
stop_loss_ticks=stop_loss_ticks,
|
|
1241
|
+
take_profit_ticks=take_profit_ticks,
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
async def sell(
|
|
1245
|
+
self,
|
|
1246
|
+
account_id: int,
|
|
1247
|
+
contract_id: str,
|
|
1248
|
+
size: int,
|
|
1249
|
+
*,
|
|
1250
|
+
type: OrderType | int = OrderType.MARKET,
|
|
1251
|
+
limit_price: float | Decimal | None = None,
|
|
1252
|
+
stop_price: float | Decimal | None = None,
|
|
1253
|
+
trail_price: float | Decimal | None = None,
|
|
1254
|
+
custom_tag: str | None = None,
|
|
1255
|
+
stop_loss_bracket: PlaceOrderBracket | dict[str, int] | None = None,
|
|
1256
|
+
take_profit_bracket: PlaceOrderBracket | dict[str, int] | None = None,
|
|
1257
|
+
stop_loss_ticks: int | None = None,
|
|
1258
|
+
take_profit_ticks: int | None = None,
|
|
1259
|
+
) -> int:
|
|
1260
|
+
return await self.place(
|
|
1261
|
+
account_id,
|
|
1262
|
+
contract_id,
|
|
1263
|
+
side=OrderSide.SELL,
|
|
1264
|
+
type=type,
|
|
1265
|
+
size=size,
|
|
1266
|
+
limit_price=limit_price,
|
|
1267
|
+
stop_price=stop_price,
|
|
1268
|
+
trail_price=trail_price,
|
|
1269
|
+
custom_tag=custom_tag,
|
|
1270
|
+
stop_loss_bracket=stop_loss_bracket,
|
|
1271
|
+
take_profit_bracket=take_profit_bracket,
|
|
1272
|
+
stop_loss_ticks=stop_loss_ticks,
|
|
1273
|
+
take_profit_ticks=take_profit_ticks,
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
async def modify(
|
|
1277
|
+
self,
|
|
1278
|
+
account_id: int,
|
|
1279
|
+
order_id: int,
|
|
1280
|
+
*,
|
|
1281
|
+
size: int | None = None,
|
|
1282
|
+
limit_price: float | Decimal | None = None,
|
|
1283
|
+
stop_price: float | Decimal | None = None,
|
|
1284
|
+
trail_price: float | Decimal | None = None,
|
|
1285
|
+
) -> None:
|
|
1286
|
+
self._b._modify(
|
|
1287
|
+
account_id,
|
|
1288
|
+
order_id,
|
|
1289
|
+
size=size,
|
|
1290
|
+
limit_price=limit_price,
|
|
1291
|
+
stop_price=stop_price,
|
|
1292
|
+
trail_price=trail_price,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
async def cancel(self, account_id: int, order_id: int) -> None:
|
|
1296
|
+
self._b._cancel(account_id, order_id)
|
|
1297
|
+
|
|
1298
|
+
async def cancel_all(self, account_id: int) -> list[int]:
|
|
1299
|
+
if account_id != self._b._account_id:
|
|
1300
|
+
reject_cancel(1, f"unknown account {account_id}")
|
|
1301
|
+
return self._b._cancel_all_working(self._b._clock.now_ns())
|
|
1302
|
+
|
|
1303
|
+
async def search_open(self, account_id: int) -> list[OrderModel]:
|
|
1304
|
+
if account_id != self._b._account_id:
|
|
1305
|
+
raise APIError("AccountNotFound", error_code=1)
|
|
1306
|
+
return [
|
|
1307
|
+
self._b._order_model(o)
|
|
1308
|
+
for o in self._b._orders.values()
|
|
1309
|
+
if o.status is OrderStatus.OPEN
|
|
1310
|
+
]
|
|
1311
|
+
|
|
1312
|
+
async def get(self, account_id: int, order_id: int) -> OrderModel | None:
|
|
1313
|
+
if account_id != self._b._account_id:
|
|
1314
|
+
return None # the gateway reports OrderNotFound for foreign accounts
|
|
1315
|
+
order = self._b._orders.get(order_id)
|
|
1316
|
+
return None if order is None else self._b._order_model(order)
|
|
1317
|
+
|
|
1318
|
+
async def wait_for_fill(
|
|
1319
|
+
self,
|
|
1320
|
+
account_id: int,
|
|
1321
|
+
order_id: int,
|
|
1322
|
+
*,
|
|
1323
|
+
timeout: float = 30.0, # noqa: ASYNC109 - mirrors the SDK signature
|
|
1324
|
+
poll_interval: float = 1.0,
|
|
1325
|
+
) -> OrderModel:
|
|
1326
|
+
order = self._b._orders.get(order_id) if account_id == self._b._account_id else None
|
|
1327
|
+
if order is not None and order.status in _TERMINAL:
|
|
1328
|
+
return self._b._order_model(order)
|
|
1329
|
+
raise UnsupportedInBacktestError(
|
|
1330
|
+
"wait_for_fill cannot busy-poll under a deterministic TestClock; "
|
|
1331
|
+
"handle fills in Strategy.on_order/on_fill callbacks instead (the "
|
|
1332
|
+
"parity-safe idiom in both sim and live)."
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
class SimPositionApi:
|
|
1337
|
+
__slots__ = ("_b",)
|
|
1338
|
+
|
|
1339
|
+
def __init__(self, broker: SimBroker) -> None:
|
|
1340
|
+
self._b = broker
|
|
1341
|
+
|
|
1342
|
+
async def search_open(self, account_id: int) -> list[PositionModel]:
|
|
1343
|
+
if account_id != self._b._account_id:
|
|
1344
|
+
raise APIError("AccountNotFound", error_code=1)
|
|
1345
|
+
return [
|
|
1346
|
+
self._b._position_model(p, self._b._instruments[cid])
|
|
1347
|
+
for cid, p in self._b._positions.items()
|
|
1348
|
+
]
|
|
1349
|
+
|
|
1350
|
+
async def close(self, account_id: int, contract_id: str) -> None:
|
|
1351
|
+
if account_id != self._b._account_id:
|
|
1352
|
+
raise APIError("AccountNotFound", error_code=1)
|
|
1353
|
+
pos = self._b._positions.get(contract_id)
|
|
1354
|
+
if pos is None:
|
|
1355
|
+
raise APIError("PositionNotFound", error_code=2)
|
|
1356
|
+
self._b._submit_reduce_market(contract_id, pos.qty)
|
|
1357
|
+
|
|
1358
|
+
async def partial_close(self, account_id: int, contract_id: str, size: int) -> None:
|
|
1359
|
+
if account_id != self._b._account_id:
|
|
1360
|
+
raise APIError("AccountNotFound", error_code=1)
|
|
1361
|
+
pos = self._b._positions.get(contract_id)
|
|
1362
|
+
if pos is None:
|
|
1363
|
+
raise APIError("PositionNotFound", error_code=2)
|
|
1364
|
+
if size <= 0 or size > pos.qty:
|
|
1365
|
+
raise APIError("InvalidCloseSize", error_code=5)
|
|
1366
|
+
self._b._submit_reduce_market(contract_id, size)
|
|
1367
|
+
|
|
1368
|
+
async def close_all(self, account_id: int) -> list[str]:
|
|
1369
|
+
if account_id != self._b._account_id:
|
|
1370
|
+
raise APIError("AccountNotFound", error_code=1)
|
|
1371
|
+
closed: list[str] = []
|
|
1372
|
+
for cid, pos in list(self._b._positions.items()):
|
|
1373
|
+
self._b._submit_reduce_market(cid, pos.qty)
|
|
1374
|
+
closed.append(cid)
|
|
1375
|
+
return closed
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
class SimHistoryApi:
|
|
1379
|
+
__slots__ = ("_b",)
|
|
1380
|
+
|
|
1381
|
+
def __init__(self, broker: SimBroker) -> None:
|
|
1382
|
+
self._b = broker
|
|
1383
|
+
|
|
1384
|
+
async def retrieve_bars(
|
|
1385
|
+
self,
|
|
1386
|
+
contract_id: str,
|
|
1387
|
+
*,
|
|
1388
|
+
unit: AggregateBarUnit | int,
|
|
1389
|
+
unit_number: int,
|
|
1390
|
+
start_time: datetime | str,
|
|
1391
|
+
end_time: datetime | str,
|
|
1392
|
+
limit: int = 1000,
|
|
1393
|
+
live: bool = False,
|
|
1394
|
+
include_partial_bar: bool = False,
|
|
1395
|
+
) -> list[AggregateBarModel]:
|
|
1396
|
+
"""Serve ONLY already-seen bars (zero look-ahead), newest-first like the
|
|
1397
|
+
|
|
1398
|
+
gateway (bars stamped at open time, matching the SDK model). The
|
|
1399
|
+
requested ``unit``/``unit_number`` must match the feed's native bar
|
|
1400
|
+
spec — Tier-0 does no resampling and refuses to silently serve wrong
|
|
1401
|
+
aggregation. ``start_time``/``end_time`` filter when passed as
|
|
1402
|
+
datetimes (ISO strings accepted for parity but treated as unbounded).
|
|
1403
|
+
"""
|
|
1404
|
+
if limit > 20_000:
|
|
1405
|
+
raise ValueError(f"limit {limit} exceeds the gateway maximum of 20000")
|
|
1406
|
+
bars = self._b._history.get(contract_id, [])
|
|
1407
|
+
if bars:
|
|
1408
|
+
native = bars[-1].bar_type
|
|
1409
|
+
if (int(unit), unit_number) != (int(native.unit), native.unit_number):
|
|
1410
|
+
raise ValueError(
|
|
1411
|
+
f"Tier-0 history serves only the feed's native bar spec "
|
|
1412
|
+
f"({native.unit.name} x{native.unit_number}); requested "
|
|
1413
|
+
f"{AggregateBarUnit(int(unit)).name} x{unit_number}. "
|
|
1414
|
+
"Resampling arrives with the multi-timeframe data layer."
|
|
1415
|
+
)
|
|
1416
|
+
start_ns = dt_to_ns(start_time) if isinstance(start_time, datetime) else None
|
|
1417
|
+
end_ns = dt_to_ns(end_time) if isinstance(end_time, datetime) else None
|
|
1418
|
+
out: list[AggregateBarModel] = []
|
|
1419
|
+
for bar in reversed(bars):
|
|
1420
|
+
if len(out) >= limit:
|
|
1421
|
+
break
|
|
1422
|
+
if start_ns is not None and bar.ts_event < start_ns:
|
|
1423
|
+
break # history is time-ascending; everything earlier is out of range
|
|
1424
|
+
if end_ns is not None and bar.ts_event > end_ns:
|
|
1425
|
+
continue
|
|
1426
|
+
out.append(
|
|
1427
|
+
AggregateBarModel(
|
|
1428
|
+
t=ns_to_dt(bar.ts_event),
|
|
1429
|
+
o=bar.open,
|
|
1430
|
+
h=bar.high,
|
|
1431
|
+
l=bar.low,
|
|
1432
|
+
c=bar.close,
|
|
1433
|
+
v=bar.volume,
|
|
1434
|
+
)
|
|
1435
|
+
)
|
|
1436
|
+
return out
|