synpath 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 (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,279 @@
1
+ """Reconciliation: what the venue says, against what the engine believes.
2
+
3
+ The engine's journal is authoritative about its own decisions and about
4
+ nothing else. Someone can trade the same account from a phone, a fill can
5
+ arrive while the process is down, a venue can expire an order without
6
+ telling anyone. So the engine asks, on a timer and at startup, and names
7
+ every difference:
8
+
9
+ | Finding | Meaning |
10
+ |---|---|
11
+ | `orphan` | resting at the venue, unknown to the journal |
12
+ | `ghost` | open in the journal, gone at the venue |
13
+ | `drift` | known to both, different status, filled amount or price |
14
+ | `position` | the ledger and the venue disagree about a position |
15
+ | `fill` | a fill the venue has and the journal does not |
16
+ | `balance` | cash moved without a fill explaining it |
17
+
18
+ Findings are reported, not silently fixed. The policy decides what happens
19
+ to each: `report` only says so, `adopt` takes an orphan into the journal so
20
+ the engine manages it from now on, `cancel` pulls it. A ghost is always
21
+ closed in the journal, because the venue is the authority on whether an
22
+ order exists.
23
+
24
+ Balance reconciliation is the one that catches what nothing else does: a
25
+ deposit, a withdrawal, a settlement, or a fee charged outside any fill. It
26
+ compares the venue's cash against the cash the journal can explain, and
27
+ reports the difference rather than adjusting anything.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from dataclasses import dataclass, field
33
+ from decimal import Decimal
34
+ from typing import Any, Literal
35
+
36
+ from ..trading.base import TradingExchange
37
+ from ..trading.types import Balance, Fill, HeldBy, Order, OrderStatus, Position, PositionSide
38
+ from .engine import Engine
39
+
40
+ log = logging.getLogger("synpath.engine")
41
+
42
+ ZERO = Decimal("0")
43
+
44
+ OrphanPolicy = Literal["report", "adopt", "cancel"]
45
+ Finding = Literal["orphan", "ghost", "drift", "position", "fill", "balance"]
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class Difference:
50
+ kind: Finding
51
+ venue: str
52
+ key: str
53
+ detail: dict[str, Any] = field(default_factory=dict)
54
+ action: str = "reported"
55
+
56
+ def as_event(self) -> dict[str, Any]:
57
+ return {"kind": self.kind, "venue": self.venue, "key": self.key, "action": self.action, **self.detail}
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class ReconcileReport:
62
+ venue: str
63
+ orphans: list[Difference] = field(default_factory=list)
64
+ ghosts: list[Difference] = field(default_factory=list)
65
+ drifts: list[Difference] = field(default_factory=list)
66
+ positions: list[Difference] = field(default_factory=list)
67
+ fills: list[Difference] = field(default_factory=list)
68
+ balances: list[Difference] = field(default_factory=list)
69
+ checked_orders: int = 0
70
+ checked_positions: int = 0
71
+ new_fills: int = 0
72
+
73
+ @property
74
+ def differences(self) -> list[Difference]:
75
+ return [*self.orphans, *self.ghosts, *self.drifts, *self.positions, *self.fills, *self.balances]
76
+
77
+ @property
78
+ def clean(self) -> bool:
79
+ return not self.differences
80
+
81
+ def summary(self) -> dict[str, Any]:
82
+ return {
83
+ "venue": self.venue, "clean": self.clean, "orders_checked": self.checked_orders,
84
+ "positions_checked": self.checked_positions, "new_fills": self.new_fills,
85
+ "orphans": len(self.orphans), "ghosts": len(self.ghosts), "drifts": len(self.drifts),
86
+ "position_differences": len(self.positions), "balance_differences": len(self.balances),
87
+ }
88
+
89
+
90
+ class Reconciler:
91
+ """Compares one engine against the venues it trades."""
92
+
93
+ def __init__(
94
+ self,
95
+ engine: Engine,
96
+ *,
97
+ orphan_policy: OrphanPolicy = "report",
98
+ position_tolerance: Decimal = ZERO,
99
+ balance_tolerance: Decimal = Decimal("0.01"),
100
+ ):
101
+ self.engine = engine
102
+ self.orphan_policy = orphan_policy
103
+ self.position_tolerance = position_tolerance
104
+ self.balance_tolerance = balance_tolerance
105
+
106
+ async def run(self, venue: str | None = None) -> list[ReconcileReport]:
107
+ """Reconcile one venue or all of them."""
108
+ names = [venue] if venue else list(self.engine.adapters)
109
+ reports = []
110
+ for name in names:
111
+ report = await self.venue(name, self.engine.adapters[name])
112
+ reports.append(report)
113
+ return reports
114
+
115
+ async def venue(self, venue: str, adapter: TradingExchange) -> ReconcileReport:
116
+ report = ReconcileReport(venue=venue)
117
+ await self._orders(venue, adapter, report)
118
+ await self._fills(venue, adapter, report)
119
+ await self._positions(venue, adapter, report)
120
+ await self._balance(venue, adapter, report)
121
+ await self.engine.bus.publish("reconcile.done", report.summary(), key=venue)
122
+ for difference in report.differences:
123
+ await self.engine.bus.publish(f"reconcile.{difference.kind}", difference.as_event(), key=f"{venue}:{difference.key}")
124
+ return report
125
+
126
+ # -- orders ---------------------------------------------------------------
127
+
128
+ async def _orders(self, venue: str, adapter: TradingExchange, report: ReconcileReport) -> None:
129
+ live = {o.id: o for o in await adapter.fetch_open_orders()}
130
+ # Engine-held parents are not orders at the venue: asking about one
131
+ # would report it missing every minute.
132
+ mine = {o.id: o for o in await self.engine.journal.open_orders(venue=venue) if o.held_by != HeldBy.ENGINE}
133
+ report.checked_orders = len(live) + len(mine)
134
+
135
+ for order_id, order in live.items():
136
+ known = mine.get(order_id)
137
+ if known is None:
138
+ # An order this engine never wrote down. Adopting it means
139
+ # managing it; cancelling it assumes nobody else should be
140
+ # trading this account. Reporting is the only safe default.
141
+ difference = Difference("orphan", venue, order_id, {
142
+ "market_id": order.market_id, "side": order.side.value,
143
+ "amount": str(order.amount), "price": str(order.price) if order.price else None,
144
+ "client_order_id": order.client_order_id,
145
+ })
146
+ if self.orphan_policy == "adopt":
147
+ await self.engine.on_order(order)
148
+ difference.action = "adopted"
149
+ elif self.orphan_policy == "cancel":
150
+ try:
151
+ await adapter.cancel_order(order_id, market_id=order.market_id)
152
+ difference.action = "canceled"
153
+ except Exception as exc:
154
+ difference.action = f"cancel failed: {type(exc).__name__}"
155
+ report.orphans.append(difference)
156
+ continue
157
+ if _drifted(known, order):
158
+ await self.engine.on_order(order)
159
+ report.drifts.append(Difference("drift", venue, order_id, {
160
+ "journal_status": known.status.value, "venue_status": order.status.value,
161
+ "journal_filled": str(known.filled), "venue_filled": str(order.filled),
162
+ "journal_price": str(known.price) if known.price else None,
163
+ "venue_price": str(order.price) if order.price else None,
164
+ }, action="updated"))
165
+
166
+ for order_id, order in mine.items():
167
+ if order_id in live:
168
+ continue
169
+ # Open here, gone there. The venue is the authority: close it, but
170
+ # read it first, because it may have filled rather than vanished.
171
+ final = None
172
+ try:
173
+ final = await adapter.fetch_order(order_id)
174
+ except Exception:
175
+ final = None
176
+ closed = final or order.model_copy(update={"status": OrderStatus.CANCELED})
177
+ await self.engine.on_order(closed)
178
+ report.ghosts.append(Difference("ghost", venue, order_id, {
179
+ "market_id": order.market_id, "journal_status": order.status.value,
180
+ "final_status": closed.status.value, "filled": str(closed.filled),
181
+ }, action="closed"))
182
+
183
+ # -- fills ----------------------------------------------------------------
184
+
185
+ async def _fills(self, venue: str, adapter: TradingExchange, report: ReconcileReport) -> None:
186
+ if not adapter.has.get("fetch_my_trades"):
187
+ return
188
+ since = int(await self.engine.journal.cursor(f"reconcile_fills:{venue}", 0) or 0)
189
+ try:
190
+ fills = await adapter.fetch_my_trades(since=since or None, limit=500)
191
+ except Exception as exc:
192
+ log.warning("synpath.engine: reading fills from %s failed: %s", venue, exc)
193
+ return
194
+ newest = since
195
+ for fill in fills:
196
+ newest = max(newest, fill.timestamp or 0)
197
+ if await self.engine.journal.has_fill(venue, fill.id):
198
+ continue
199
+ await self.engine.on_fill(fill)
200
+ report.new_fills += 1
201
+ report.fills.append(Difference("fill", venue, fill.id, {
202
+ "order_id": fill.order_id, "market_id": fill.market_id,
203
+ "amount": str(fill.amount), "price": str(fill.price),
204
+ }, action="booked"))
205
+ if newest > since:
206
+ await self.engine.journal.set_cursor(f"reconcile_fills:{venue}", newest)
207
+
208
+ # -- positions ------------------------------------------------------------
209
+
210
+ async def _positions(self, venue: str, adapter: TradingExchange, report: ReconcileReport) -> None:
211
+ if not adapter.has.get("fetch_positions"):
212
+ return
213
+ try:
214
+ live = await adapter.fetch_positions()
215
+ except Exception as exc:
216
+ log.warning("synpath.engine: reading positions from %s failed: %s", venue, exc)
217
+ return
218
+ rows = self.engine.ledger.merged(live)
219
+ report.checked_positions = len(rows)
220
+ for row in rows:
221
+ if row["venue"] != venue:
222
+ continue
223
+ difference = row["difference"]
224
+ if abs(difference) > self.position_tolerance:
225
+ report.positions.append(Difference("position", venue, row["market_id"], {
226
+ "account": row["account"], "engine": str(row["engine"]),
227
+ "venue_contracts": str(row["venue_contracts"]), "difference": str(difference),
228
+ "books": {k: str(v) for k, v in row["books"].items()},
229
+ }))
230
+
231
+ # -- balance --------------------------------------------------------------
232
+
233
+ async def _balance(self, venue: str, adapter: TradingExchange, report: ReconcileReport) -> None:
234
+ if not adapter.has.get("fetch_balance"):
235
+ return
236
+ try:
237
+ balance = await adapter.fetch_balance()
238
+ except Exception as exc:
239
+ log.warning("synpath.engine: reading the balance from %s failed: %s", venue, exc)
240
+ return
241
+ key = f"balance:{venue}"
242
+ previous = await self.engine.journal.cursor(key)
243
+ await self.engine.journal.set_cursor(key, str(balance.total))
244
+ if previous is None:
245
+ return
246
+ moved = balance.total - Decimal(previous)
247
+ if moved == ZERO:
248
+ return
249
+ explained = await self._explained_cash(venue)
250
+ unexplained = moved - explained
251
+ if abs(unexplained) > self.balance_tolerance:
252
+ report.balances.append(Difference("balance", venue, balance.currency, {
253
+ "moved": str(moved), "explained_by_fills": str(explained), "unexplained": str(unexplained),
254
+ "total": str(balance.total), "available": str(balance.available),
255
+ "note": "a deposit, a withdrawal, a settlement or a fee charged outside a fill",
256
+ }))
257
+
258
+ async def _explained_cash(self, venue: str) -> Decimal:
259
+ """Cash the fills since the last check account for."""
260
+ key = f"balance_cursor:{venue}"
261
+ since = int(await self.engine.journal.cursor(key, 0) or 0)
262
+ newest = since
263
+ total = ZERO
264
+ for fill in await self.engine.journal.fills(since_ts=since, venue=venue):
265
+ newest = max(newest, fill.timestamp or 0)
266
+ signed = -1 if fill.side.value == "buy" else 1
267
+ total += Decimal(signed) * fill.price * fill.amount - (fill.fee or ZERO)
268
+ if newest > since:
269
+ await self.engine.journal.set_cursor(key, newest)
270
+ return total
271
+
272
+
273
+ def _drifted(mine: Order, theirs: Order) -> bool:
274
+ return (
275
+ mine.status != theirs.status
276
+ or mine.filled != theirs.filled
277
+ or (mine.price or ZERO) != (theirs.price or ZERO)
278
+ or (mine.remaining or ZERO) != (theirs.remaining or ZERO)
279
+ )
synpath/engine/risk.py ADDED
@@ -0,0 +1,403 @@
1
+ """Pre-trade risk: the rules that refuse an order before it is signed.
2
+
3
+ Every rule here answers one question and names itself when it says no, so a
4
+ rejection is a sentence an operator can act on rather than a generic error.
5
+ The configuration is versioned in the journal: a rejection records which
6
+ version refused it, and a later argument about why is settled by reading
7
+ that version instead of guessing what the file said at the time.
8
+
9
+ Rules fall into three groups:
10
+
11
+ **About this order.** Price collar against the mark, order size and notional,
12
+ whole-contract and tick rules the venue would reject anyway, a restricted
13
+ list, and a guard against sending anything into a market that is about to
14
+ close. Also the duplicate window: the same order twice within a few seconds
15
+ is almost always a retry loop, not a decision.
16
+
17
+ **About the book it joins.** Open-order count, position caps per instrument,
18
+ per event and per venue, the exchange's own position limit where the venue
19
+ publishes one, and the daily loss limit, which reads the ledger rather than
20
+ a number someone maintains by hand.
21
+
22
+ **About the firm.** Self-trade prevention: an order that would cross this
23
+ firm's own resting order is refused (or the resting one is cancelled first,
24
+ where the caller asks for that), because a wash trade is a compliance
25
+ problem, not a fill. It looks at every book in the account, not just the one
26
+ placing the order, which is why it lives here and not in a strategy.
27
+
28
+ The kill switch sits alongside the rules. It can be engaged globally or for
29
+ one venue, and it decides what happens to orders already resting: `cancel`
30
+ pulls them, `hold` leaves them and blocks new ones, `rearm` blocks new
31
+ orders and lifts itself after a timer. The engine maps it onto the venues'
32
+ own mechanisms where they exist, which is what makes it fast: a Kalshi order
33
+ group cancels server-side in one round trip, and a Polymarket heartbeat that
34
+ stops beating cancels everything within ten seconds even if this process is
35
+ gone.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import time
40
+ from collections import deque
41
+ from dataclasses import dataclass, field
42
+ from decimal import Decimal
43
+ from typing import Any, Iterable, Literal
44
+
45
+ from pydantic import BaseModel, ConfigDict, Field
46
+
47
+ from .. import ids
48
+ from ..trading.types import Account, Order, OrderRequest, Side
49
+ from .ledger import Ledger
50
+
51
+ ZERO = Decimal("0")
52
+ ONE = Decimal("1")
53
+
54
+ HaltPolicy = Literal["cancel", "hold", "rearm"]
55
+ SelfTradeScope = Literal["off", "account", "firm"]
56
+
57
+
58
+ class RiskConfig(BaseModel):
59
+ """What the engine will and will not do. Stored as a version in the journal."""
60
+
61
+ model_config = ConfigDict(extra="forbid")
62
+
63
+ enabled: bool = True
64
+
65
+ # -- this order --
66
+ price_collar: Decimal | None = Decimal("0.10")
67
+ """How far from the mark a limit may sit, in price units (contracts are
68
+ priced 0 to 1, so 0.10 is ten cents). `None` disables the check."""
69
+ max_order_contracts: Decimal | None = None
70
+ max_order_notional: Decimal | None = None
71
+ """Price times contracts, in account currency."""
72
+ min_price: Decimal = Decimal("0")
73
+ max_price: Decimal = Decimal("1")
74
+ duplicate_window_ms: int = 2_000
75
+ """The same instrument, side, price and size within this window is a retry."""
76
+ closing_soon_s: int | None = 60
77
+ """Refuse opening orders this close to a market's resolution time."""
78
+ restricted: list[str] = Field(default_factory=list)
79
+ """Instrument or market ids, or prefixes ending in `*`, nobody may trade."""
80
+
81
+ # -- the book --
82
+ max_open_orders: int | None = 200
83
+ max_position_contracts: Decimal | None = None
84
+ """Per instrument, per account, netted across books."""
85
+ max_event_contracts: Decimal | None = None
86
+ max_venue_notional: Decimal | None = None
87
+ daily_loss_limit: Decimal | None = None
88
+ """Realized loss since the day's start, as a positive number."""
89
+ max_orders_per_minute: int | None = 120
90
+
91
+ # -- the firm --
92
+ self_trade_prevention: SelfTradeScope = "firm"
93
+ exchange_limits: dict[str, Decimal] = Field(default_factory=dict)
94
+ """Venue-published position limits, per market id."""
95
+ paused_books: list[str] = Field(default_factory=list)
96
+ """Strategies that may cancel but not open."""
97
+
98
+ def paused(self, book: str | None) -> bool:
99
+ return bool(book and book in self.paused_books)
100
+
101
+ def restricts(self, market_id: str, _unused: str | None = None) -> str | None:
102
+ for pattern in self.restricted:
103
+ for candidate in (market_id, ids.split(market_id)[1]):
104
+ if not candidate:
105
+ continue
106
+ if pattern.endswith("*") and candidate.startswith(pattern[:-1]):
107
+ return pattern
108
+ if candidate == pattern:
109
+ return pattern
110
+ return None
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class Decision:
115
+ """The answer, and which rule gave it."""
116
+
117
+ ok: bool
118
+ rule: str = ""
119
+ message: str = ""
120
+ config_version: int | None = None
121
+ detail: dict[str, Any] = field(default_factory=dict)
122
+
123
+ def __bool__(self) -> bool:
124
+ return self.ok
125
+
126
+
127
+ ALLOWED = Decision(ok=True)
128
+
129
+
130
+ @dataclass(slots=True)
131
+ class KillSwitch:
132
+ """Stop trading now, and say what happens to what is already resting."""
133
+
134
+ engaged: bool = False
135
+ scope: str = "*"
136
+ """`*` for everything, or a venue id."""
137
+ policy: HaltPolicy = "cancel"
138
+ reason: str = ""
139
+ engaged_ts: int | None = None
140
+ rearm_after_s: float | None = None
141
+ venues: set[str] = field(default_factory=set)
142
+
143
+ def engage(self, *, reason: str, scope: str = "*", policy: HaltPolicy = "cancel", rearm_after_s: float | None = None) -> None:
144
+ self.engaged = True
145
+ self.scope = scope
146
+ self.policy = policy
147
+ self.reason = reason
148
+ self.engaged_ts = int(time.time() * 1000)
149
+ self.rearm_after_s = rearm_after_s
150
+ if scope != "*":
151
+ self.venues.add(scope)
152
+
153
+ def release(self, *, scope: str | None = None) -> None:
154
+ if scope and scope != "*" and scope in self.venues:
155
+ self.venues.discard(scope)
156
+ if not self.venues and self.scope == scope:
157
+ self.engaged = False
158
+ return
159
+ self.engaged = False
160
+ self.venues.clear()
161
+ self.reason = ""
162
+ self.rearm_after_s = None
163
+
164
+ def blocks(self, venue: str, *, now: float | None = None) -> bool:
165
+ if not self.engaged:
166
+ return False
167
+ if self.policy == "rearm" and self.rearm_after_s is not None and self.engaged_ts is not None:
168
+ if ((now or time.time()) * 1000 - self.engaged_ts) / 1000 >= self.rearm_after_s:
169
+ self.release()
170
+ return False
171
+ return self.scope == "*" or venue in self.venues or venue == self.scope
172
+
173
+
174
+ class RiskEngine:
175
+ """Runs the rules. Holds no venue state: everything it needs is passed in."""
176
+
177
+ def __init__(
178
+ self,
179
+ config: RiskConfig | None = None,
180
+ *,
181
+ ledger: Ledger | None = None,
182
+ config_version: int | None = None,
183
+ clock: Any = time.time,
184
+ ):
185
+ self.config = config or RiskConfig()
186
+ self.config_version = config_version
187
+ self.ledger = ledger
188
+ self.clock = clock
189
+ self.kill = KillSwitch()
190
+ self._recent: deque[tuple[float, tuple[str, str, str, str]]] = deque(maxlen=512)
191
+ self._sent: deque[float] = deque(maxlen=4096)
192
+ self.day_start_ms: int = _day_start_ms(self.clock())
193
+ self.realized_at_day_start: Decimal = ZERO
194
+
195
+ # -- configuration --------------------------------------------------------
196
+
197
+ def configure(self, config: RiskConfig, *, version: int | None = None) -> None:
198
+ self.config, self.config_version = config, version
199
+
200
+ def roll_day(self, *, realized_now: Decimal | None = None) -> None:
201
+ """Start a new trading day: the loss limit counts from here."""
202
+ self.day_start_ms = _day_start_ms(self.clock())
203
+ if realized_now is not None:
204
+ self.realized_at_day_start = realized_now
205
+ elif self.ledger is not None:
206
+ self.realized_at_day_start = self.ledger.total().realized
207
+
208
+ def realized_today(self) -> Decimal:
209
+ if self.ledger is None:
210
+ return ZERO
211
+ return self.ledger.total().realized - self.realized_at_day_start
212
+
213
+ # -- the check ------------------------------------------------------------
214
+
215
+ def check(
216
+ self,
217
+ request: OrderRequest,
218
+ *,
219
+ venue: str,
220
+ account: Account,
221
+ open_orders: Iterable[Order] = (),
222
+ mark: Decimal | None = None,
223
+ market_close_ts: int | None = None,
224
+ event_id: str | None = None,
225
+ venue_notional: Decimal | None = None,
226
+ deliberate: bool = False,
227
+ ) -> Decision:
228
+ """Everything that must be true before this order is signed.
229
+
230
+ `deliberate` is true when the caller named the order's
231
+ `client_order_id` itself: the duplicate window is for an accidental
232
+ second click, and a caller that names each order is not clicking."""
233
+ config = self.config
234
+ if not config.enabled:
235
+ return self._ok()
236
+ if self.kill.blocks(venue, now=self.clock()):
237
+ return self._no("kill_switch", f"trading is halted: {self.kill.reason or 'kill switch engaged'}")
238
+ if config.paused(request.book):
239
+ return self._no("book_paused", f"strategy {request.book!r} is paused; it may cancel but not open")
240
+
241
+ market_id = request.market_id
242
+ if (pattern := config.restricts(market_id)):
243
+ return self._no("restricted", f"{market_id} matches the restricted entry {pattern!r}")
244
+
245
+ price = request.price
246
+ if price is not None:
247
+ if not (config.min_price <= price <= config.max_price):
248
+ return self._no("price_bounds", f"price {price} is outside {config.min_price}-{config.max_price}")
249
+ if config.price_collar is not None and mark is not None:
250
+ away = abs(price - mark)
251
+ if away > config.price_collar:
252
+ return self._no(
253
+ "price_collar", f"price {price} is {away} from the mark {mark}, over the collar {config.price_collar}",
254
+ detail={"mark": str(mark), "away": str(away)},
255
+ )
256
+ if config.max_order_contracts is not None and request.amount > config.max_order_contracts:
257
+ return self._no("max_order_contracts", f"{request.amount} contracts is over the {config.max_order_contracts} limit")
258
+ notional = (price or ONE) * request.amount
259
+ if config.max_order_notional is not None and notional > config.max_order_notional:
260
+ return self._no("max_order_notional", f"{notional} is over the {config.max_order_notional} order limit")
261
+
262
+ now = self.clock()
263
+ # An engine-held order's children are sliced on purpose and often look
264
+ # alike (an iceberg's reloads, a TWAP's slices, two parents on one
265
+ # market), so the window applies to orders sent from outside only.
266
+ if config.duplicate_window_ms and not deliberate and not request.tags.get("parent"):
267
+ signature = (market_id, request.side.value, str(price), str(request.amount))
268
+ cutoff = now - config.duplicate_window_ms / 1000
269
+ for stamp, seen in reversed(self._recent):
270
+ if stamp < cutoff:
271
+ break
272
+ if seen == signature:
273
+ return self._no(
274
+ "duplicate", f"the same order was sent {round((now - stamp) * 1000)}ms ago; "
275
+ f"pass a different client_order_id if this is deliberate",
276
+ )
277
+ if config.max_orders_per_minute is not None:
278
+ while self._sent and self._sent[0] < now - 60:
279
+ self._sent.popleft()
280
+ if len(self._sent) >= config.max_orders_per_minute:
281
+ return self._no("order_rate", f"{len(self._sent)} orders in the last minute, at the {config.max_orders_per_minute} limit")
282
+
283
+ if config.closing_soon_s is not None and market_close_ts is not None and not request.reduce_only:
284
+ left = (market_close_ts - now * 1000) / 1000
285
+ if left <= config.closing_soon_s:
286
+ return self._no(
287
+ "closing_soon", f"{market_id} resolves in {round(left)}s, inside the {config.closing_soon_s}s guard",
288
+ detail={"seconds_left": round(left)},
289
+ )
290
+
291
+ resting = [o for o in open_orders if not o.is_terminal]
292
+ if config.max_open_orders is not None and len(resting) >= config.max_open_orders:
293
+ return self._no("max_open_orders", f"{len(resting)} orders already resting, at the {config.max_open_orders} limit")
294
+
295
+ if config.self_trade_prevention != "off" and price is not None:
296
+ crossing = self._self_cross(request, resting, account, price)
297
+ if crossing is not None:
298
+ return self._no(
299
+ "self_trade", f"this would trade against the firm's own order {crossing.id} "
300
+ f"({crossing.side.value} {crossing.remaining} at {crossing.price} on {crossing.market_id})",
301
+ detail={"order_id": crossing.id, "book": crossing.book or ""},
302
+ )
303
+
304
+ signed = request.amount if request.side == Side.BUY else -request.amount
305
+ if self.ledger is not None:
306
+ current = self._net_contracts(account.key, venue, market_id)
307
+ after = abs(current + signed)
308
+ if config.max_position_contracts is not None and after > config.max_position_contracts:
309
+ return self._no(
310
+ "max_position", f"{market_id} would reach {after} contracts, over the {config.max_position_contracts} limit",
311
+ detail={"current": str(current), "after": str(after)},
312
+ )
313
+ limit = config.exchange_limits.get(market_id)
314
+ if limit is not None and after > limit:
315
+ return self._no(
316
+ "exchange_limit", f"{market_id} would reach {after} contracts, over the exchange limit {limit}",
317
+ detail={"limit": str(limit)},
318
+ )
319
+ if config.max_event_contracts is not None and event_id:
320
+ total = self._event_contracts(account.key, venue, event_id) + abs(signed)
321
+ if total > config.max_event_contracts:
322
+ return self._no("max_event", f"event {event_id} would reach {total} contracts, over {config.max_event_contracts}")
323
+ if config.daily_loss_limit is not None:
324
+ loss = -self.realized_today()
325
+ if loss >= config.daily_loss_limit:
326
+ return self._no(
327
+ "daily_loss", f"today's realized loss {loss} has reached the {config.daily_loss_limit} limit",
328
+ detail={"loss": str(loss)},
329
+ )
330
+ if config.max_venue_notional is not None and venue_notional is not None:
331
+ if venue_notional + notional > config.max_venue_notional:
332
+ return self._no(
333
+ "max_venue_notional",
334
+ f"{venue} would hold {venue_notional + notional} of notional, over {config.max_venue_notional}",
335
+ )
336
+ return self._ok()
337
+
338
+ # -- bookkeeping ----------------------------------------------------------
339
+
340
+ def record_sent(self, request: OrderRequest) -> None:
341
+ """Count an order that actually went out, for the rate and duplicate windows."""
342
+ now = self.clock()
343
+ self._sent.append(now)
344
+ self._recent.append((now, (request.market_id, request.side.value, str(request.price), str(request.amount))))
345
+
346
+ def _self_cross(self, request: OrderRequest, resting: list[Order], account: Account, price: Decimal) -> Order | None:
347
+ scope = self.config.self_trade_prevention
348
+ side = request.side
349
+ limit = price
350
+ for order in resting:
351
+ if order.price is None:
352
+ continue
353
+ if order.market_id != request.market_id:
354
+ continue
355
+ if scope == "account":
356
+ order_account = order.account.key if order.account else None
357
+ if order_account != account.key:
358
+ continue
359
+ other_side = order.side
360
+ other_price = order.price
361
+ if other_side == side:
362
+ continue
363
+ if side == Side.BUY and limit >= other_price:
364
+ return order
365
+ if side == Side.SELL and limit <= other_price:
366
+ return order
367
+ return None
368
+
369
+ def _net_contracts(self, account_key: str, venue: str, market_id: str) -> Decimal:
370
+ total = ZERO
371
+ for state in self.ledger.positions.values() if self.ledger else ():
372
+ if state.account_key == account_key and state.venue == venue and state.market_id == market_id:
373
+ total += state.contracts
374
+ return total
375
+
376
+ def _event_contracts(self, account_key: str, venue: str, event_id: str) -> Decimal:
377
+ total = ZERO
378
+ for state in self.ledger.positions.values() if self.ledger else ():
379
+ if state.account_key == account_key and state.venue == venue and state.market_id.startswith(event_id):
380
+ total += abs(state.contracts)
381
+ return total
382
+
383
+ def _ok(self) -> Decision:
384
+ return Decision(ok=True, config_version=self.config_version)
385
+
386
+ def _no(self, rule: str, message: str, *, detail: dict[str, Any] | None = None) -> Decision:
387
+ return Decision(ok=False, rule=rule, message=message, config_version=self.config_version, detail=detail or {})
388
+
389
+
390
+ def _day_start_ms(now: float) -> int:
391
+ """Midnight UTC before `now`, in milliseconds."""
392
+ day = int(now) // 86_400 * 86_400
393
+ return day * 1000
394
+
395
+
396
+ def market_of(market_id: str) -> str:
397
+ """Kept for callers written against the instrument era; the id is the market."""
398
+ return market_id
399
+
400
+
401
+ __all__ = [
402
+ "RiskConfig", "RiskEngine", "Decision", "KillSwitch", "HaltPolicy", "SelfTradeScope", "ALLOWED", "market_of",
403
+ ]