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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|