topstep-backtest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. topstep_backtest/__init__.py +43 -0
  2. topstep_backtest/clock/__init__.py +1 -0
  3. topstep_backtest/clock/live_clock.py +82 -0
  4. topstep_backtest/clock/test_clock.py +133 -0
  5. topstep_backtest/core/__init__.py +1 -0
  6. topstep_backtest/core/ids.py +23 -0
  7. topstep_backtest/core/instruments.py +167 -0
  8. topstep_backtest/core/money.py +160 -0
  9. topstep_backtest/core/time.py +125 -0
  10. topstep_backtest/data/__init__.py +1 -0
  11. topstep_backtest/data/clean.py +86 -0
  12. topstep_backtest/data/feed.py +56 -0
  13. topstep_backtest/data/synthetic.py +137 -0
  14. topstep_backtest/data/validator.py +215 -0
  15. topstep_backtest/data/wrangler.py +306 -0
  16. topstep_backtest/engine/__init__.py +1 -0
  17. topstep_backtest/engine/backtest.py +209 -0
  18. topstep_backtest/execution/__init__.py +1 -0
  19. topstep_backtest/execution/rejections.py +53 -0
  20. topstep_backtest/execution/sim_broker.py +1436 -0
  21. topstep_backtest/fills/__init__.py +1 -0
  22. topstep_backtest/fills/bar_fill.py +268 -0
  23. topstep_backtest/fills/fees.py +120 -0
  24. topstep_backtest/fills/path.py +59 -0
  25. topstep_backtest/harness.py +446 -0
  26. topstep_backtest/indicators/__init__.py +46 -0
  27. topstep_backtest/indicators/base.py +57 -0
  28. topstep_backtest/indicators/library.py +303 -0
  29. topstep_backtest/indicators/talib_adapter.py +657 -0
  30. topstep_backtest/metrics/__init__.py +5 -0
  31. topstep_backtest/metrics/stats.py +153 -0
  32. topstep_backtest/protocols.py +473 -0
  33. topstep_backtest/py.typed +0 -0
  34. topstep_backtest/rules/__init__.py +1 -0
  35. topstep_backtest/rules/kernel.py +281 -0
  36. topstep_backtest/rules/params.py +74 -0
  37. topstep_backtest/strategy/__init__.py +20 -0
  38. topstep_backtest/strategy/base.py +118 -0
  39. topstep_backtest/strategy/symbol.py +344 -0
  40. topstep_backtest/strategy/tracker.py +151 -0
  41. topstep_backtest-0.1.0.dist-info/METADATA +250 -0
  42. topstep_backtest-0.1.0.dist-info/RECORD +44 -0
  43. topstep_backtest-0.1.0.dist-info/WHEEL +4 -0
  44. topstep_backtest-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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