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,888 @@
1
+ """The engine: one place that decides, records, sends, and knows what happened.
2
+
3
+ An adapter sends an order. An engine is what you need when the process can
4
+ die between deciding and sending, when two people share an account, when a
5
+ venue answers late, and when someone has to explain afterwards what was
6
+ refused and why. It owns four things: the journal, the ledger, the risk
7
+ rules, and the venue adapters.
8
+
9
+ **Submitting is four steps, in this order.** Risk decides, the intent is
10
+ written to disk with its client order id, the venue is called, the answer is
11
+ written. The write happens before the call because that is the only
12
+ ordering that survives a crash: a restarted engine finds an intent marked
13
+ `sending`, asks the venue whether an order with that client order id exists,
14
+ and either adopts it or sweeps it. The other order would lose orders
15
+ silently or send them twice.
16
+
17
+ **An idempotency key per operation.** Creating uses the request's client
18
+ order id; cancelling and editing get their own, so a retried cancel is the
19
+ same cancel and a retried edit does not apply twice. All three are journaled
20
+ before they leave.
21
+
22
+ **Recovery is a first-class path, not an error handler.** `recover()` runs
23
+ at startup: it rebuilds the ledger from the journal's fills, resolves every
24
+ in-doubt intent against the venue, and reports what it adopted, swept or
25
+ could not explain. `sweep()` runs on a timer and does the same for intents
26
+ that have been in doubt longer than the engine will wait.
27
+
28
+ **The kill switch uses the venue's own mechanism first.** `halt()` engages
29
+ the switch, then applies its policy: `cancel` pulls every resting order
30
+ through the adapter's cancel-all (a Kalshi order group, a Polymarket
31
+ heartbeat stopping, one call rather than one per order), `hold` leaves them
32
+ and refuses new ones, `rearm` refuses new ones and lifts itself after a
33
+ timer. New orders are refused by the risk rules the moment it engages, so a
34
+ halt cannot race a submit.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import logging
40
+ import time
41
+ import uuid
42
+ from dataclasses import dataclass, field
43
+ from decimal import Decimal
44
+ from typing import Any, Iterable, Mapping
45
+
46
+ from .. import ids
47
+ from ..bucket import Bucket, bucket_id_of, is_bucket_id
48
+ from ..errors import BadRequest, ExchangeError, NetworkError
49
+ from ..trading.base import TradingExchange
50
+ from ..trading.errors import OrderNotFound, OrderRejected, RiskRejected
51
+ from ..trading.types import (
52
+ VENUE_ORDER_TYPES, Account, EditRequest, Fill, HeldBy, Order, OrderRequest, OrderStatus, OrderType, Position,
53
+ Settlement, Side,
54
+ )
55
+ from .events import EventBus
56
+ from .fair_values import FairValues
57
+ from .journal import IntentState, Journal, LeaseLost, now_ms
58
+ from .ledger import Ledger
59
+ from .orders import ManagedOrders, as_gtd
60
+ from .orders.manager import REGISTRY
61
+ from .risk import Decision, HaltPolicy, RiskConfig, RiskEngine
62
+
63
+ log = logging.getLogger("synpath.engine")
64
+
65
+ ZERO = Decimal("0")
66
+
67
+
68
+ @dataclass
69
+ class EngineConfig:
70
+ """How this engine runs. The risk rules live in `RiskConfig`."""
71
+
72
+ journal_path: str = "synpath.db"
73
+ lease_name: str = "engine"
74
+ lease_ttl_ms: int = 30_000
75
+ lease_renew_s: float = 10.0
76
+ in_doubt_timeout_s: float = 20.0
77
+ """How long an unanswered send stays in doubt before the sweep resolves it."""
78
+ lost_after_s: float = 60.0
79
+ """How long the venue must keep saying it has no such order before the
80
+ engine believes it. Venues do not list an order the instant they accept
81
+ it -- Kalshi's demo takes a few hundred milliseconds -- so declaring one
82
+ lost too early would abandon a live order."""
83
+ sweep_interval_s: float = 10.0
84
+ reconcile_interval_s: float = 60.0
85
+ poll_interval_s: float = 5.0
86
+ """How often to poll orders and fills when no stream is attached."""
87
+ halt_policy: HaltPolicy = "cancel"
88
+ require_lease: bool = True
89
+ mark_stale_after_s: float = 300.0
90
+ managed_tick_s: float = 1.0
91
+ """How often engine-held orders get their timer. A TWAP slice, an
92
+ iceberg reload and a peg's minimum stay are all measured against it."""
93
+ session_timezone: str | None = None
94
+ """Where "today" ends, for Day orders. The machine's zone by default."""
95
+ session_end: str = "23:59:59"
96
+
97
+
98
+ @dataclass(slots=True)
99
+ class Recovery:
100
+ """What `recover()` found. Every number here is a thing that was in doubt."""
101
+
102
+ in_doubt: int = 0
103
+ adopted: int = 0
104
+ """Intents whose order was found at the venue after all."""
105
+ swept: int = 0
106
+ """Intents with no order at the venue: nothing was sent."""
107
+ unresolved: int = 0
108
+ """Still in doubt: the venue could not be reached. Trading stays blocked
109
+ for these instruments until it can."""
110
+ fills_replayed: int = 0
111
+ orders_open: int = 0
112
+ managed: int = 0
113
+ """Engine-held orders brought back and still running."""
114
+ details: list[dict[str, Any]] = field(default_factory=list)
115
+
116
+ def summary(self, *, detail_limit: int = 20) -> dict[str, Any]:
117
+ return {
118
+ "in_doubt": self.in_doubt, "adopted": self.adopted, "swept": self.swept, "unresolved": self.unresolved,
119
+ "fills_replayed": self.fills_replayed, "orders_open": self.orders_open, "managed": self.managed,
120
+ "details": self.details[:detail_limit],
121
+ }
122
+
123
+
124
+ POST_ONLY_KINDS = ("iceberg", "peg", "twap")
125
+ """Engine types whose children rest on the book, where `post_only` means
126
+ something. A TWAP only in its `limit` style."""
127
+
128
+
129
+ def check_managed_fields(request: OrderRequest, *, now_ms: int) -> None:
130
+ """Refuse the order fields an engine-held type cannot honour, rather than
131
+ ignore them. `reduce_only` always passes to the children; `expires_at` is
132
+ the whole order's expiry."""
133
+ kind = str(request.params.get("managed_kind") or request.type.value)
134
+ if request.post_only:
135
+ if kind in ("oco", "bracket"):
136
+ raise BadRequest(f"post_only goes on the {kind}'s legs, not on the order itself")
137
+ style = str(request.params.get("style") or "limit")
138
+ if kind not in POST_ONLY_KINDS or (kind == "twap" and style != "limit"):
139
+ raise BadRequest(f"post_only is for types that rest on the book (iceberg, peg, a limit-style twap); "
140
+ f"a {kind} order takes liquidity")
141
+ if request.expires_at is not None and request.expires_at <= now_ms:
142
+ raise BadRequest("expires_at is in the past")
143
+
144
+
145
+ class Engine:
146
+ """Order entry with a memory.
147
+
148
+ ```python
149
+ engine = Engine({"kalshi": kalshi_adapter}, EngineConfig(journal_path="trading.db"))
150
+ async with engine:
151
+ await engine.submit(OrderRequest(market_id="kalshi:KXX", side=Side.BUY,
152
+ amount=Decimal("10"), price=Decimal("0.42"), book="alpha"))
153
+ ```
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ adapters: Mapping[str, TradingExchange],
159
+ config: EngineConfig | None = None,
160
+ *,
161
+ risk: RiskConfig | None = None,
162
+ journal: Journal | None = None,
163
+ accounts: Mapping[str, Account] | None = None,
164
+ clock: Any = time.time,
165
+ ):
166
+ self.config = config or EngineConfig()
167
+ self.adapters: dict[str, TradingExchange] = dict(adapters)
168
+ self.journal = journal or Journal(self.config.journal_path)
169
+ self.bus = EventBus(self.journal)
170
+ self.ledger = Ledger()
171
+ self.fair_values = FairValues(self.journal, stale_after_s=self.config.mark_stale_after_s)
172
+ self.risk = RiskEngine(risk or RiskConfig(), ledger=self.ledger, clock=clock)
173
+ self.accounts: dict[str, Account] = dict(accounts or {})
174
+ for venue in self.adapters:
175
+ # Every configured venue trades under some account; name the
176
+ # default one, so listing accounts shows what orders will use.
177
+ self.accounts.setdefault(venue, Account(venue=venue))
178
+ self.clock = clock
179
+ self.running = False
180
+ self.started_ts: int | None = None
181
+ self._tasks: list[asyncio.Task] = []
182
+ self._locks: dict[str, asyncio.Lock] = {}
183
+ self.market_close: dict[str, int] = {}
184
+ """Resolution timestamps, for the closing-soon guard; filled by the caller."""
185
+ self.event_of: dict[str, str] = {}
186
+ """Market to event id, for the per-event cap."""
187
+ self._open_cache: dict[str, Order] = {}
188
+ self.orders = ManagedOrders(self)
189
+ self.books: dict[str, Any] = {}
190
+ """The local books engine-held orders watch, fed by `on_book`."""
191
+ self.last_trade: dict[str, Decimal] = {}
192
+
193
+ # -- lifecycle ------------------------------------------------------------
194
+
195
+ async def start(self) -> Recovery:
196
+ await self.journal.open()
197
+ if self.config.require_lease:
198
+ await self.journal.acquire_lease(self.config.lease_name, ttl_ms=self.config.lease_ttl_ms)
199
+ version = await self.journal.save_config("risk", self.risk.config.model_dump(mode="json"))
200
+ self.risk.config_version = version
201
+ await self.fair_values.load()
202
+ recovery = await self.recover()
203
+ self.running = True
204
+ self.started_ts = now_ms()
205
+ await self.bus.publish("engine.started", {
206
+ "owner": self.journal.owner, "risk_version": version, "recovery": recovery.summary(),
207
+ })
208
+ return recovery
209
+
210
+ def background(self) -> dict[str, Any]:
211
+ """Every loop a running engine needs, by name, as coroutines to schedule.
212
+ One list for `run()` and for anything that hosts the engine itself (the
213
+ daemon, `synpath serve`), so a host cannot leave one out: without the
214
+ managed loop a TWAP never slices and nothing engine-held ever expires."""
215
+ return {
216
+ "lease": self._lease_loop(),
217
+ "sweep": self._sweep_loop(),
218
+ "poll": self._poll_loop(),
219
+ "managed": self._managed_loop(),
220
+ }
221
+
222
+ async def run(self) -> None:
223
+ """Start, then keep the background work going until `stop()`."""
224
+ if not self.running:
225
+ await self.start()
226
+ loop = asyncio.get_running_loop()
227
+ self._tasks = [loop.create_task(coro, name=f"synpath-engine-{name}") for name, coro in self.background().items()]
228
+ await asyncio.gather(*self._tasks)
229
+
230
+ async def stop(self) -> None:
231
+ self.running = False
232
+ for task in self._tasks:
233
+ task.cancel()
234
+ for task in self._tasks:
235
+ try:
236
+ await task
237
+ except (asyncio.CancelledError, Exception):
238
+ pass
239
+ self._tasks = []
240
+ if self.journal._db is not None:
241
+ await self.bus.publish("engine.stopped", {"owner": self.journal.owner})
242
+ await self.journal.close()
243
+
244
+ async def __aenter__(self) -> "Engine":
245
+ await self.start()
246
+ return self
247
+
248
+ async def __aexit__(self, *exc: Any) -> None:
249
+ await self.stop()
250
+
251
+ # -- submitting -----------------------------------------------------------
252
+
253
+ async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: Decimal | None = None) -> Order:
254
+ """Risk, journal, send, record. Raises `RiskRejected` before anything
255
+ is sent, or the venue's own error after."""
256
+ if is_bucket_id(request.market_id):
257
+ return await self._submit_bucket(request, mark=mark)
258
+ venue = venue or self._venue_of(request)
259
+ adapter = self._adapter(venue)
260
+ account = request.account or self.accounts.get(venue) or Account(venue=venue)
261
+ # Day is not a venue concept here: the expiry must outlive this process.
262
+ request = as_gtd(request, now_s=self.clock(), timezone_name=self.config.session_timezone,
263
+ session_end=self.config.session_end)
264
+ if request.type not in VENUE_ORDER_TYPES:
265
+ return await self.submit_managed(request, venue=venue, account=account, mark=mark)
266
+ if request.type == OrderType.MARKET and request.params.get("walk"):
267
+ # An explicit request for the engine's book walk rather than the
268
+ # adapter's single immediate limit.
269
+ walked = request.model_copy(update={"params": {**request.params, "managed_kind": "market_engine"}})
270
+ return await self.submit_managed(walked, venue=venue, account=account, mark=mark)
271
+ deliberate = request.client_order_id is not None
272
+ client_order_id = request.client_order_id or f"sp-{uuid.uuid4().hex[:20]}"
273
+ request = request.model_copy(update={"client_order_id": client_order_id, "account": account})
274
+
275
+ decision = self.check(request, venue=venue, account=account, mark=mark, deliberate=deliberate)
276
+ if not decision.ok:
277
+ await self.bus.publish("risk.rejected", {
278
+ "rule": decision.rule, "message": decision.message, "client_order_id": client_order_id,
279
+ "market_id": request.market_id, "book": request.book, "config_version": decision.config_version,
280
+ **decision.detail,
281
+ }, key=client_order_id)
282
+ raise RiskRejected(decision.message, rule=decision.rule)
283
+
284
+ await self.journal.record_intent(request, client_order_id=client_order_id, venue=venue, account=account)
285
+ async with self._lock(f"{venue}:{client_order_id}"):
286
+ await self.journal.mark_intent(client_order_id, IntentState.SENDING, bump_attempt=True)
287
+ self.risk.record_sent(request)
288
+ try:
289
+ order = await adapter.create_order(request)
290
+ except OrderRejected as exc:
291
+ await self.journal.mark_intent(client_order_id, IntentState.REJECTED, detail=str(exc))
292
+ await self.bus.publish("order.rejected", {"client_order_id": client_order_id, "error": str(exc)}, key=client_order_id)
293
+ raise
294
+ except (NetworkError, asyncio.TimeoutError) as exc:
295
+ # In doubt on purpose: the intent stays `sending` so the sweep
296
+ # asks the venue rather than this process guessing.
297
+ await self.bus.publish("order.in_doubt", {
298
+ "client_order_id": client_order_id, "venue": venue, "error": f"{type(exc).__name__}: {exc}",
299
+ }, key=client_order_id)
300
+ raise
301
+ except ExchangeError as exc:
302
+ await self.journal.mark_intent(client_order_id, IntentState.FAILED, detail=str(exc))
303
+ await self.bus.publish("order.failed", {"client_order_id": client_order_id, "error": str(exc)}, key=client_order_id)
304
+ raise
305
+ order = self._stamp(order, request)
306
+ await self.journal.upsert_order(order, event="order.accepted")
307
+ self._remember(order)
308
+ await self.journal.mark_intent(client_order_id, IntentState.SENT, order_id=order.id)
309
+ await self.bus.publish("order.accepted", order.model_dump(mode="json"), key=f"{venue}:{order.id}", persist=False)
310
+ return order
311
+
312
+ async def _submit_bucket(self, request: OrderRequest, *, mark: Decimal | None) -> Order:
313
+ """An order on a bucket is a market order the engine holds as a
314
+ `routed_limit` parent, with the bucket's definition on the request so
315
+ the parent is whole in the journal. Its `price` is the worst price the
316
+ caller accepts, in bucket terms, as on any market order here. The
317
+ nominal venue is the first member's; the legs each carry their own."""
318
+ if request.type != OrderType.MARKET:
319
+ raise BadRequest("an order on a bucket is a market order: set type to market, "
320
+ "and price to the worst price you accept")
321
+ if request.price is None:
322
+ raise BadRequest("a market order on a bucket needs price: the worst price you accept, in bucket terms")
323
+ row = await self.journal.bucket(bucket_id_of(request.market_id))
324
+ if row is None:
325
+ raise BadRequest(f"{request.market_id}: no such bucket in this journal")
326
+ bucket = Bucket.model_validate(row)
327
+ if bucket.status != "active":
328
+ raise BadRequest(f"{request.market_id}: the bucket is {bucket.status}")
329
+ missing = bucket.venues() - set(self.adapters)
330
+ if missing:
331
+ raise BadRequest(f"{request.market_id}: no adapter configured for {', '.join(sorted(missing))}")
332
+ nominal = ids.venue_of(bucket.members[0].market_id)
333
+ account = request.account or self.accounts.get(nominal) or Account(venue=nominal)
334
+ params = {**request.params, "managed_kind": "routed_limit", "bucket": bucket.model_dump(mode="json")}
335
+ request = request.model_copy(update={"params": params, "account": account})
336
+ return await self.submit_managed(request, venue=nominal, account=account, mark=mark)
337
+
338
+ async def save_bucket(self, bucket: Bucket) -> Bucket:
339
+ """Store or update a bucket definition in this engine's journal."""
340
+ bucket.check()
341
+ await self.journal.save_bucket(bucket.model_dump(mode="json"))
342
+ return bucket
343
+
344
+ async def submit_managed(self, request: OrderRequest, *, venue: str, account: Account,
345
+ mark: Decimal | None = None) -> Order:
346
+ """Accept an engine-held order: check it, journal it, start it."""
347
+ check_managed_fields(request, now_ms=int(self.clock() * 1000))
348
+ decision = self.check(request, venue=venue, account=account, mark=mark)
349
+ if not decision.ok:
350
+ await self.bus.publish("risk.rejected", {
351
+ "rule": decision.rule, "message": decision.message, "market_id": request.market_id,
352
+ "book": request.book, "type": request.type.value, "config_version": decision.config_version,
353
+ })
354
+ raise RiskRejected(decision.message, rule=decision.rule)
355
+ parent = await self.orders.create(request, venue=venue, account=account)
356
+ return parent.as_order()
357
+
358
+ async def submit_child(self, parent: Any, request: OrderRequest, **kw: Any) -> Order:
359
+ """A child of an engine-held order. A venue type goes to the venue; an
360
+ engine type becomes a parent of its own (a bracket's stop-loss).
361
+
362
+ The child's venue comes from its own market id, not the parent's: a
363
+ parent on a bucket puts legs on several venues. A child whose id names
364
+ no venue falls back to the parent's, which is every single-venue type."""
365
+ venue = self._child_venue(parent, request)
366
+ account = parent.account
367
+ if venue != parent.venue:
368
+ # `child_request` stamps the parent's account on every child; a
369
+ # leg on another venue must carry that venue's instead.
370
+ account = self.accounts.get(venue) or Account(venue=venue)
371
+ request = request.model_copy(update={"account": account})
372
+ if request.type in VENUE_ORDER_TYPES:
373
+ order = await self.submit(request, venue=venue, **kw)
374
+ self.orders.adopt_child(parent.id, order.id, order.venue)
375
+ return order
376
+ leg = await self.orders.create(request, venue=venue, account=account, owner=parent)
377
+ return leg.as_order()
378
+
379
+ @staticmethod
380
+ def _child_venue(parent: Any, request: OrderRequest) -> str:
381
+ try:
382
+ return ids.venue_of(request.market_id)
383
+ except BadRequest:
384
+ return parent.venue
385
+
386
+ async def cancel_child(self, parent: Any, order_id: str, venue: str | None = None) -> Order | None:
387
+ if order_id in self.orders.parents:
388
+ return (await self.orders.cancel(order_id, reason=f"cancelled by {parent.id}")).as_order()
389
+ child = parent.child_of(order_id, venue)
390
+ venue = venue or (child.venue if child is not None else parent.venue)
391
+ try:
392
+ return await self.cancel(order_id, venue=venue)
393
+ except OrderNotFound:
394
+ return None
395
+
396
+ def check(self, request: OrderRequest, *, venue: str, account: Account, mark: Decimal | None = None,
397
+ deliberate: bool = False) -> Decision:
398
+ """The risk decision on its own, for a caller that wants to ask first."""
399
+ market_id = request.market_id
400
+ if mark is None:
401
+ mark = self.fair_values.get(account.key, market_id)
402
+ return self.risk.check(
403
+ request, venue=venue, account=account,
404
+ open_orders=[o for o in self.open_orders(venue=venue)],
405
+ mark=mark, market_close_ts=self.market_close.get(market_id), event_id=self.event_of.get(market_id),
406
+ deliberate=deliberate,
407
+ )
408
+
409
+ async def cancel(self, order_id: str, *, venue: str | None = None, market_id: str | None = None) -> Order:
410
+ """Cancel, journaled with its own idempotency key. An engine-held order
411
+ pulls its children first."""
412
+ if order_id in self.orders.parents:
413
+ parent = await self.orders.cancel(order_id)
414
+ return parent.as_order()
415
+ order = await self._known(order_id, venue)
416
+ venue = venue or order.venue
417
+ adapter = self._adapter(venue)
418
+ key = f"cancel:{venue}:{order_id}"
419
+ request = OrderRequest(
420
+ market_id=order.market_id, side=order.side, amount=order.remaining or order.amount,
421
+ price=order.price, book=order.book, trader=order.trader, client_order_id=key,
422
+ )
423
+ account = order.account or self.accounts.get(venue) or Account(venue=venue)
424
+ await self.journal.record_intent(request, client_order_id=key, venue=venue, account=account,
425
+ operation="cancel", target_order_id=order_id)
426
+ async with self._lock(key):
427
+ await self.journal.mark_intent(key, IntentState.SENDING, bump_attempt=True)
428
+ try:
429
+ result = await adapter.cancel_order(order_id, market_id=market_id or order.market_id)
430
+ except OrderNotFound:
431
+ # Already gone: the cancel achieved what it asked for.
432
+ await self.journal.mark_intent(key, IntentState.SETTLED, detail="already gone")
433
+ closed = order.model_copy(update={"status": OrderStatus.CANCELED})
434
+ await self.journal.upsert_order(closed, event="order.canceled")
435
+ self._remember(closed)
436
+ return closed
437
+ except (NetworkError, asyncio.TimeoutError):
438
+ raise
439
+ result = self._stamp(result, None, template=order)
440
+ await self.journal.upsert_order(result, event="order.canceled")
441
+ self._remember(result)
442
+ await self.journal.mark_intent(key, IntentState.SETTLED, order_id=order_id)
443
+ await self.orders.on_order(result)
444
+ await self.bus.publish("order.canceled", result.model_dump(mode="json"), key=f"{venue}:{order_id}", persist=False)
445
+ return result
446
+
447
+ async def edit(self, request: EditRequest, *, venue: str | None = None) -> Order:
448
+ """Amend a resting order. The edit carries its own idempotency key, so
449
+ a retry is the same edit rather than a second one."""
450
+ order = await self._known(request.order_id, venue)
451
+ venue = venue or order.venue
452
+ adapter = self._adapter(venue)
453
+ key = request.client_order_id or f"edit:{venue}:{request.order_id}:{uuid.uuid4().hex[:8]}"
454
+ request = request.model_copy(update={"client_order_id": key})
455
+ intent_request = OrderRequest(
456
+ market_id=order.market_id, side=order.side, amount=request.amount or order.amount,
457
+ price=request.price if request.price is not None else order.price, book=order.book, trader=order.trader,
458
+ client_order_id=key,
459
+ )
460
+ account = order.account or self.accounts.get(venue) or Account(venue=venue)
461
+ decision = self.check(intent_request, venue=venue, account=account)
462
+ if not decision.ok:
463
+ await self.bus.publish("risk.rejected", {
464
+ "rule": decision.rule, "message": decision.message, "order_id": order.id, "operation": "edit",
465
+ }, key=key)
466
+ raise RiskRejected(decision.message, rule=decision.rule)
467
+ await self.journal.record_intent(intent_request, client_order_id=key, venue=venue, account=account,
468
+ operation="edit", target_order_id=order.id)
469
+ async with self._lock(key):
470
+ await self.journal.mark_intent(key, IntentState.SENDING, bump_attempt=True)
471
+ result = await adapter.edit_order(request, current=order)
472
+ result = self._stamp(result, None, template=order)
473
+ await self.journal.upsert_order(result, event="order.edited")
474
+ self._remember(result)
475
+ await self.journal.mark_intent(key, IntentState.SETTLED, order_id=result.id)
476
+ await self.orders.on_order(result)
477
+ await self.bus.publish("order.edited", result.model_dump(mode="json"), key=f"{venue}:{result.id}", persist=False)
478
+ return result
479
+
480
+ # -- halting --------------------------------------------------------------
481
+
482
+ async def halt(self, reason: str, *, scope: str = "*", policy: HaltPolicy | None = None, rearm_after_s: float | None = None) -> dict[str, Any]:
483
+ """Stop trading. Returns what the policy did, per venue."""
484
+ policy = policy or self.config.halt_policy
485
+ self.risk.kill.engage(reason=reason, scope=scope, policy=policy, rearm_after_s=rearm_after_s)
486
+ result: dict[str, Any] = {"policy": policy, "scope": scope, "reason": reason, "canceled": {}, "remaining": {}}
487
+ if policy == "cancel":
488
+ for venue, adapter in self.adapters.items():
489
+ if scope not in ("*", venue):
490
+ continue
491
+ try:
492
+ count = await adapter.cancel_all_orders()
493
+ result["canceled"][venue] = count
494
+ except Exception as exc: # a venue that cannot be reached must not stop the others
495
+ result["canceled"][venue] = f"failed: {type(exc).__name__}: {exc}"
496
+ continue
497
+ left = await self._finish_cancelling(venue, adapter)
498
+ if left:
499
+ result["remaining"][venue] = left
500
+ for order in self.open_orders():
501
+ if scope in ("*", order.venue):
502
+ canceled = order.model_copy(update={"status": OrderStatus.CANCELED})
503
+ await self.journal.upsert_order(canceled, event="order.canceled")
504
+ self._remember(canceled)
505
+ result["managed"] = await self.orders.on_halt(reason, scope=scope)
506
+ await self.bus.publish("engine.halted", result)
507
+ return result
508
+
509
+ async def _finish_cancelling(self, venue: str, adapter: TradingExchange, *, rounds: int = 4, pause_s: float = 0.5) -> int:
510
+ """Cancel-all is not always instant, and on some venues not always
511
+ complete. Read the book back and pull whatever is still resting, one
512
+ order at a time; return how many refused to go."""
513
+ for attempt in range(rounds):
514
+ try:
515
+ left = await adapter.fetch_open_orders()
516
+ except Exception:
517
+ return -1
518
+ if not left:
519
+ return 0
520
+ if attempt:
521
+ for order in left:
522
+ try:
523
+ await adapter.cancel_order(order.id, market_id=order.market_id)
524
+ except Exception:
525
+ pass
526
+ await asyncio.sleep(pause_s)
527
+ try:
528
+ return len(await adapter.fetch_open_orders())
529
+ except Exception:
530
+ return -1
531
+
532
+ async def resume(self, *, scope: str | None = None) -> None:
533
+ self.risk.kill.release(scope=scope)
534
+ await self.bus.publish("engine.resumed", {"scope": scope or "*"})
535
+
536
+ async def pause_book(self, book: str, *, paused: bool = True) -> None:
537
+ """Stop one strategy without stopping the engine."""
538
+ books = set(self.risk.config.paused_books)
539
+ books.add(book) if paused else books.discard(book)
540
+ self.risk.config = self.risk.config.model_copy(update={"paused_books": sorted(books)})
541
+ self.risk.config_version = await self.journal.save_config("risk", self.risk.config.model_dump(mode="json"))
542
+ await self.bus.publish("engine.book_paused" if paused else "engine.book_resumed", {"book": book})
543
+
544
+ async def set_risk(self, config: RiskConfig, *, author: str | None = None) -> int:
545
+ """Replace the rules; the new version is journaled and returned."""
546
+ version = await self.journal.save_config("risk", config.model_dump(mode="json"), author=author)
547
+ self.risk.configure(config, version=version)
548
+ await self.bus.publish("risk.configured", {"version": version, "author": author})
549
+ return version
550
+
551
+ # -- recovery and the sweep ----------------------------------------------
552
+
553
+ async def recover(self) -> Recovery:
554
+ """Rebuild the ledger and resolve everything that was in doubt."""
555
+ recovery = Recovery()
556
+ for fill in await self.journal.fills():
557
+ self.ledger.apply_fill(fill)
558
+ recovery.fills_replayed += 1
559
+ self.risk.roll_day()
560
+ for intent in await self.journal.in_doubt():
561
+ recovery.in_doubt += 1
562
+ outcome = await self._resolve(intent)
563
+ recovery.details.append(outcome)
564
+ if outcome["result"] == "adopted":
565
+ recovery.adopted += 1
566
+ elif outcome["result"] == "swept":
567
+ recovery.swept += 1
568
+ else:
569
+ # Pending or unreachable: still in doubt, and reported as such.
570
+ recovery.unresolved += 1
571
+ recovery.managed = await self.orders.restore()
572
+ # After the in-doubt intents, so an order adopted a moment ago is in
573
+ # the cache the risk rules count against.
574
+ recovery.orders_open = len(await self.refresh_open_orders())
575
+ return recovery
576
+
577
+ async def sweep(self) -> list[dict[str, Any]]:
578
+ """Resolve intents that have been in doubt longer than the timeout."""
579
+ out = []
580
+ for intent in await self.journal.in_doubt(older_than_ms=int(self.config.in_doubt_timeout_s * 1000)):
581
+ out.append(await self._resolve(intent))
582
+ return out
583
+
584
+ async def _resolve(self, intent: Any) -> dict[str, Any]:
585
+ """Ask the venue whether this intent's order exists."""
586
+ adapter = self.adapters.get(intent.venue)
587
+ base = {"client_order_id": intent.client_order_id, "venue": intent.venue, "operation": intent.operation}
588
+ if adapter is None:
589
+ await self.bus.publish("intent.unresolved", base | {"reason": "no adapter for this venue"})
590
+ return base | {"result": "unresolved", "reason": "no adapter"}
591
+ try:
592
+ found = await self._find_by_client_id(adapter, intent)
593
+ except Exception as exc:
594
+ await self.bus.publish("intent.unresolved", base | {"reason": f"{type(exc).__name__}: {exc}"})
595
+ return base | {"result": "unresolved", "reason": str(exc)}
596
+ if found is not None:
597
+ await self.journal.upsert_order(found, event="order.adopted")
598
+ self._remember(found)
599
+ await self.journal.mark_intent(intent.client_order_id, IntentState.SENT, order_id=found.id,
600
+ detail="adopted after recovery")
601
+ await self.bus.publish("intent.adopted", base | {"order_id": found.id, "status": found.status.value})
602
+ return base | {"result": "adopted", "order_id": found.id}
603
+ if intent.age_ms < self.config.lost_after_s * 1000:
604
+ # The venue says no such order, but it may simply not list it yet.
605
+ await self.bus.publish("intent.pending", base | {"age_ms": intent.age_ms})
606
+ return base | {"result": "pending", "age_ms": intent.age_ms}
607
+ await self.journal.mark_intent(intent.client_order_id, IntentState.LOST, detail="not found at the venue")
608
+ await self.bus.publish("intent.swept", base | {"reason": "no order with this client order id"})
609
+ return base | {"result": "swept"}
610
+
611
+ async def _find_by_client_id(self, adapter: TradingExchange, intent: Any) -> Order | None:
612
+ """Look for an order carrying this intent's client order id."""
613
+ if intent.operation != "create":
614
+ # A cancel or edit in doubt is resolved by reading the order itself.
615
+ if intent.target_order_id:
616
+ try:
617
+ return await adapter.fetch_order(intent.target_order_id)
618
+ except OrderNotFound:
619
+ return None
620
+ return None
621
+ for order in await adapter.fetch_open_orders():
622
+ if order.client_order_id == intent.client_order_id:
623
+ return order
624
+ if adapter.has.get("fetch_orders"):
625
+ since = intent.created_ts - 60_000
626
+ page = await adapter.fetch_orders(since=since, limit=200)
627
+ for order in page:
628
+ if order.client_order_id == intent.client_order_id:
629
+ return order
630
+ return None
631
+
632
+ # -- state ----------------------------------------------------------------
633
+
634
+ def open_orders(self, *, venue: str | None = None, book: str | None = None) -> list[Order]:
635
+ """Open orders as the engine believes them, from memory of the journal."""
636
+ return [o for o in self._open_cache.values()
637
+ if (venue is None or o.venue == venue) and (book is None or o.book == book)]
638
+
639
+ async def refresh_open_orders(self) -> list[Order]:
640
+ orders = await self.journal.open_orders()
641
+ self._open_cache = {f"{o.venue}:{o.id}": o for o in orders}
642
+ return orders
643
+
644
+ async def on_order(self, order: Order) -> None:
645
+ """Record an order update, from a stream or a poll."""
646
+ stored = await self.journal.order(order.venue, order.id)
647
+ if stored is not None:
648
+ order = self._stamp(order, None, template=stored)
649
+ await self.journal.upsert_order(order)
650
+ self._remember(order)
651
+ await self.bus.publish(f"order.{order.status.value}", order.model_dump(mode="json"),
652
+ key=f"{order.venue}:{order.id}", persist=False)
653
+ await self.orders.on_order(order)
654
+
655
+ async def on_fill(self, fill: Fill) -> None:
656
+ """Record a fill once, book it in the ledger, publish it."""
657
+ order = await self.journal.order(fill.venue, fill.order_id)
658
+ book = order.book if order else None
659
+ trader = order.trader if order else None
660
+ changed = await self.journal.record_fill(fill, book=book, trader=trader)
661
+ if not changed:
662
+ return
663
+ realized = self.ledger.apply_fill(fill, book=book)
664
+ await self.bus.publish("fill.booked", fill.model_dump(mode="json") | {"book": book, "realized": str(realized)},
665
+ key=f"{fill.venue}:{fill.id}", persist=False)
666
+ # Engine-held parents do not hear about fill events: they follow the
667
+ # venue's order record, which `on_order` carries.
668
+
669
+ async def on_book(self, event: Any) -> None:
670
+ """A book changed, from `synpath.ws` or anywhere else. Engine-held
671
+ orders that watch this instrument hear about it."""
672
+ market_id = getattr(event, "market_id", None) or event["market_id"]
673
+ book = getattr(event, "book", None)
674
+ if book is None:
675
+ book = self.books.get(market_id)
676
+ if book is not None:
677
+ self.books[market_id] = book
678
+ await self.orders.on_book(market_id)
679
+
680
+ def set_book(self, market_id: str, book: Any) -> None:
681
+ """Hand the engine a `LocalBook` (or anything with `best_bid`,
682
+ `best_ask` and `levels`) to watch."""
683
+ self.books[market_id] = book
684
+
685
+ async def on_trade(self, event: Any) -> None:
686
+ """A public print."""
687
+ market_id = getattr(event, "market_id", None) or event["market_id"]
688
+ price = getattr(event, "price", None) or event["price"]
689
+ amount = getattr(event, "amount", None) or event.get("amount", ZERO)
690
+ self.last_trade[market_id] = Decimal(str(price))
691
+ await self.orders.on_trade(market_id, Decimal(str(price)), Decimal(str(amount)))
692
+
693
+ async def on_settlement(self, settlement: Settlement) -> None:
694
+ realized = self.ledger.apply_settlement(settlement)
695
+ await self.bus.publish("settlement.booked", settlement.model_dump(mode="json") | {"realized": str(realized)},
696
+ key=f"{settlement.venue}:{settlement.market_id}")
697
+
698
+ def positions(self) -> list[Position]:
699
+ marks = self.fair_values.marks()
700
+ out = []
701
+ for state in self.ledger.positions.values():
702
+ mark = marks.get((state.account_key, state.market_id))
703
+ out.append(state.to_position(self.accounts.get(state.venue), mark=mark))
704
+ return out
705
+
706
+ async def bucket_orders(self, bucket_id: str) -> list[Any]:
707
+ """Every order placed on a bucket, live or finished, oldest first. A
708
+ live parent is the running one; a finished one this process no longer
709
+ holds is rebuilt from its journal snapshot, read-only."""
710
+ out = []
711
+ for kind, snapshot in await self.journal.managed_on(f"bucket:{bucket_id}"):
712
+ parent = self.orders.get(snapshot["id"]) or self._rebuilt(kind, snapshot)
713
+ if parent is not None:
714
+ out.append(parent)
715
+ return out
716
+
717
+ async def bucket_order(self, parent_id: str) -> Any | None:
718
+ """One order on a bucket, by its id, or None if the id is not one."""
719
+ parent = self.orders.get(parent_id)
720
+ if parent is None:
721
+ found = await self.journal.managed_one(parent_id)
722
+ parent = self._rebuilt(*found) if found else None
723
+ return parent if parent is not None and is_bucket_id(parent.market_id) else None
724
+
725
+ @staticmethod
726
+ def _rebuilt(kind: str, snapshot: Mapping[str, Any]) -> Any | None:
727
+ cls = REGISTRY.get(kind)
728
+ return cls.from_snapshot(snapshot) if cls is not None else None
729
+
730
+ async def bucket_position(self, bucket_id: str, *, book: str | None = None) -> dict[str, Any]:
731
+ """The ledger's positions in a bucket's members, netted in bucket
732
+ terms: a flipped member's long YES is a short bucket. Per member
733
+ underneath, so the net can be traced."""
734
+ row = await self.journal.bucket(bucket_id)
735
+ if row is None:
736
+ raise BadRequest(f"bucket:{bucket_id}: no such bucket in this journal")
737
+ bucket = Bucket.model_validate(row)
738
+ marks = self.fair_values.marks()
739
+ members: list[dict[str, Any]] = []
740
+ net = ZERO
741
+ cost = ZERO
742
+ realized = ZERO
743
+ fees = ZERO
744
+ for member in bucket.members:
745
+ for (account_key, name, market_id), state in self.ledger.positions.items():
746
+ if market_id != member.market_id or (book is not None and name != book):
747
+ continue
748
+ signed = -state.contracts if member.flip else state.contracts
749
+ entry = state.average_price_in_bucket(member.flip) if state.contracts else None
750
+ net += signed
751
+ if entry is not None:
752
+ cost += signed * entry
753
+ realized += state.realized
754
+ fees += state.fees
755
+ mark = marks.get((account_key, market_id))
756
+ members.append({
757
+ "market_id": member.market_id, "venue": state.venue, "book": name, "account": account_key,
758
+ "flip": member.flip, "contracts": str(state.contracts), "bucket_contracts": str(signed),
759
+ "entry_price": str(entry) if entry is not None else None,
760
+ "mark": str(mark) if mark is not None else None,
761
+ })
762
+ return {
763
+ "bucket_id": bucket.id, "name": bucket.name, "book": book or bucket.book,
764
+ "contracts": str(net), "side": "long" if net > 0 else "short" if net < 0 else "flat",
765
+ "entry_price": str(cost / net) if net else None,
766
+ "realized": str(realized), "fees": str(fees), "members": members,
767
+ }
768
+
769
+ def pnl(self, level: str = "book") -> dict[str, Any]:
770
+ marks = self.fair_values.marks()
771
+ rows = self.ledger.rollup(level, marks) # type: ignore[arg-type]
772
+ total = self.ledger.total(marks)
773
+ return {"level": level, "rows": rows, "total": total}
774
+
775
+ # -- background loops -----------------------------------------------------
776
+
777
+ async def _lease_loop(self) -> None:
778
+ while self.running:
779
+ await asyncio.sleep(self.config.lease_renew_s)
780
+ if not self.config.require_lease:
781
+ continue
782
+ if not await self.journal.renew_lease():
783
+ await self.bus.publish("engine.lease_lost", {"owner": self.journal.owner})
784
+ await self.halt("the journal lease was taken by another engine", policy="hold")
785
+ raise LeaseLost("another engine took the journal lease; this one stopped trading")
786
+
787
+ async def _managed_loop(self) -> None:
788
+ """The clock engine-held orders run on."""
789
+ while self.running:
790
+ await asyncio.sleep(self.config.managed_tick_s)
791
+ try:
792
+ await self.orders.on_timer()
793
+ except Exception:
794
+ log.exception("synpath.engine: the managed-order timer failed")
795
+
796
+ async def _sweep_loop(self) -> None:
797
+ while self.running:
798
+ await asyncio.sleep(self.config.sweep_interval_s)
799
+ try:
800
+ await self.sweep()
801
+ except Exception:
802
+ log.exception("synpath.engine: sweep failed")
803
+
804
+ async def _poll_loop(self) -> None:
805
+ """Poll orders and fills where no stream feeds the engine."""
806
+ while self.running:
807
+ await asyncio.sleep(self.config.poll_interval_s)
808
+ for venue, adapter in self.adapters.items():
809
+ try:
810
+ await self.poll(venue, adapter)
811
+ except Exception:
812
+ log.exception("synpath.engine: polling %s failed", venue)
813
+
814
+ async def poll(self, venue: str, adapter: TradingExchange | None = None) -> None:
815
+ adapter = adapter or self._adapter(venue)
816
+ for order in await adapter.fetch_open_orders():
817
+ await self.on_order(order)
818
+ if adapter.has.get("fetch_my_trades"):
819
+ since = int(await self.journal.cursor(f"fills:{venue}", 0) or 0)
820
+ fills = await adapter.fetch_my_trades(since=since or None, limit=200)
821
+ newest = since
822
+ for fill in fills:
823
+ await self.on_fill(fill)
824
+ newest = max(newest, fill.timestamp or 0)
825
+ if newest > since:
826
+ await self.journal.set_cursor(f"fills:{venue}", newest)
827
+
828
+ # -- helpers --------------------------------------------------------------
829
+
830
+ def _adapter(self, venue: str) -> TradingExchange:
831
+ adapter = self.adapters.get(venue)
832
+ if adapter is None:
833
+ raise KeyError(f"no adapter configured for {venue!r}; known venues: {sorted(self.adapters)}")
834
+ return adapter
835
+
836
+ def _venue_of(self, request: OrderRequest) -> str:
837
+ """The account's venue if one is named, else the venue the market id
838
+ names (`kalshi:...` routes to Kalshi), else the only venue there is."""
839
+ if request.account is not None:
840
+ return request.account.venue
841
+ venue, _ = ids.split(request.market_id)
842
+ if venue is not None and venue in self.adapters:
843
+ return venue
844
+ if len(self.adapters) == 1:
845
+ return next(iter(self.adapters))
846
+ raise ValueError("more than one venue is configured and the market id names none of them: "
847
+ "use a Synpath id (venue:native), pass venue=, or name an account")
848
+
849
+ async def _known(self, order_id: str, venue: str | None) -> Order:
850
+ if venue is not None:
851
+ order = await self.journal.order(venue, order_id)
852
+ if order is not None:
853
+ return order
854
+ for name in ([venue] if venue else list(self.adapters)):
855
+ order = await self.journal.order(name, order_id)
856
+ if order is not None:
857
+ return order
858
+ raise OrderNotFound(f"the engine has no order {order_id!r} in its journal")
859
+
860
+ def _stamp(self, order: Order, request: OrderRequest | None, *, template: Order | None = None) -> Order:
861
+ """Carry the engine's own fields onto a venue's answer."""
862
+ source = request or template
863
+ updates: dict[str, Any] = {}
864
+ if source is not None:
865
+ if order.book is None and source.book:
866
+ updates["book"] = source.book
867
+ if order.trader is None and source.trader:
868
+ updates["trader"] = source.trader
869
+ if not order.tags and getattr(source, "tags", None):
870
+ updates["tags"] = source.tags
871
+ if order.client_order_id is None and source.client_order_id:
872
+ updates["client_order_id"] = source.client_order_id
873
+ return order.model_copy(update=updates) if updates else order
874
+
875
+ def _remember(self, order: Order) -> None:
876
+ """Keep the open-order view in step with what was just written, so the
877
+ rules that count resting orders count this one too."""
878
+ key = f"{order.venue}:{order.id}"
879
+ if order.is_terminal:
880
+ self._open_cache.pop(key, None)
881
+ else:
882
+ self._open_cache[key] = order
883
+
884
+ def _lock(self, key: str) -> asyncio.Lock:
885
+ lock = self._locks.get(key)
886
+ if lock is None:
887
+ lock = self._locks[key] = asyncio.Lock()
888
+ return lock