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/eod.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""End of day: close the books, say what happened, start the next one.
|
|
2
|
+
|
|
3
|
+
Prediction markets trade almost around the clock, so "end of day" is a
|
|
4
|
+
choice rather than a bell. It is whatever hour the operator names, and what
|
|
5
|
+
it does is bookkeeping, not trading:
|
|
6
|
+
|
|
7
|
+
1. read the settlements each venue reports and book them, so a resolved
|
|
8
|
+
market stops counting as an open position;
|
|
9
|
+
2. write the day's report -- realized profit, fees, volume, fills, and the
|
|
10
|
+
open positions carried into tomorrow, per strategy and per account;
|
|
11
|
+
3. roll the risk day, which is what makes the daily loss limit mean
|
|
12
|
+
"today" rather than "since the engine started";
|
|
13
|
+
4. journal the report so the next morning's question, "what did we make
|
|
14
|
+
yesterday", is answered by reading rather than recomputing.
|
|
15
|
+
|
|
16
|
+
Nothing here cancels or trades. A desk that wants orders pulled at the close
|
|
17
|
+
says so with the kill switch's `cancel` policy, which is a decision, not a
|
|
18
|
+
side effect of a report.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timedelta, timezone
|
|
25
|
+
from decimal import Decimal
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..trading.types import Settlement
|
|
29
|
+
from .engine import Engine
|
|
30
|
+
|
|
31
|
+
ZERO = Decimal("0")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class DailyReport:
|
|
36
|
+
"""One day, closed."""
|
|
37
|
+
|
|
38
|
+
date: str
|
|
39
|
+
started_ms: int
|
|
40
|
+
ended_ms: int
|
|
41
|
+
realized: Decimal = ZERO
|
|
42
|
+
fees: Decimal = ZERO
|
|
43
|
+
volume: Decimal = ZERO
|
|
44
|
+
fills: int = 0
|
|
45
|
+
settlements: int = 0
|
|
46
|
+
settled_realized: Decimal = ZERO
|
|
47
|
+
by_book: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
48
|
+
by_account: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
49
|
+
open_positions: list[dict[str, Any]] = field(default_factory=list)
|
|
50
|
+
unrealized: Decimal | None = None
|
|
51
|
+
marked: int = 0
|
|
52
|
+
unmarked: int = 0
|
|
53
|
+
|
|
54
|
+
def summary(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"date": self.date, "realized": str(self.realized), "fees": str(self.fees), "volume": str(self.volume),
|
|
57
|
+
"fills": self.fills, "settlements": self.settlements, "settled_realized": str(self.settled_realized),
|
|
58
|
+
"unrealized": str(self.unrealized) if self.unrealized is not None else None,
|
|
59
|
+
"open_positions": len(self.open_positions), "marked": self.marked, "unmarked": self.unmarked,
|
|
60
|
+
"by_book": self.by_book, "by_account": self.by_account,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EndOfDay:
|
|
65
|
+
"""Runs the close, on demand or on a timer."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, engine: Engine, *, hour_utc: int = 0, minute_utc: int = 5):
|
|
68
|
+
self.engine = engine
|
|
69
|
+
self.hour_utc = hour_utc
|
|
70
|
+
self.minute_utc = minute_utc
|
|
71
|
+
|
|
72
|
+
async def run(self, *, date: str | None = None, book_settlements: bool = True) -> DailyReport:
|
|
73
|
+
engine = self.engine
|
|
74
|
+
started = int(engine.clock() * 1000)
|
|
75
|
+
day = date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
76
|
+
report = DailyReport(date=day, started_ms=started, ended_ms=started)
|
|
77
|
+
|
|
78
|
+
if book_settlements:
|
|
79
|
+
report.settlements, report.settled_realized = await self._settle()
|
|
80
|
+
|
|
81
|
+
since = engine.risk.day_start_ms
|
|
82
|
+
for fill in await engine.journal.fills(since_ts=since):
|
|
83
|
+
report.fills += 1
|
|
84
|
+
report.volume += fill.amount
|
|
85
|
+
report.fees += fill.fee or ZERO
|
|
86
|
+
|
|
87
|
+
marks = engine.fair_values.marks()
|
|
88
|
+
for level, target in (("book", report.by_book), ("account", report.by_account)):
|
|
89
|
+
for key, row in engine.ledger.rollup(level, marks).items(): # type: ignore[arg-type]
|
|
90
|
+
target[key] = {
|
|
91
|
+
"realized": str(row.realized), "unrealized": str(row.unrealized) if row.unrealized is not None else "",
|
|
92
|
+
"fees": str(row.fees), "volume": str(row.volume), "open_positions": str(row.positions),
|
|
93
|
+
}
|
|
94
|
+
total = engine.ledger.total(marks)
|
|
95
|
+
report.realized = total.realized
|
|
96
|
+
report.unrealized = total.unrealized
|
|
97
|
+
for state in engine.ledger.open_positions():
|
|
98
|
+
mark = marks.get((state.account_key, state.market_id))
|
|
99
|
+
report.marked += 1 if mark is not None else 0
|
|
100
|
+
report.unmarked += 0 if mark is not None else 1
|
|
101
|
+
report.open_positions.append({
|
|
102
|
+
"account": state.account_key, "book": state.book, "market_id": state.market_id,
|
|
103
|
+
"contracts": str(state.contracts), "average_cost": str(state.average_cost),
|
|
104
|
+
"mark": str(mark) if mark is not None else None,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
report.ended_ms = int(engine.clock() * 1000)
|
|
108
|
+
await engine.bus.publish("eod.report", report.summary(), key=day)
|
|
109
|
+
engine.risk.roll_day(realized_now=total.realized)
|
|
110
|
+
await engine.journal.set_cursor("eod:last", day)
|
|
111
|
+
return report
|
|
112
|
+
|
|
113
|
+
async def _settle(self) -> tuple[int, Decimal]:
|
|
114
|
+
"""Book every settlement the venues report since the last close."""
|
|
115
|
+
booked, realized = 0, ZERO
|
|
116
|
+
for venue, adapter in self.engine.adapters.items():
|
|
117
|
+
if not adapter.has.get("fetch_settlements"):
|
|
118
|
+
continue
|
|
119
|
+
since = int(await self.engine.journal.cursor(f"settlements:{venue}", 0) or 0)
|
|
120
|
+
try:
|
|
121
|
+
settlements = await adapter.fetch_settlements(since=since or None, limit=500)
|
|
122
|
+
except Exception:
|
|
123
|
+
continue
|
|
124
|
+
newest = since
|
|
125
|
+
for settlement in settlements:
|
|
126
|
+
newest = max(newest, settlement.timestamp or 0)
|
|
127
|
+
realized += self.engine.ledger.apply_settlement(settlement)
|
|
128
|
+
await self.engine.bus.publish("settlement.booked", settlement.model_dump(mode="json"),
|
|
129
|
+
key=f"{venue}:{settlement.market_id}")
|
|
130
|
+
booked += 1
|
|
131
|
+
if newest > since:
|
|
132
|
+
await self.engine.journal.set_cursor(f"settlements:{venue}", newest)
|
|
133
|
+
return booked, realized
|
|
134
|
+
|
|
135
|
+
def seconds_until_next(self, *, now: float | None = None) -> float:
|
|
136
|
+
"""How long until the next close, for a caller scheduling it."""
|
|
137
|
+
stamp = datetime.fromtimestamp(now if now is not None else self.engine.clock(), tz=timezone.utc)
|
|
138
|
+
target = stamp.replace(hour=self.hour_utc, minute=self.minute_utc, second=0, microsecond=0)
|
|
139
|
+
if target <= stamp:
|
|
140
|
+
target += timedelta(days=1)
|
|
141
|
+
return (target - stamp).total_seconds()
|
|
142
|
+
|
|
143
|
+
async def loop(self) -> None:
|
|
144
|
+
"""Run the close every day at the configured hour."""
|
|
145
|
+
import asyncio
|
|
146
|
+
|
|
147
|
+
while self.engine.running:
|
|
148
|
+
await asyncio.sleep(self.seconds_until_next())
|
|
149
|
+
try:
|
|
150
|
+
await self.run()
|
|
151
|
+
except Exception:
|
|
152
|
+
import logging
|
|
153
|
+
|
|
154
|
+
logging.getLogger("synpath.engine").exception("synpath.engine: the daily close failed")
|
synpath/engine/events.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The engine's event bus: one stream, many readers, replayable.
|
|
2
|
+
|
|
3
|
+
Everything the engine does becomes an event: an order accepted, a fill
|
|
4
|
+
booked, a risk rule refusing, a halt, a reconciliation finding an order
|
|
5
|
+
nobody asked for. Strategies, the server and the operator all read the same
|
|
6
|
+
stream, so what a user sees in a dashboard is what the journal recorded.
|
|
7
|
+
|
|
8
|
+
Two properties matter more than speed:
|
|
9
|
+
|
|
10
|
+
**A slow reader does not stall the engine.** Each subscriber gets its own
|
|
11
|
+
bounded queue. A reader that stops draining loses its oldest events and is
|
|
12
|
+
told how many, rather than blocking order entry behind it.
|
|
13
|
+
|
|
14
|
+
**The stream survives a restart.** Every event carries the journal sequence
|
|
15
|
+
number it was written under, so a reader that saved its cursor asks for
|
|
16
|
+
`replay(since)` and continues without a gap.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, AsyncIterator, Callable, Iterable
|
|
25
|
+
|
|
26
|
+
from .journal import Journal
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("synpath.engine")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class EngineEvent:
|
|
33
|
+
"""One thing that happened. `kind` is a dotted name (`order.accepted`,
|
|
34
|
+
`risk.rejected`, `reconcile.orphan`), `key` the thing it happened to."""
|
|
35
|
+
|
|
36
|
+
kind: str
|
|
37
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
key: str | None = None
|
|
39
|
+
ts: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
40
|
+
seq: int | None = None
|
|
41
|
+
"""The journal sequence number, once it has one."""
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
return f"{self.kind}({self.key})" if self.key else self.kind
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Subscription:
|
|
48
|
+
"""One reader's view of the stream."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, bus: "EventBus", *, kinds: tuple[str, ...] | None = None, maxsize: int = 10_000):
|
|
51
|
+
self.bus = bus
|
|
52
|
+
self.kinds = kinds
|
|
53
|
+
self.queue: asyncio.Queue[EngineEvent] = asyncio.Queue(maxsize=maxsize)
|
|
54
|
+
self.dropped = 0
|
|
55
|
+
|
|
56
|
+
def wants(self, event: EngineEvent) -> bool:
|
|
57
|
+
return not self.kinds or any(event.kind == k or event.kind.startswith(k + ".") for k in self.kinds)
|
|
58
|
+
|
|
59
|
+
def offer(self, event: EngineEvent) -> None:
|
|
60
|
+
if not self.wants(event):
|
|
61
|
+
return
|
|
62
|
+
try:
|
|
63
|
+
self.queue.put_nowait(event)
|
|
64
|
+
except asyncio.QueueFull:
|
|
65
|
+
# Drop the oldest: a stalled dashboard must not hold up trading.
|
|
66
|
+
try:
|
|
67
|
+
self.queue.get_nowait()
|
|
68
|
+
self.dropped += 1
|
|
69
|
+
self.queue.put_nowait(event)
|
|
70
|
+
except asyncio.QueueEmpty: # pragma: no cover - raced with the reader
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
async def __aiter__(self) -> AsyncIterator[EngineEvent]:
|
|
74
|
+
while True:
|
|
75
|
+
yield await self.queue.get()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self.bus.unsubscribe(self)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class EventBus:
|
|
82
|
+
"""Publish once, persist once, fan out to every reader."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, journal: Journal | None = None, *, persist: bool = True):
|
|
85
|
+
self.journal = journal
|
|
86
|
+
self.persist = persist and journal is not None
|
|
87
|
+
self.subscribers: list[Subscription] = []
|
|
88
|
+
self.hooks: list[Callable[[EngineEvent], Any]] = []
|
|
89
|
+
self.published = 0
|
|
90
|
+
|
|
91
|
+
def subscribe(self, *kinds: str, maxsize: int = 10_000) -> Subscription:
|
|
92
|
+
"""A reader of the whole stream, or of `kinds` and their children
|
|
93
|
+
(`subscribe("order")` also gets `order.accepted`)."""
|
|
94
|
+
sub = Subscription(self, kinds=kinds or None, maxsize=maxsize)
|
|
95
|
+
self.subscribers.append(sub)
|
|
96
|
+
return sub
|
|
97
|
+
|
|
98
|
+
def unsubscribe(self, sub: Subscription) -> None:
|
|
99
|
+
if sub in self.subscribers:
|
|
100
|
+
self.subscribers.remove(sub)
|
|
101
|
+
|
|
102
|
+
def on(self, hook: Callable[[EngineEvent], Any]) -> Callable[[EngineEvent], Any]:
|
|
103
|
+
"""Call `hook` for every event. A hook that raises is logged, not fatal."""
|
|
104
|
+
self.hooks.append(hook)
|
|
105
|
+
return hook
|
|
106
|
+
|
|
107
|
+
async def publish(self, kind: str, payload: dict[str, Any] | None = None, *, key: str | None = None, persist: bool | None = None) -> EngineEvent:
|
|
108
|
+
event = EngineEvent(kind=kind, payload=payload or {}, key=key)
|
|
109
|
+
if (self.persist if persist is None else persist) and self.journal is not None:
|
|
110
|
+
seq = await self.journal.append(kind, payload or {}, key=key)
|
|
111
|
+
event = EngineEvent(kind=kind, payload=event.payload, key=key, ts=event.ts, seq=seq)
|
|
112
|
+
self.emit(event)
|
|
113
|
+
return event
|
|
114
|
+
|
|
115
|
+
def emit(self, event: EngineEvent) -> None:
|
|
116
|
+
"""Fan out an event that is already written (or deliberately not)."""
|
|
117
|
+
self.published += 1
|
|
118
|
+
for sub in list(self.subscribers):
|
|
119
|
+
sub.offer(event)
|
|
120
|
+
for hook in list(self.hooks):
|
|
121
|
+
try:
|
|
122
|
+
result = hook(event)
|
|
123
|
+
if asyncio.iscoroutine(result):
|
|
124
|
+
asyncio.get_running_loop().create_task(_guard(result))
|
|
125
|
+
except Exception:
|
|
126
|
+
log.exception("synpath.engine: event hook failed on %s", event.kind)
|
|
127
|
+
|
|
128
|
+
async def replay(self, since: int = 0, *, kinds: Iterable[str] | None = None) -> AsyncIterator[EngineEvent]:
|
|
129
|
+
"""Events already in the journal, oldest first, for a reader catching up."""
|
|
130
|
+
if self.journal is None:
|
|
131
|
+
return
|
|
132
|
+
async for row in self.journal.replay(since, kinds=kinds):
|
|
133
|
+
yield EngineEvent(kind=row.kind, payload=row.payload, key=row.key, ts=row.ts, seq=row.seq)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _guard(coro: Any) -> None:
|
|
137
|
+
try:
|
|
138
|
+
await coro
|
|
139
|
+
except Exception:
|
|
140
|
+
log.exception("synpath.engine: async event hook failed")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Fair values: the number the engine marks a position at.
|
|
2
|
+
|
|
3
|
+
A mark is a decision, not a fact, so it is stored rather than computed on
|
|
4
|
+
the fly: per account and instrument, with where it came from and when. The
|
|
5
|
+
ledger marks unrealized profit with it, and later the synthetic order types
|
|
6
|
+
peg to it and the router compares venues with it.
|
|
7
|
+
|
|
8
|
+
Three sources, in the order the engine prefers them:
|
|
9
|
+
|
|
10
|
+
* one the caller set, because a desk's own model outranks anything here;
|
|
11
|
+
* the midpoint of a book the engine already has, which is free and honest
|
|
12
|
+
while the book is tight;
|
|
13
|
+
* the last trade, when a side of the book is empty and a midpoint would be
|
|
14
|
+
invented.
|
|
15
|
+
|
|
16
|
+
A mark that nothing has refreshed goes stale. `stale_after_s` decides when,
|
|
17
|
+
and a stale mark is returned as `None` rather than used quietly, so a
|
|
18
|
+
position shows its cost instead of a profit computed from yesterday's price.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from decimal import Decimal
|
|
25
|
+
from typing import Any, Iterable
|
|
26
|
+
|
|
27
|
+
from .journal import Journal
|
|
28
|
+
|
|
29
|
+
ZERO = Decimal("0")
|
|
30
|
+
ONE = Decimal("1")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class FairValue:
|
|
35
|
+
account_key: str
|
|
36
|
+
market_id: str
|
|
37
|
+
value: Decimal
|
|
38
|
+
source: str
|
|
39
|
+
ts: int
|
|
40
|
+
|
|
41
|
+
def stale(self, *, after_s: float, now_ms: int | None = None) -> bool:
|
|
42
|
+
stamp = now_ms if now_ms is not None else int(time.time() * 1000)
|
|
43
|
+
return (stamp - self.ts) / 1000 > after_s
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FairValues:
|
|
47
|
+
"""Marks per account and instrument, kept in memory and in the journal."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, journal: Journal | None = None, *, stale_after_s: float = 300.0, face_value: Decimal = ONE):
|
|
50
|
+
self.journal = journal
|
|
51
|
+
self.stale_after_s = stale_after_s
|
|
52
|
+
self.face_value = face_value
|
|
53
|
+
self._values: dict[tuple[str, str], FairValue] = {}
|
|
54
|
+
|
|
55
|
+
async def load(self) -> None:
|
|
56
|
+
"""Read what the journal has, so a restart does not start blind."""
|
|
57
|
+
if self.journal is None:
|
|
58
|
+
return
|
|
59
|
+
stamp = int(time.time() * 1000)
|
|
60
|
+
for (account_key, market_id), value in (await self.journal.fair_values()).items():
|
|
61
|
+
self._values[(account_key, market_id)] = FairValue(account_key, market_id, value, "journal", stamp)
|
|
62
|
+
|
|
63
|
+
async def set(self, account_key: str, market_id: str, value: Decimal, *, source: str = "manual", persist: bool = True) -> FairValue:
|
|
64
|
+
mark = FairValue(account_key, market_id, Decimal(value), source, int(time.time() * 1000))
|
|
65
|
+
self._values[(account_key, market_id)] = mark
|
|
66
|
+
if persist and self.journal is not None:
|
|
67
|
+
await self.journal.set_fair_value(account_key, market_id, mark.value, source=source)
|
|
68
|
+
return mark
|
|
69
|
+
|
|
70
|
+
def get(self, account_key: str, market_id: str) -> Decimal | None:
|
|
71
|
+
"""The mark, or `None` if there is none or it has gone stale."""
|
|
72
|
+
mark = self._values.get((account_key, market_id))
|
|
73
|
+
if mark is None or mark.stale(after_s=self.stale_after_s):
|
|
74
|
+
return None
|
|
75
|
+
return mark.value
|
|
76
|
+
|
|
77
|
+
def entry(self, account_key: str, market_id: str) -> FairValue | None:
|
|
78
|
+
return self._values.get((account_key, market_id))
|
|
79
|
+
|
|
80
|
+
def marks(self) -> dict[tuple[str, str], Decimal]:
|
|
81
|
+
"""Every live mark, in the shape `Ledger.rollup` wants."""
|
|
82
|
+
stamp = int(time.time() * 1000)
|
|
83
|
+
return {
|
|
84
|
+
key: mark.value for key, mark in self._values.items()
|
|
85
|
+
if not mark.stale(after_s=self.stale_after_s, now_ms=stamp)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async def from_book(
|
|
89
|
+
self,
|
|
90
|
+
account_key: str,
|
|
91
|
+
market_id: str,
|
|
92
|
+
*,
|
|
93
|
+
bid: Decimal | None = None,
|
|
94
|
+
ask: Decimal | None = None,
|
|
95
|
+
last: Decimal | None = None,
|
|
96
|
+
persist: bool = False,
|
|
97
|
+
) -> FairValue | None:
|
|
98
|
+
"""Mark from a book: the midpoint where both sides exist, otherwise the
|
|
99
|
+
last trade, otherwise the one side there is. `None` when there is
|
|
100
|
+
nothing to mark with."""
|
|
101
|
+
if bid is not None and ask is not None and ask >= bid:
|
|
102
|
+
return await self.set(account_key, market_id, (bid + ask) / 2, source="mid", persist=persist)
|
|
103
|
+
if last is not None:
|
|
104
|
+
return await self.set(account_key, market_id, last, source="last", persist=persist)
|
|
105
|
+
one_side = bid if bid is not None else ask
|
|
106
|
+
if one_side is not None:
|
|
107
|
+
return await self.set(account_key, market_id, one_side, source="touch", persist=persist)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
async def from_books(self, account_key: str, books: Iterable[tuple[str, Any]], *, persist: bool = False) -> int:
|
|
111
|
+
"""Mark a batch of `(market_id, LocalBook)` from the streaming layer."""
|
|
112
|
+
marked = 0
|
|
113
|
+
for market_id, book in books:
|
|
114
|
+
bid, ask = getattr(book, "best_bid", None), getattr(book, "best_ask", None)
|
|
115
|
+
if await self.from_book(account_key, market_id, bid=bid, ask=ask, persist=persist):
|
|
116
|
+
marked += 1
|
|
117
|
+
return marked
|
synpath/engine/feeds.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Feeds: the venues' streams, wired into a running engine.
|
|
2
|
+
|
|
3
|
+
The engine decides; it does not listen. Something has to subscribe the
|
|
4
|
+
venues' WebSockets and hand what they say to the engine, or a stop never sees
|
|
5
|
+
a price and a parent never hears that its child filled. That is this module.
|
|
6
|
+
|
|
7
|
+
Two kinds of stream per venue, and where each one goes:
|
|
8
|
+
|
|
9
|
+
* **Market data** (books and public prints). Every market a live engine-held
|
|
10
|
+
order watches is subscribed on its venue's market stream. The stream keeps a
|
|
11
|
+
`LocalBook` up to date in place; the engine is handed that same object once
|
|
12
|
+
(`Engine.set_book`) and told about every change (`Engine.on_book`), so a
|
|
13
|
+
parent always reads the book as the stream last left it. A book the stream
|
|
14
|
+
has not snapshotted yet, or lost confidence in after a gap, is not `ready`,
|
|
15
|
+
and parents read it as no book at all.
|
|
16
|
+
* **This account's orders and fills.** Order updates go to `Engine.on_order`,
|
|
17
|
+
which is how parents learn what their children filled: the venue's order
|
|
18
|
+
record, one channel, latest overwrites. Fill events go to `Engine.on_fill`,
|
|
19
|
+
which books them in the ledger and nothing else.
|
|
20
|
+
|
|
21
|
+
Subscriptions follow the engine: every `sync_interval_s` the markets its live
|
|
22
|
+
parents watch are compared with what is subscribed, and the difference is
|
|
23
|
+
subscribed. A stream that reports `reconcile_required` (a reconnect with
|
|
24
|
+
private channels, a gap it cannot replay) makes the engine poll that venue's
|
|
25
|
+
REST state straight away, rather than waiting for the next poll.
|
|
26
|
+
|
|
27
|
+
Venues without a stream here are still traded: orders reach the engine
|
|
28
|
+
through its poll loop, only more slowly. That covers the paper venue, which
|
|
29
|
+
delivers its own events, and the Polymarket US exchange API, whose streams are
|
|
30
|
+
gRPC and an optional install.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import logging
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Any, Coroutine, Mapping
|
|
38
|
+
|
|
39
|
+
from .. import ids
|
|
40
|
+
from ..ws.base import BookEvent, FillEvent, OrderEvent, StreamStatusEvent, TradeEvent
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger("synpath.engine.feeds")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class VenueStreams:
|
|
47
|
+
"""One venue's streams. `private` may be the same object as `market`
|
|
48
|
+
(Kalshi carries both on one connection) or absent (no credentials)."""
|
|
49
|
+
|
|
50
|
+
market: Any | None = None
|
|
51
|
+
private: Any | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any]) -> dict[str, VenueStreams]:
|
|
55
|
+
"""The library's own streams for each configured venue that has one.
|
|
56
|
+
Kalshi and Polymarket US need credentials even for books; Polymarket's
|
|
57
|
+
books are public and its user stream authenticates through the adapter."""
|
|
58
|
+
out: dict[str, VenueStreams] = {}
|
|
59
|
+
for venue, adapter in adapters.items():
|
|
60
|
+
creds = credentials.get(venue)
|
|
61
|
+
if venue == "kalshi" and creds is not None:
|
|
62
|
+
from ..ws.kalshi import KalshiStream
|
|
63
|
+
stream = KalshiStream(creds)
|
|
64
|
+
out[venue] = VenueStreams(market=stream, private=stream)
|
|
65
|
+
elif venue == "polymarket":
|
|
66
|
+
from ..ws.polymarket import PolymarketMarketStream, PolymarketUserStream
|
|
67
|
+
out[venue] = VenueStreams(
|
|
68
|
+
market=PolymarketMarketStream(),
|
|
69
|
+
private=PolymarketUserStream(trading=adapter) if creds is not None else None,
|
|
70
|
+
)
|
|
71
|
+
elif venue == "polymarket_us" and creds is not None:
|
|
72
|
+
from ..ws.polymarket_us import PolymarketUSMarketStream, PolymarketUSPrivateStream
|
|
73
|
+
out[venue] = VenueStreams(market=PolymarketUSMarketStream(creds), private=PolymarketUSPrivateStream(creds))
|
|
74
|
+
else:
|
|
75
|
+
log.info("synpath.engine.feeds: no stream for %s; its orders reach the engine by polling", venue)
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _has(stream: Any, capability: str) -> bool:
|
|
80
|
+
return bool((getattr(stream, "has", None) or {}).get(capability)) and callable(getattr(stream, capability, None))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Feeds:
|
|
84
|
+
def __init__(self, engine: Any, streams: Mapping[str, VenueStreams], *, sync_interval_s: float = 1.0):
|
|
85
|
+
self.engine = engine
|
|
86
|
+
self.streams: dict[str, VenueStreams] = dict(streams)
|
|
87
|
+
self.sync_interval_s = sync_interval_s
|
|
88
|
+
self.watching: dict[str, set[str]] = {venue: set() for venue in self.streams}
|
|
89
|
+
self.dispatched = 0
|
|
90
|
+
self.closing = False
|
|
91
|
+
|
|
92
|
+
def distinct(self) -> list[tuple[str, Any]]:
|
|
93
|
+
"""Every stream object once, with the venue it belongs to."""
|
|
94
|
+
seen: set[int] = set()
|
|
95
|
+
out: list[tuple[str, Any]] = []
|
|
96
|
+
for venue, pair in self.streams.items():
|
|
97
|
+
for stream in (pair.market, pair.private):
|
|
98
|
+
if stream is not None and id(stream) not in seen:
|
|
99
|
+
seen.add(id(stream))
|
|
100
|
+
out.append((venue, stream))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
# -- subscribing ----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def start(self) -> None:
|
|
106
|
+
"""Start every stream, subscribe this account's orders and fills, and
|
|
107
|
+
the books every live parent already watches (restored ones included)."""
|
|
108
|
+
for _, stream in self.distinct():
|
|
109
|
+
stream.start()
|
|
110
|
+
for venue, pair in self.streams.items():
|
|
111
|
+
private = pair.private
|
|
112
|
+
if private is None:
|
|
113
|
+
continue
|
|
114
|
+
for capability in ("watch_orders", "watch_my_trades"):
|
|
115
|
+
if _has(private, capability):
|
|
116
|
+
try:
|
|
117
|
+
await getattr(private, capability)()
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
log.warning("synpath.engine.feeds: %s %s failed: %s", venue, capability, exc)
|
|
120
|
+
await self.sync()
|
|
121
|
+
|
|
122
|
+
def wanted(self) -> dict[str, set[str]]:
|
|
123
|
+
"""Markets live parents watch, by venue, on venues with a market stream."""
|
|
124
|
+
out: dict[str, set[str]] = {}
|
|
125
|
+
for market_id in list(self.engine.orders.by_market):
|
|
126
|
+
if not self.engine.orders.watching(market_id):
|
|
127
|
+
continue
|
|
128
|
+
venue, _ = ids.split(market_id)
|
|
129
|
+
if venue is None or venue not in self.streams or self.streams[venue].market is None:
|
|
130
|
+
continue
|
|
131
|
+
out.setdefault(venue, set()).add(market_id)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
async def sync(self) -> int:
|
|
135
|
+
"""Subscribe what is wanted and not yet watched. Returns how many."""
|
|
136
|
+
added = 0
|
|
137
|
+
for venue, markets in self.wanted().items():
|
|
138
|
+
new = sorted(markets - self.watching[venue])
|
|
139
|
+
if not new:
|
|
140
|
+
continue
|
|
141
|
+
stream = self.streams[venue].market
|
|
142
|
+
try:
|
|
143
|
+
await stream.watch_order_book(new)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
log.warning("synpath.engine.feeds: subscribing %s on %s failed: %s", new, venue, exc)
|
|
146
|
+
continue
|
|
147
|
+
self.watching[venue].update(new)
|
|
148
|
+
for market_id in new:
|
|
149
|
+
self.register(stream, market_id)
|
|
150
|
+
added += len(new)
|
|
151
|
+
return added
|
|
152
|
+
|
|
153
|
+
def register(self, stream: Any, market_id: str) -> None:
|
|
154
|
+
"""Hand the engine the stream's own book object for this market."""
|
|
155
|
+
book = stream.book(market_id)
|
|
156
|
+
if book is not None and self.engine.books.get(market_id) is not book:
|
|
157
|
+
self.engine.set_book(market_id, book)
|
|
158
|
+
|
|
159
|
+
# -- delivering -----------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
async def dispatch(self, venue: str, stream: Any, event: Any) -> None:
|
|
162
|
+
self.dispatched += 1
|
|
163
|
+
if isinstance(event, BookEvent):
|
|
164
|
+
self.register(stream, event.market_id)
|
|
165
|
+
await self.engine.on_book(event)
|
|
166
|
+
elif isinstance(event, TradeEvent):
|
|
167
|
+
await self.engine.on_trade(event)
|
|
168
|
+
elif isinstance(event, OrderEvent):
|
|
169
|
+
await self.engine.on_order(event.order)
|
|
170
|
+
elif isinstance(event, FillEvent):
|
|
171
|
+
await self.engine.on_fill(event.fill)
|
|
172
|
+
elif isinstance(event, StreamStatusEvent):
|
|
173
|
+
if event.reconcile_required and venue in self.engine.adapters:
|
|
174
|
+
await self.engine.poll(venue)
|
|
175
|
+
if event.state == "failed":
|
|
176
|
+
log.error("synpath.engine.feeds: %s %s failed for good: %s", venue, event.stream, event.detail)
|
|
177
|
+
|
|
178
|
+
async def pump(self, venue: str, stream: Any) -> None:
|
|
179
|
+
"""Deliver one stream's events until it ends. One bad event is logged
|
|
180
|
+
and skipped; it does not stop the stream.
|
|
181
|
+
|
|
182
|
+
A stream that ends on its own (the venue refused it for good) does not
|
|
183
|
+
end the pump until the engine stops or the feeds close: a host that
|
|
184
|
+
waits for the first of its tasks to finish must not take a lost
|
|
185
|
+
stream for a stopped engine. That venue's orders keep arriving by
|
|
186
|
+
polling."""
|
|
187
|
+
async for event in stream:
|
|
188
|
+
try:
|
|
189
|
+
await self.dispatch(venue, stream, event)
|
|
190
|
+
except Exception:
|
|
191
|
+
log.exception("synpath.engine.feeds: handling a %s event from %s failed", type(event).__name__, venue)
|
|
192
|
+
if self.closing:
|
|
193
|
+
return
|
|
194
|
+
log.warning("synpath.engine.feeds: the %s %s stream ended; that venue is polled until a restart",
|
|
195
|
+
venue, getattr(stream, "name", "stream"))
|
|
196
|
+
while self.engine.running and not self.closing:
|
|
197
|
+
await asyncio.sleep(1.0)
|
|
198
|
+
|
|
199
|
+
async def _sync_loop(self) -> None:
|
|
200
|
+
while self.engine.running:
|
|
201
|
+
await asyncio.sleep(self.sync_interval_s)
|
|
202
|
+
try:
|
|
203
|
+
await self.sync()
|
|
204
|
+
except Exception:
|
|
205
|
+
log.exception("synpath.engine.feeds: sync failed")
|
|
206
|
+
|
|
207
|
+
def background(self) -> dict[str, Coroutine[Any, Any, None]]:
|
|
208
|
+
"""The coroutines a host schedules alongside `Engine.background()`."""
|
|
209
|
+
loops: dict[str, Coroutine[Any, Any, None]] = {"feeds-sync": self._sync_loop()}
|
|
210
|
+
for venue, stream in self.distinct():
|
|
211
|
+
loops[f"feed-{venue}-{getattr(stream, 'name', 'stream')}"] = self.pump(venue, stream)
|
|
212
|
+
return loops
|
|
213
|
+
|
|
214
|
+
async def close(self) -> None:
|
|
215
|
+
self.closing = True
|
|
216
|
+
for venue, stream in self.distinct():
|
|
217
|
+
try:
|
|
218
|
+
await stream.close()
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
log.debug("synpath.engine.feeds: closing %s failed: %s", venue, exc)
|