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
synpath/engine/engine.py
ADDED
|
@@ -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
|