synpath 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
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")
@@ -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
@@ -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)