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,907 @@
1
+ """The journal: what the engine knew, in the order it knew it.
2
+
3
+ Every decision the engine makes is written here before it leaves the
4
+ process, and every answer a venue gives is written here when it arrives. A
5
+ crashed engine restarts by reading this file, so the rules are strict:
6
+
7
+ **Append first, act second.** An order's intent, with its client order id,
8
+ is committed to disk before any network call. A process killed between the
9
+ write and the venue's answer restarts holding an intent marked `sending`
10
+ and a client order id it can ask the venue about, which is the only way to
11
+ tell "never sent" from "sent, answer lost" without risking a double order.
12
+
13
+ **One writer.** Two engines on one journal would both submit. A lease row
14
+ with an owner and an expiry makes the second one refuse to trade; it is
15
+ taken before the first write and renewed while the engine runs.
16
+
17
+ **The event log is the truth; the tables are a convenience.** `events` is
18
+ append-only and ordered by `seq`. `intents`, `orders`, `fills` and
19
+ `positions` are materialized in the same transaction as the event that
20
+ changes them, so a reader never sees a row that no event explains, and
21
+ `replay(since)` can rebuild any of it.
22
+
23
+ SQLite in WAL mode with `synchronous=FULL`, through `aiosqlite`: one engine
24
+ on one machine needs no server, and FULL is what makes "written before the
25
+ call" true across a power cut rather than only across a crash.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import json
31
+ import os
32
+ import socket
33
+ import time
34
+ import uuid
35
+ from dataclasses import dataclass, field
36
+ from decimal import Decimal
37
+ from typing import Any, AsyncIterator, Iterable
38
+
39
+ from ..trading.types import Account, Fill, Order, OrderRequest
40
+
41
+ SCHEMA_VERSION = 3
42
+
43
+ SCHEMA = """
44
+ CREATE TABLE IF NOT EXISTS meta (
45
+ key TEXT PRIMARY KEY,
46
+ value TEXT NOT NULL
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS events (
50
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ ts INTEGER NOT NULL,
52
+ kind TEXT NOT NULL,
53
+ key TEXT,
54
+ payload TEXT NOT NULL
55
+ );
56
+ CREATE INDEX IF NOT EXISTS events_kind ON events(kind, seq);
57
+ CREATE INDEX IF NOT EXISTS events_key ON events(key, seq);
58
+
59
+ CREATE TABLE IF NOT EXISTS intents (
60
+ client_order_id TEXT PRIMARY KEY,
61
+ state TEXT NOT NULL,
62
+ operation TEXT NOT NULL,
63
+ venue TEXT NOT NULL,
64
+ account_key TEXT NOT NULL,
65
+ market_id TEXT,
66
+ order_id TEXT,
67
+ target_order_id TEXT,
68
+ book TEXT,
69
+ trader TEXT,
70
+ attempts INTEGER NOT NULL DEFAULT 0,
71
+ request TEXT NOT NULL,
72
+ detail TEXT,
73
+ created_ts INTEGER NOT NULL,
74
+ updated_ts INTEGER NOT NULL
75
+ );
76
+ CREATE INDEX IF NOT EXISTS intents_state ON intents(state, updated_ts);
77
+ CREATE INDEX IF NOT EXISTS intents_order ON intents(venue, order_id);
78
+
79
+ CREATE TABLE IF NOT EXISTS orders (
80
+ venue TEXT NOT NULL,
81
+ id TEXT NOT NULL,
82
+ client_order_id TEXT,
83
+ account_key TEXT NOT NULL,
84
+ market_id TEXT NOT NULL,
85
+ side TEXT NOT NULL,
86
+ status TEXT NOT NULL,
87
+ terminal INTEGER NOT NULL DEFAULT 0,
88
+ book TEXT,
89
+ trader TEXT,
90
+ amount TEXT NOT NULL,
91
+ filled TEXT NOT NULL,
92
+ price TEXT,
93
+ parent_id TEXT,
94
+ payload TEXT NOT NULL,
95
+ created_ts INTEGER,
96
+ updated_ts INTEGER NOT NULL,
97
+ PRIMARY KEY (venue, id)
98
+ );
99
+ CREATE INDEX IF NOT EXISTS orders_open ON orders(terminal, venue, account_key);
100
+ CREATE INDEX IF NOT EXISTS orders_client ON orders(client_order_id);
101
+
102
+ CREATE TABLE IF NOT EXISTS fills (
103
+ venue TEXT NOT NULL,
104
+ id TEXT NOT NULL,
105
+ order_id TEXT NOT NULL,
106
+ client_order_id TEXT,
107
+ account_key TEXT NOT NULL,
108
+ market_id TEXT NOT NULL,
109
+ side TEXT NOT NULL,
110
+ price TEXT NOT NULL,
111
+ amount TEXT NOT NULL,
112
+ fee TEXT,
113
+ settlement TEXT NOT NULL,
114
+ book TEXT,
115
+ trader TEXT,
116
+ ts INTEGER NOT NULL,
117
+ payload TEXT NOT NULL,
118
+ PRIMARY KEY (venue, id)
119
+ );
120
+ CREATE INDEX IF NOT EXISTS fills_order ON fills(venue, order_id);
121
+ CREATE INDEX IF NOT EXISTS fills_ts ON fills(ts);
122
+
123
+ CREATE TABLE IF NOT EXISTS managed_orders (
124
+ id TEXT PRIMARY KEY,
125
+ kind TEXT NOT NULL,
126
+ state TEXT NOT NULL,
127
+ account_key TEXT,
128
+ market_id TEXT NOT NULL,
129
+ book TEXT,
130
+ trader TEXT,
131
+ payload TEXT NOT NULL,
132
+ child_order_id TEXT,
133
+ created_ts INTEGER NOT NULL,
134
+ updated_ts INTEGER NOT NULL
135
+ );
136
+ CREATE INDEX IF NOT EXISTS managed_state ON managed_orders(state);
137
+
138
+ CREATE TABLE IF NOT EXISTS leases (
139
+ name TEXT PRIMARY KEY,
140
+ owner TEXT NOT NULL,
141
+ holder TEXT NOT NULL,
142
+ acquired_ts INTEGER NOT NULL,
143
+ expires_ts INTEGER NOT NULL
144
+ );
145
+
146
+ CREATE TABLE IF NOT EXISTS config_versions (
147
+ version INTEGER PRIMARY KEY AUTOINCREMENT,
148
+ kind TEXT NOT NULL,
149
+ ts INTEGER NOT NULL,
150
+ author TEXT,
151
+ payload TEXT NOT NULL
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS fair_values (
155
+ account_key TEXT NOT NULL,
156
+ market_id TEXT NOT NULL,
157
+ value TEXT NOT NULL,
158
+ source TEXT,
159
+ ts INTEGER NOT NULL,
160
+ PRIMARY KEY (account_key, market_id)
161
+ );
162
+
163
+ CREATE TABLE IF NOT EXISTS buckets (
164
+ id TEXT PRIMARY KEY,
165
+ book TEXT NOT NULL,
166
+ name TEXT NOT NULL,
167
+ status TEXT NOT NULL DEFAULT 'active',
168
+ created_ts INTEGER NOT NULL,
169
+ updated_ts INTEGER NOT NULL
170
+ );
171
+
172
+ CREATE TABLE IF NOT EXISTS bucket_members (
173
+ bucket_id TEXT NOT NULL REFERENCES buckets(id) ON DELETE CASCADE,
174
+ market_id TEXT NOT NULL,
175
+ flip INTEGER NOT NULL DEFAULT 0,
176
+ position INTEGER NOT NULL,
177
+ PRIMARY KEY (bucket_id, market_id)
178
+ );
179
+
180
+ CREATE TABLE IF NOT EXISTS cursors (
181
+ name TEXT PRIMARY KEY,
182
+ value TEXT NOT NULL,
183
+ updated_ts INTEGER NOT NULL
184
+ );
185
+ """
186
+
187
+
188
+ def now_ms() -> int:
189
+ return int(time.time() * 1000)
190
+
191
+
192
+ def _dump(value: Any) -> str:
193
+ return json.dumps(value, default=str, separators=(",", ":"))
194
+
195
+
196
+ def _text(value: Decimal | None) -> str | None:
197
+ return None if value is None else str(value)
198
+
199
+
200
+ def _word(value: Any) -> str:
201
+ """An enum's value, or the string a `model_copy` left in its place."""
202
+ return str(getattr(value, "value", value))
203
+
204
+
205
+ class LeaseLost(RuntimeError):
206
+ """Another engine holds the journal, or this one's lease expired."""
207
+
208
+
209
+ class _Held(Exception):
210
+ """Internal: the lease row belongs to someone else. Becomes `LeaseLost`."""
211
+
212
+
213
+ class IntentState:
214
+ PLANNED = "planned"
215
+ """Written, risk passed, not yet sent."""
216
+ SENDING = "sending"
217
+ """Handed to the venue; the answer has not come back."""
218
+ SENT = "sent"
219
+ SETTLED = "settled"
220
+ """The order reached a terminal status."""
221
+ REJECTED = "rejected"
222
+ FAILED = "failed"
223
+ """The venue refused it, or the call failed in a way that proves nothing left."""
224
+ LOST = "lost"
225
+ """In doubt for longer than the sweep allows and not found at the venue."""
226
+
227
+
228
+ IN_DOUBT = (IntentState.SENDING,)
229
+
230
+
231
+ @dataclass(frozen=True, slots=True)
232
+ class JournalEvent:
233
+ seq: int
234
+ ts: int
235
+ kind: str
236
+ key: str | None
237
+ payload: dict[str, Any]
238
+
239
+
240
+ @dataclass(slots=True)
241
+ class Intent:
242
+ client_order_id: str
243
+ state: str
244
+ operation: str
245
+ venue: str
246
+ account_key: str
247
+ market_id: str | None
248
+ order_id: str | None
249
+ target_order_id: str | None
250
+ book: str | None
251
+ trader: str | None
252
+ attempts: int
253
+ request: dict[str, Any]
254
+ detail: str | None
255
+ created_ts: int
256
+ updated_ts: int
257
+
258
+ @property
259
+ def age_ms(self) -> int:
260
+ return now_ms() - self.updated_ts
261
+
262
+
263
+ @dataclass(slots=True)
264
+ class Lease:
265
+ name: str
266
+ owner: str
267
+ holder: str
268
+ acquired_ts: int
269
+ expires_ts: int
270
+ ttl_ms: int = 30_000
271
+ renew_task: Any = field(default=None, repr=False)
272
+
273
+
274
+ class Journal:
275
+ """The engine's write-ahead log and its materialized state."""
276
+
277
+ def __init__(self, path: str | os.PathLike[str] = "synpath.db", *, owner: str | None = None, busy_timeout_ms: int = 10_000):
278
+ self.path = str(path)
279
+ self.owner = owner or f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
280
+ self.busy_timeout_ms = busy_timeout_ms
281
+ self._db: Any = None
282
+ self._write = asyncio.Lock()
283
+ self.lease: Lease | None = None
284
+
285
+ # -- lifecycle ------------------------------------------------------------
286
+
287
+ async def open(self) -> "Journal":
288
+ try:
289
+ import aiosqlite
290
+ except ImportError as exc: # pragma: no cover - depends on the environment
291
+ raise ImportError("the engine's journal needs aiosqlite: pip install synpath") from exc
292
+ connection = aiosqlite.connect(self.path, isolation_level=None)
293
+ # aiosqlite runs each connection on its own thread. Marking it a
294
+ # daemon means a journal somebody forgot to close cannot keep the
295
+ # process alive after everything else has finished.
296
+ connection.daemon = True
297
+ self._db = await connection
298
+ self._db.row_factory = aiosqlite.Row
299
+ await self._db.execute(f"PRAGMA busy_timeout={self.busy_timeout_ms}")
300
+ await self._db.execute("PRAGMA journal_mode=WAL")
301
+ # An order must be on disk before it is on the wire, so the write that
302
+ # records it cannot be one the operating system is still holding.
303
+ await self._db.execute("PRAGMA synchronous=FULL")
304
+ await self._db.execute("PRAGMA foreign_keys=ON")
305
+ await self._db.executescript(SCHEMA)
306
+ await self._upgrade()
307
+ await self._db.execute("CREATE INDEX IF NOT EXISTS orders_parent ON orders(parent_id)")
308
+ await self._db.execute(
309
+ "INSERT INTO meta(key, value) VALUES('schema_version', ?) "
310
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
311
+ (str(SCHEMA_VERSION),),
312
+ )
313
+ return self
314
+
315
+ async def schema_version(self) -> int:
316
+ async with self._db.execute("SELECT value FROM meta WHERE key='schema_version'") as cursor:
317
+ row = await cursor.fetchone()
318
+ return int(row["value"]) if row else 0
319
+
320
+ async def _columns(self, table: str) -> dict[str, dict[str, Any]]:
321
+ async with self._db.execute(f"PRAGMA table_info({table})") as cursor:
322
+ rows = await cursor.fetchall()
323
+ return {row["name"]: {"notnull": bool(row["notnull"]), "type": row["type"]} for row in rows}
324
+
325
+ async def _upgrade(self) -> None:
326
+ """Bring a file written by an older version up to this one. `SCHEMA` is
327
+ all `CREATE TABLE IF NOT EXISTS`, so it adds tables but never changes
328
+ one; anything else lives here, keyed on what the file actually has
329
+ rather than on the number it claims, so a half-applied upgrade is
330
+ finished rather than skipped.
331
+
332
+ 2 -> 3: `orders.parent_id` (a child names its parent in a column, not
333
+ only inside its payload) and `managed_orders.account_key` nullable (a
334
+ parent across venues has no single account)."""
335
+ orders = await self._columns("orders")
336
+ if "parent_id" not in orders:
337
+ await self._db.execute("ALTER TABLE orders ADD COLUMN parent_id TEXT")
338
+ managed = await self._columns("managed_orders")
339
+ if managed.get("account_key", {}).get("notnull"):
340
+ # SQLite cannot drop NOT NULL in place: rebuild, copy, swap.
341
+ await self._db.execute("BEGIN IMMEDIATE")
342
+ try:
343
+ await self._db.execute("""
344
+ CREATE TABLE managed_orders_v3 (
345
+ id TEXT PRIMARY KEY,
346
+ kind TEXT NOT NULL,
347
+ state TEXT NOT NULL,
348
+ account_key TEXT,
349
+ market_id TEXT NOT NULL,
350
+ book TEXT,
351
+ trader TEXT,
352
+ payload TEXT NOT NULL,
353
+ child_order_id TEXT,
354
+ created_ts INTEGER NOT NULL,
355
+ updated_ts INTEGER NOT NULL
356
+ )""")
357
+ await self._db.execute("""
358
+ INSERT INTO managed_orders_v3
359
+ (id, kind, state, account_key, market_id, book, trader, payload, child_order_id, created_ts, updated_ts)
360
+ SELECT id, kind, state, account_key, market_id, book, trader, payload, child_order_id, created_ts, updated_ts
361
+ FROM managed_orders""")
362
+ await self._db.execute("DROP TABLE managed_orders")
363
+ await self._db.execute("ALTER TABLE managed_orders_v3 RENAME TO managed_orders")
364
+ await self._db.execute("COMMIT")
365
+ except Exception:
366
+ await self._db.execute("ROLLBACK")
367
+ raise
368
+
369
+ async def close(self) -> None:
370
+ if self.lease is not None and self._db is not None:
371
+ await self.release_lease()
372
+ self.lease = None
373
+ if self._db is not None:
374
+ await self._db.close()
375
+ self._db = None
376
+
377
+ async def __aenter__(self) -> "Journal":
378
+ return await self.open()
379
+
380
+ async def __aexit__(self, *exc: Any) -> None:
381
+ await self.close()
382
+
383
+ # -- the log --------------------------------------------------------------
384
+
385
+ async def append(self, kind: str, payload: dict[str, Any] | None = None, *, key: str | None = None) -> int:
386
+ """One event. Returns its sequence number."""
387
+ async with self._write:
388
+ cursor = await self._db.execute(
389
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
390
+ (now_ms(), kind, key, _dump(payload or {})),
391
+ )
392
+ return int(cursor.lastrowid)
393
+
394
+ async def replay(self, since: int = 0, *, kinds: Iterable[str] | None = None, limit: int | None = None) -> AsyncIterator[JournalEvent]:
395
+ """Every event after `since`, oldest first."""
396
+ sql = "SELECT seq, ts, kind, key, payload FROM events WHERE seq > ?"
397
+ args: list[Any] = [since]
398
+ if kinds:
399
+ names = list(kinds)
400
+ sql += f" AND kind IN ({','.join('?' * len(names))})"
401
+ args += names
402
+ sql += " ORDER BY seq"
403
+ if limit:
404
+ sql += f" LIMIT {int(limit)}"
405
+ async with self._db.execute(sql, args) as cursor:
406
+ async for row in cursor:
407
+ yield JournalEvent(row["seq"], row["ts"], row["kind"], row["key"], json.loads(row["payload"]))
408
+
409
+ async def last_seq(self) -> int:
410
+ async with self._db.execute("SELECT COALESCE(MAX(seq), 0) AS seq FROM events") as cursor:
411
+ row = await cursor.fetchone()
412
+ return int(row["seq"])
413
+
414
+ # -- intents --------------------------------------------------------------
415
+
416
+ async def record_intent(
417
+ self,
418
+ request: OrderRequest,
419
+ *,
420
+ client_order_id: str,
421
+ venue: str,
422
+ account: Account,
423
+ operation: str = "create",
424
+ target_order_id: str | None = None,
425
+ ) -> Intent:
426
+ """Write what the engine is about to do, before it does it."""
427
+ stamp = now_ms()
428
+ body = request.model_dump(mode="json")
429
+ async with self._write:
430
+ await self._db.execute("BEGIN IMMEDIATE")
431
+ try:
432
+ await self._db.execute(
433
+ """INSERT INTO intents(client_order_id, state, operation, venue, account_key, market_id,
434
+ order_id, target_order_id, book, trader, attempts, request, detail,
435
+ created_ts, updated_ts)
436
+ VALUES(?,?,?,?,?,?,NULL,?,?,?,0,?,NULL,?,?)
437
+ ON CONFLICT(client_order_id) DO NOTHING""",
438
+ (client_order_id, IntentState.PLANNED, operation, venue, account.key, request.market_id,
439
+ target_order_id, request.book, request.trader, _dump(body), stamp, stamp),
440
+ )
441
+ await self._db.execute(
442
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
443
+ (stamp, "intent.planned", client_order_id,
444
+ _dump({"operation": operation, "venue": venue, "account": account.key, "request": body,
445
+ "target_order_id": target_order_id})),
446
+ )
447
+ await self._db.execute("COMMIT")
448
+ except BaseException:
449
+ await self._db.execute("ROLLBACK")
450
+ raise
451
+ return await self.intent(client_order_id) # type: ignore[return-value]
452
+
453
+ async def mark_intent(
454
+ self,
455
+ client_order_id: str,
456
+ state: str,
457
+ *,
458
+ order_id: str | None = None,
459
+ detail: str | None = None,
460
+ bump_attempt: bool = False,
461
+ ) -> None:
462
+ stamp = now_ms()
463
+ async with self._write:
464
+ await self._db.execute("BEGIN IMMEDIATE")
465
+ try:
466
+ await self._db.execute(
467
+ f"""UPDATE intents SET state=?, updated_ts=?,
468
+ order_id=COALESCE(?, order_id), detail=?,
469
+ attempts=attempts+{1 if bump_attempt else 0}
470
+ WHERE client_order_id=?""",
471
+ (state, stamp, order_id, detail, client_order_id),
472
+ )
473
+ await self._db.execute(
474
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
475
+ (stamp, f"intent.{state}", client_order_id, _dump({"order_id": order_id, "detail": detail})),
476
+ )
477
+ await self._db.execute("COMMIT")
478
+ except BaseException:
479
+ await self._db.execute("ROLLBACK")
480
+ raise
481
+
482
+ async def intent(self, client_order_id: str) -> Intent | None:
483
+ async with self._db.execute("SELECT * FROM intents WHERE client_order_id=?", (client_order_id,)) as cursor:
484
+ row = await cursor.fetchone()
485
+ return self._intent_of(row) if row else None
486
+
487
+ async def intents(self, *, state: str | None = None, states: Iterable[str] | None = None, older_than_ms: int | None = None) -> list[Intent]:
488
+ sql = "SELECT * FROM intents"
489
+ args: list[Any] = []
490
+ clauses = []
491
+ names = [state] if state else list(states or [])
492
+ if names:
493
+ clauses.append(f"state IN ({','.join('?' * len(names))})")
494
+ args += names
495
+ if older_than_ms is not None:
496
+ clauses.append("updated_ts <= ?")
497
+ args.append(now_ms() - older_than_ms)
498
+ if clauses:
499
+ sql += " WHERE " + " AND ".join(clauses)
500
+ sql += " ORDER BY updated_ts"
501
+ async with self._db.execute(sql, args) as cursor:
502
+ return [self._intent_of(row) async for row in cursor]
503
+
504
+ async def in_doubt(self, older_than_ms: int = 0) -> list[Intent]:
505
+ """Intents handed to a venue whose answer never came back."""
506
+ return await self.intents(states=IN_DOUBT, older_than_ms=older_than_ms)
507
+
508
+ @staticmethod
509
+ def _intent_of(row: Any) -> Intent:
510
+ return Intent(
511
+ client_order_id=row["client_order_id"], state=row["state"], operation=row["operation"], venue=row["venue"],
512
+ account_key=row["account_key"], market_id=row["market_id"], order_id=row["order_id"],
513
+ target_order_id=row["target_order_id"], book=row["book"], trader=row["trader"], attempts=row["attempts"],
514
+ request=json.loads(row["request"]), detail=row["detail"], created_ts=row["created_ts"],
515
+ updated_ts=row["updated_ts"],
516
+ )
517
+
518
+ # -- orders and fills -----------------------------------------------------
519
+
520
+ async def upsert_order(self, order: Order, *, event: str = "order.updated") -> None:
521
+ stamp = now_ms()
522
+ body = order.model_dump(mode="json")
523
+ account_key = order.account.key if order.account else f"{order.venue}:default"
524
+ async with self._write:
525
+ await self._db.execute("BEGIN IMMEDIATE")
526
+ try:
527
+ await self._db.execute(
528
+ """INSERT INTO orders(venue, id, client_order_id, account_key, market_id, side,
529
+ status, terminal, book, trader, amount, filled, price, parent_id, payload,
530
+ created_ts, updated_ts)
531
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
532
+ ON CONFLICT(venue, id) DO UPDATE SET
533
+ client_order_id=COALESCE(excluded.client_order_id, orders.client_order_id),
534
+ status=excluded.status, terminal=excluded.terminal, filled=excluded.filled,
535
+ price=excluded.price, amount=excluded.amount, payload=excluded.payload,
536
+ book=COALESCE(excluded.book, orders.book), trader=COALESCE(excluded.trader, orders.trader),
537
+ parent_id=COALESCE(excluded.parent_id, orders.parent_id),
538
+ updated_ts=excluded.updated_ts""",
539
+ (order.venue, order.id, order.client_order_id, account_key, order.market_id,
540
+ _word(order.side), _word(order.status), 1 if order.is_terminal else 0, order.book, order.trader,
541
+ str(order.amount), str(order.filled), _text(order.price),
542
+ order.parent_id or order.tags.get("parent"), _dump(body),
543
+ order.created_at or stamp, stamp),
544
+ )
545
+ await self._db.execute(
546
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
547
+ (stamp, event, f"{order.venue}:{order.id}", _dump(body)),
548
+ )
549
+ await self._db.execute("COMMIT")
550
+ except BaseException:
551
+ await self._db.execute("ROLLBACK")
552
+ raise
553
+
554
+ async def record_fill(self, fill: Fill, *, book: str | None = None, trader: str | None = None) -> bool:
555
+ """Store a fill. `False` means this fill id was already known, which
556
+ happens on every venue that delivers at least once."""
557
+ stamp = now_ms()
558
+ body = fill.model_dump(mode="json")
559
+ account_key = fill.account.key if fill.account else f"{fill.venue}:default"
560
+ async with self._write:
561
+ await self._db.execute("BEGIN IMMEDIATE")
562
+ try:
563
+ async with self._db.execute(
564
+ "SELECT settlement FROM fills WHERE venue=? AND id=?", (fill.venue, fill.id),
565
+ ) as cursor:
566
+ existing = await cursor.fetchone()
567
+ changed = existing is None or existing["settlement"] != _word(fill.settlement)
568
+ await self._db.execute(
569
+ """INSERT INTO fills(venue, id, order_id, client_order_id, account_key, market_id,
570
+ side, price, amount, fee, settlement, book, trader, ts, payload)
571
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
572
+ ON CONFLICT(venue, id) DO UPDATE SET
573
+ settlement=excluded.settlement, fee=COALESCE(excluded.fee, fills.fee),
574
+ payload=excluded.payload,
575
+ book=COALESCE(fills.book, excluded.book), trader=COALESCE(fills.trader, excluded.trader)""",
576
+ (fill.venue, fill.id, fill.order_id, fill.client_order_id, account_key, fill.market_id,
577
+ _word(fill.side), str(fill.price), str(fill.amount), _text(fill.fee),
578
+ _word(fill.settlement), book, trader, fill.timestamp or stamp, _dump(body)),
579
+ )
580
+ if changed:
581
+ await self._db.execute(
582
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
583
+ (stamp, "fill.new" if existing is None else "fill.settlement", f"{fill.venue}:{fill.id}", _dump(body)),
584
+ )
585
+ await self._db.execute("COMMIT")
586
+ except BaseException:
587
+ await self._db.execute("ROLLBACK")
588
+ raise
589
+ return changed
590
+
591
+ async def order(self, venue: str, order_id: str) -> Order | None:
592
+ async with self._db.execute("SELECT payload FROM orders WHERE venue=? AND id=?", (venue, order_id)) as cursor:
593
+ row = await cursor.fetchone()
594
+ return Order.model_validate(json.loads(row["payload"])) if row else None
595
+
596
+ async def order_by_client_id(self, client_order_id: str) -> Order | None:
597
+ async with self._db.execute(
598
+ "SELECT payload FROM orders WHERE client_order_id=? ORDER BY updated_ts DESC LIMIT 1", (client_order_id,),
599
+ ) as cursor:
600
+ row = await cursor.fetchone()
601
+ return Order.model_validate(json.loads(row["payload"])) if row else None
602
+
603
+ async def open_orders(self, *, venue: str | None = None, account_key: str | None = None, book: str | None = None) -> list[Order]:
604
+ sql = "SELECT payload FROM orders WHERE terminal=0"
605
+ args: list[Any] = []
606
+ for column, value in (("venue", venue), ("account_key", account_key), ("book", book)):
607
+ if value is not None:
608
+ sql += f" AND {column}=?"
609
+ args.append(value)
610
+ async with self._db.execute(sql + " ORDER BY updated_ts", args) as cursor:
611
+ return [Order.model_validate(json.loads(row["payload"])) async for row in cursor]
612
+
613
+ async def fills(self, *, since_ts: int | None = None, venue: str | None = None, book: str | None = None) -> list[Fill]:
614
+ """Fills as recorded. The strategy that placed the order is carried in
615
+ `info["book"]`, because a venue's fill does not know about books and
616
+ the ledger needs it to roll up."""
617
+ sql = "SELECT payload, book, trader FROM fills WHERE 1=1"
618
+ args: list[Any] = []
619
+ if since_ts is not None:
620
+ sql += " AND ts >= ?"
621
+ args.append(since_ts)
622
+ for column, value in (("venue", venue), ("book", book)):
623
+ if value is not None:
624
+ sql += f" AND {column}=?"
625
+ args.append(value)
626
+ async with self._db.execute(sql + " ORDER BY ts, id", args) as cursor:
627
+ return [self._fill_of(row) async for row in cursor]
628
+
629
+ @staticmethod
630
+ def _fill_of(row: Any) -> Fill:
631
+ fill = Fill.model_validate(json.loads(row["payload"]))
632
+ extra = {k: row[k] for k in ("book", "trader") if row[k]}
633
+ return fill.model_copy(update={"info": {**fill.info, **extra}}) if extra else fill
634
+
635
+ async def has_fill(self, venue: str, fill_id: str) -> bool:
636
+ async with self._db.execute("SELECT 1 FROM fills WHERE venue=? AND id=?", (venue, fill_id)) as cursor:
637
+ return await cursor.fetchone() is not None
638
+
639
+ async def orders_for_parent(self, parent_id: str) -> list[Order]:
640
+ """Every venue order a parent put out, by the column, oldest first."""
641
+ async with self._db.execute(
642
+ "SELECT payload FROM orders WHERE parent_id=? ORDER BY created_ts, id", (parent_id,)
643
+ ) as cursor:
644
+ return [Order.model_validate(json.loads(row["payload"])) async for row in cursor]
645
+
646
+ # -- buckets --------------------------------------------------------------
647
+
648
+ async def save_bucket(self, bucket: dict[str, Any]) -> None:
649
+ """Write a bucket and its members in one transaction; the member list
650
+ is replaced whole, so a save is also an edit."""
651
+ stamp = now_ms()
652
+ members = bucket.get("members") or []
653
+ async with self._write:
654
+ await self._db.execute("BEGIN IMMEDIATE")
655
+ try:
656
+ await self._db.execute(
657
+ """INSERT INTO buckets(id, book, name, status, created_ts, updated_ts)
658
+ VALUES(?,?,?,?,?,?)
659
+ ON CONFLICT(id) DO UPDATE SET book=excluded.book, name=excluded.name,
660
+ status=excluded.status, updated_ts=excluded.updated_ts""",
661
+ (bucket["id"], bucket["book"], bucket["name"], bucket.get("status", "active"),
662
+ int(bucket.get("created_ms") or stamp), stamp),
663
+ )
664
+ await self._db.execute("DELETE FROM bucket_members WHERE bucket_id=?", (bucket["id"],))
665
+ for position, member in enumerate(members):
666
+ await self._db.execute(
667
+ "INSERT INTO bucket_members(bucket_id, market_id, flip, position) VALUES(?,?,?,?)",
668
+ (bucket["id"], member["market_id"], 1 if member.get("flip") else 0, position),
669
+ )
670
+ await self._db.execute(
671
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
672
+ (stamp, "bucket.saved", bucket["id"], _dump(bucket)),
673
+ )
674
+ await self._db.execute("COMMIT")
675
+ except Exception:
676
+ await self._db.execute("ROLLBACK")
677
+ raise
678
+
679
+ async def bucket(self, bucket_id: str) -> dict[str, Any] | None:
680
+ async with self._db.execute("SELECT * FROM buckets WHERE id=?", (bucket_id,)) as cursor:
681
+ row = await cursor.fetchone()
682
+ if row is None:
683
+ return None
684
+ async with self._db.execute(
685
+ "SELECT market_id, flip FROM bucket_members WHERE bucket_id=? ORDER BY position", (bucket_id,)
686
+ ) as cursor:
687
+ members = [{"market_id": m["market_id"], "flip": bool(m["flip"])} async for m in cursor]
688
+ return {"id": row["id"], "book": row["book"], "name": row["name"], "status": row["status"],
689
+ "created_ms": row["created_ts"], "updated_ms": row["updated_ts"], "members": members}
690
+
691
+ async def buckets(self, *, book: str | None = None, status: str | None = "active") -> list[dict[str, Any]]:
692
+ sql, args = "SELECT id FROM buckets", []
693
+ where = []
694
+ if book is not None:
695
+ where.append("book=?"); args.append(book)
696
+ if status is not None:
697
+ where.append("status=?"); args.append(status)
698
+ if where:
699
+ sql += " WHERE " + " AND ".join(where)
700
+ async with self._db.execute(sql + " ORDER BY created_ts", args) as cursor:
701
+ ids = [row["id"] async for row in cursor]
702
+ out = []
703
+ for bucket_id in ids:
704
+ found = await self.bucket(bucket_id)
705
+ if found is not None:
706
+ out.append(found)
707
+ return out
708
+
709
+ async def archive_bucket(self, bucket_id: str) -> bool:
710
+ """Soft delete: orders that referenced it still resolve."""
711
+ async with self._write:
712
+ cursor = await self._db.execute(
713
+ "UPDATE buckets SET status='archived', updated_ts=? WHERE id=? AND status<>'archived'",
714
+ (now_ms(), bucket_id),
715
+ )
716
+ return cursor.rowcount > 0
717
+
718
+ # -- engine-held orders ---------------------------------------------------
719
+
720
+ async def save_managed(self, snapshot: dict[str, Any]) -> None:
721
+ """Write one engine-held order's whole state. Called on every change,
722
+ because a parent that is not on disk did not happen."""
723
+ stamp = now_ms()
724
+ account = snapshot.get("account") or {}
725
+ account_key: str | None
726
+ if not account or not account.get("venue"):
727
+ account_key = None
728
+ elif account.get("name"):
729
+ account_key = f"{account['venue']}:{account['name']}"
730
+ else:
731
+ account_key = str(account["venue"])
732
+ request = snapshot.get("request") or {}
733
+ children = snapshot.get("children") or []
734
+ async with self._write:
735
+ await self._db.execute("BEGIN IMMEDIATE")
736
+ try:
737
+ await self._db.execute(
738
+ """INSERT INTO managed_orders(id, kind, state, account_key, market_id, book, trader,
739
+ payload, child_order_id, created_ts, updated_ts)
740
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
741
+ ON CONFLICT(id) DO UPDATE SET state=excluded.state, payload=excluded.payload,
742
+ child_order_id=excluded.child_order_id, updated_ts=excluded.updated_ts""",
743
+ (snapshot["id"], snapshot.get("kind", ""), snapshot.get("state", ""), account_key,
744
+ request.get("market_id", ""), request.get("book"), request.get("trader"),
745
+ _dump(snapshot), children[-1]["order_id"] if children else None,
746
+ int(snapshot.get("created_ms") or stamp), stamp),
747
+ )
748
+ await self._db.execute(
749
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
750
+ (stamp, f"managed.{snapshot.get('state', 'updated')}", snapshot["id"], _dump(snapshot)),
751
+ )
752
+ await self._db.execute("COMMIT")
753
+ except BaseException:
754
+ await self._db.execute("ROLLBACK")
755
+ raise
756
+
757
+ async def managed(self, *, states: Iterable[str] | None = None) -> list[dict[str, Any]]:
758
+ sql = "SELECT payload FROM managed_orders"
759
+ args: list[Any] = []
760
+ names = list(states or [])
761
+ if names:
762
+ sql += f" WHERE state IN ({','.join('?' * len(names))})"
763
+ args += names
764
+ async with self._db.execute(sql + " ORDER BY created_ts", args) as cursor:
765
+ return [json.loads(row["payload"]) async for row in cursor]
766
+
767
+ async def managed_on(self, market_id: str) -> list[tuple[str, dict[str, Any]]]:
768
+ """Every engine-held order on one instrument, live or finished, as
769
+ `(kind, snapshot)`, oldest first. A bucket's orders are found by
770
+ `bucket:<id>`."""
771
+ async with self._db.execute(
772
+ "SELECT kind, payload FROM managed_orders WHERE market_id=? ORDER BY created_ts", (market_id,)
773
+ ) as cursor:
774
+ return [(row["kind"], json.loads(row["payload"])) async for row in cursor]
775
+
776
+ async def managed_one(self, parent_id: str) -> tuple[str, dict[str, Any]] | None:
777
+ async with self._db.execute("SELECT kind, payload FROM managed_orders WHERE id=?", (parent_id,)) as cursor:
778
+ row = await cursor.fetchone()
779
+ return (row["kind"], json.loads(row["payload"])) if row else None
780
+
781
+ # -- cursors, config, fair values ----------------------------------------
782
+
783
+ async def set_cursor(self, name: str, value: Any) -> None:
784
+ async with self._write:
785
+ await self._db.execute(
786
+ "INSERT INTO cursors(name, value, updated_ts) VALUES(?,?,?) "
787
+ "ON CONFLICT(name) DO UPDATE SET value=excluded.value, updated_ts=excluded.updated_ts",
788
+ (name, str(value), now_ms()),
789
+ )
790
+
791
+ async def cursor(self, name: str, default: Any = None) -> Any:
792
+ async with self._db.execute("SELECT value FROM cursors WHERE name=?", (name,)) as cursor:
793
+ row = await cursor.fetchone()
794
+ return row["value"] if row else default
795
+
796
+ async def save_config(self, kind: str, payload: dict[str, Any], *, author: str | None = None) -> int:
797
+ """Store a configuration version. Risk configuration is versioned so a
798
+ rejection can be explained by the rules that were in force."""
799
+ async with self._write:
800
+ cursor = await self._db.execute(
801
+ "INSERT INTO config_versions(kind, ts, author, payload) VALUES(?,?,?,?)",
802
+ (kind, now_ms(), author, _dump(payload)),
803
+ )
804
+ return int(cursor.lastrowid)
805
+
806
+ async def config(self, kind: str, version: int | None = None) -> tuple[int, dict[str, Any]] | None:
807
+ if version is None:
808
+ sql, args = "SELECT version, payload FROM config_versions WHERE kind=? ORDER BY version DESC LIMIT 1", (kind,)
809
+ else:
810
+ sql, args = "SELECT version, payload FROM config_versions WHERE kind=? AND version=?", (kind, version)
811
+ async with self._db.execute(sql, args) as cursor:
812
+ row = await cursor.fetchone()
813
+ return (int(row["version"]), json.loads(row["payload"])) if row else None
814
+
815
+ async def set_fair_value(self, account_key: str, market_id: str, value: Decimal, *, source: str | None = None) -> None:
816
+ async with self._write:
817
+ await self._db.execute(
818
+ "INSERT INTO fair_values(account_key, market_id, value, source, ts) VALUES(?,?,?,?,?) "
819
+ "ON CONFLICT(account_key, market_id) DO UPDATE SET value=excluded.value, source=excluded.source, ts=excluded.ts",
820
+ (account_key, market_id, str(value), source, now_ms()),
821
+ )
822
+
823
+ async def fair_values(self, account_key: str | None = None) -> dict[tuple[str, str], Decimal]:
824
+ sql = "SELECT account_key, market_id, value FROM fair_values"
825
+ args: list[Any] = []
826
+ if account_key is not None:
827
+ sql += " WHERE account_key=?"
828
+ args.append(account_key)
829
+ async with self._db.execute(sql, args) as cursor:
830
+ return {(row["account_key"], row["market_id"]): Decimal(row["value"]) async for row in cursor}
831
+
832
+ # -- the single-writer lease ---------------------------------------------
833
+
834
+ async def acquire_lease(self, name: str = "engine", *, ttl_ms: int = 30_000, steal_expired: bool = True) -> Lease:
835
+ """Take the right to write. Raises `LeaseLost` if another engine holds it."""
836
+ # `_Held` carries the refusal out of the transaction, so the rollback
837
+ # and the message that names the other engine do not tangle.
838
+ stamp = now_ms()
839
+ held_by: Any = None
840
+ async with self._write:
841
+ await self._db.execute("BEGIN IMMEDIATE")
842
+ try:
843
+ async with self._db.execute("SELECT * FROM leases WHERE name=?", (name,)) as cursor:
844
+ row = await cursor.fetchone()
845
+ if row is not None and row["owner"] != self.owner and (row["expires_ts"] > stamp or not steal_expired):
846
+ held_by = (row["owner"], row["expires_ts"])
847
+ await self._db.execute("ROLLBACK")
848
+ raise _Held()
849
+ await self._db.execute(
850
+ "INSERT INTO leases(name, owner, holder, acquired_ts, expires_ts) VALUES(?,?,?,?,?) "
851
+ "ON CONFLICT(name) DO UPDATE SET owner=excluded.owner, holder=excluded.holder, "
852
+ "acquired_ts=excluded.acquired_ts, expires_ts=excluded.expires_ts",
853
+ (name, self.owner, socket.gethostname(), stamp, stamp + ttl_ms),
854
+ )
855
+ await self._db.execute(
856
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
857
+ (stamp, "lease.acquired", name, _dump({"owner": self.owner, "ttl_ms": ttl_ms})),
858
+ )
859
+ await self._db.execute("COMMIT")
860
+ except _Held:
861
+ pass
862
+ except BaseException:
863
+ try:
864
+ await self._db.execute("ROLLBACK")
865
+ except Exception: # pragma: no cover - the transaction was already gone
866
+ pass
867
+ raise
868
+ if held_by is not None:
869
+ raise LeaseLost(
870
+ f"the journal at {self.path} is held by {held_by[0]} until {held_by[1]}; this engine will not trade"
871
+ )
872
+ self.lease = Lease(name=name, owner=self.owner, holder=socket.gethostname(), acquired_ts=stamp,
873
+ expires_ts=stamp + ttl_ms, ttl_ms=ttl_ms)
874
+ return self.lease
875
+
876
+ async def renew_lease(self) -> bool:
877
+ """Extend this engine's lease. `False` means someone else took it."""
878
+ if self.lease is None:
879
+ return False
880
+ stamp = now_ms()
881
+ async with self._write:
882
+ cursor = await self._db.execute(
883
+ "UPDATE leases SET expires_ts=? WHERE name=? AND owner=?",
884
+ (stamp + self.lease.ttl_ms, self.lease.name, self.owner),
885
+ )
886
+ if cursor.rowcount == 0:
887
+ return False
888
+ self.lease.expires_ts = stamp + self.lease.ttl_ms
889
+ return True
890
+
891
+ async def holds_lease(self) -> bool:
892
+ if self.lease is None:
893
+ return False
894
+ async with self._db.execute("SELECT owner, expires_ts FROM leases WHERE name=?", (self.lease.name,)) as cursor:
895
+ row = await cursor.fetchone()
896
+ return bool(row and row["owner"] == self.owner and row["expires_ts"] > now_ms())
897
+
898
+ async def release_lease(self) -> None:
899
+ lease, self.lease = self.lease, None
900
+ if lease is None:
901
+ return
902
+ async with self._write:
903
+ await self._db.execute("DELETE FROM leases WHERE name=? AND owner=?", (lease.name, self.owner))
904
+ await self._db.execute(
905
+ "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)",
906
+ (now_ms(), "lease.released", lease.name, _dump({"owner": self.owner})),
907
+ )