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/ws/base.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""The streaming layer's shared machinery: events, local books, one connection.
|
|
2
|
+
|
|
3
|
+
A `Stream` owns one WebSocket to one venue endpoint. It connects, sends the
|
|
4
|
+
venue's subscription frames, turns every message into typed events, and puts
|
|
5
|
+
them on a queue the caller reads with `async for event in stream`. When the
|
|
6
|
+
connection drops it reconnects with jittered exponential backoff and sends
|
|
7
|
+
every subscription again; the caller's loop keeps running.
|
|
8
|
+
|
|
9
|
+
Three promises, because an execution engine is built on them:
|
|
10
|
+
|
|
11
|
+
* **Nothing is lost silently.** A stream that can detect a missed message
|
|
12
|
+
(a sequence gap on Kalshi, a best bid or ask that disagrees with the local
|
|
13
|
+
book on Polymarket) emits a `StreamStatusEvent(state="gap")` and
|
|
14
|
+
resynchronizes. A reconnect on a channel that cannot be replayed (every
|
|
15
|
+
private channel) emits `reconcile_required=True`, so the engine knows to
|
|
16
|
+
read the venue's REST state before trusting its own.
|
|
17
|
+
* **A book is either right or marked not ready.** Deltas that arrive while a
|
|
18
|
+
book is waiting for its snapshot are not applied to a stale base.
|
|
19
|
+
* **The loop never dies on one bad message.** A payload that cannot be read
|
|
20
|
+
becomes a `StreamStatusEvent(state="error")`; the connection stays up.
|
|
21
|
+
|
|
22
|
+
Events are frozen, slotted dataclasses rather than pydantic models: a busy
|
|
23
|
+
book emits thousands a second, and the orders, fills, positions and balances
|
|
24
|
+
they carry are already validated trading models.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import random
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from decimal import Decimal
|
|
35
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Literal
|
|
36
|
+
|
|
37
|
+
from ..base import Capability, complete_capabilities
|
|
38
|
+
from ..trading.types import Balance, Fill, Order, Position, Side
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger("synpath.ws")
|
|
41
|
+
|
|
42
|
+
ZERO = Decimal("0")
|
|
43
|
+
ONE = Decimal("1")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def now_ms() -> int:
|
|
47
|
+
return int(time.time() * 1000)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Events
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class BookLevel:
|
|
56
|
+
price: Decimal
|
|
57
|
+
size: Decimal
|
|
58
|
+
"""For a delta, the level's size after the change; zero means removed."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
62
|
+
class Event:
|
|
63
|
+
venue: str
|
|
64
|
+
received_at: int = field(default_factory=now_ms)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
68
|
+
class BookEvent(Event):
|
|
69
|
+
"""A book changed. `kind="snapshot"` replaces the book; `kind="delta"`
|
|
70
|
+
lists only the levels that changed, each with its new size. Prices are in
|
|
71
|
+
the terms of `side`: a NO book is what NO costs. `best_bid` and
|
|
72
|
+
`best_ask` are the local book's after the change."""
|
|
73
|
+
|
|
74
|
+
market_id: str
|
|
75
|
+
"""Synpath id, `venue:native`."""
|
|
76
|
+
side: Literal["yes", "no"] = "yes"
|
|
77
|
+
kind: Literal["snapshot", "delta"]
|
|
78
|
+
bids: tuple[BookLevel, ...] = ()
|
|
79
|
+
asks: tuple[BookLevel, ...] = ()
|
|
80
|
+
best_bid: Decimal | None = None
|
|
81
|
+
best_ask: Decimal | None = None
|
|
82
|
+
sequence: int | None = None
|
|
83
|
+
timestamp: int | None = None
|
|
84
|
+
info: dict[str, Any] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
88
|
+
class QuoteEvent(Event):
|
|
89
|
+
"""Top of book and market statistics, as the venue summarizes them, in
|
|
90
|
+
the terms of `side`."""
|
|
91
|
+
|
|
92
|
+
market_id: str
|
|
93
|
+
side: Literal["yes", "no"] = "yes"
|
|
94
|
+
bid: Decimal | None = None
|
|
95
|
+
ask: Decimal | None = None
|
|
96
|
+
bid_size: Decimal | None = None
|
|
97
|
+
ask_size: Decimal | None = None
|
|
98
|
+
last: Decimal | None = None
|
|
99
|
+
volume: Decimal | None = None
|
|
100
|
+
open_interest: Decimal | None = None
|
|
101
|
+
timestamp: int | None = None
|
|
102
|
+
info: dict[str, Any] = field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
106
|
+
class TradeEvent(Event):
|
|
107
|
+
"""A public print, in the YES price. `taker_side` is what the aggressor
|
|
108
|
+
did on the YES leg (`buy` took YES, `sell` took NO) where the venue says,
|
|
109
|
+
`None` where it does not."""
|
|
110
|
+
|
|
111
|
+
market_id: str
|
|
112
|
+
id: str | None
|
|
113
|
+
price: Decimal
|
|
114
|
+
amount: Decimal
|
|
115
|
+
taker_side: Side | None = None
|
|
116
|
+
timestamp: int | None = None
|
|
117
|
+
info: dict[str, Any] = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
121
|
+
class OrderEvent(Event):
|
|
122
|
+
"""One of this account's orders, as the venue now reports it. `native`
|
|
123
|
+
names the venue's own event (placement, update, cancellation, snapshot)."""
|
|
124
|
+
|
|
125
|
+
order: Order
|
|
126
|
+
native: str | None = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
130
|
+
class FillEvent(Event):
|
|
131
|
+
"""One of this account's fills. On Polymarket the same fill arrives again
|
|
132
|
+
as its settlement state moves from `matched` to `confirmed` or `failed`."""
|
|
133
|
+
|
|
134
|
+
fill: Fill
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
138
|
+
class PositionEvent(Event):
|
|
139
|
+
position: Position
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
143
|
+
class BalanceEvent(Event):
|
|
144
|
+
balance: Balance
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
MarketState = Literal["created", "open", "paused", "closed", "determined", "settled", "updated"]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
151
|
+
class MarketStatusEvent(Event):
|
|
152
|
+
"""A market opened, paused, resumed, closed, resolved or settled. `native`
|
|
153
|
+
keeps the venue's own word; `result` is the outcome once determined."""
|
|
154
|
+
|
|
155
|
+
market_id: str
|
|
156
|
+
state: MarketState
|
|
157
|
+
native: str | None = None
|
|
158
|
+
result: str | None = None
|
|
159
|
+
timestamp: int | None = None
|
|
160
|
+
info: dict[str, Any] = field(default_factory=dict)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
164
|
+
class VenueEvent(Event):
|
|
165
|
+
"""Something venue-specific with no unified shape: a Kalshi order group
|
|
166
|
+
tripping, a Polymarket tick size change, an RFQ."""
|
|
167
|
+
|
|
168
|
+
name: str
|
|
169
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
170
|
+
timestamp: int | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
StreamState = Literal["connected", "disconnected", "connect_failed", "subscribed", "gap", "resynced", "error", "failed"]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
177
|
+
class StreamStatusEvent(Event):
|
|
178
|
+
"""The stream itself: connection lifecycle, gaps and recoveries, errors.
|
|
179
|
+
|
|
180
|
+
`failed` is final: the venue refused the stream in a way retrying cannot
|
|
181
|
+
fix (no permission, a request it rejects), and iteration ends after it.
|
|
182
|
+
|
|
183
|
+
`reconcile_required` is set when events may have been missed that the
|
|
184
|
+
venue cannot replay -- a reconnect with private subscriptions, or a gap
|
|
185
|
+
on a channel with no snapshot to recover from. Read the venue's REST
|
|
186
|
+
state before trusting anything derived from the stream."""
|
|
187
|
+
|
|
188
|
+
stream: str
|
|
189
|
+
state: StreamState
|
|
190
|
+
detail: str = ""
|
|
191
|
+
key: str | None = None
|
|
192
|
+
reconcile_required: bool = False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Local book
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
class LocalBook:
|
|
200
|
+
"""One side of a market's book: price -> size on each side, plus readiness.
|
|
201
|
+
|
|
202
|
+
`ready` is false until a snapshot has been applied and becomes false again
|
|
203
|
+
when the stream loses confidence in it (a gap, a reconnect); a book that
|
|
204
|
+
is not ready ignores deltas rather than applying them to a stale base.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
__slots__ = ("bids", "asks", "ready", "sequence", "timestamp")
|
|
208
|
+
|
|
209
|
+
def __init__(self) -> None:
|
|
210
|
+
self.bids: dict[Decimal, Decimal] = {}
|
|
211
|
+
self.asks: dict[Decimal, Decimal] = {}
|
|
212
|
+
self.ready = False
|
|
213
|
+
self.sequence: int | None = None
|
|
214
|
+
self.timestamp: int | None = None
|
|
215
|
+
|
|
216
|
+
def replace(self, bids: list[tuple[Decimal, Decimal]], asks: list[tuple[Decimal, Decimal]]) -> None:
|
|
217
|
+
self.bids = {p: s for p, s in bids if s > 0}
|
|
218
|
+
self.asks = {p: s for p, s in asks if s > 0}
|
|
219
|
+
self.ready = True
|
|
220
|
+
|
|
221
|
+
def set(self, side: Literal["bid", "ask"], price: Decimal, size: Decimal) -> Decimal:
|
|
222
|
+
levels = self.bids if side == "bid" else self.asks
|
|
223
|
+
if size > 0:
|
|
224
|
+
levels[price] = size
|
|
225
|
+
else:
|
|
226
|
+
levels.pop(price, None)
|
|
227
|
+
return size if size > 0 else ZERO
|
|
228
|
+
|
|
229
|
+
def add(self, side: Literal["bid", "ask"], price: Decimal, delta: Decimal) -> Decimal:
|
|
230
|
+
levels = self.bids if side == "bid" else self.asks
|
|
231
|
+
return self.set(side, price, levels.get(price, ZERO) + delta)
|
|
232
|
+
|
|
233
|
+
def invalidate(self) -> None:
|
|
234
|
+
self.ready = False
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def best_bid(self) -> Decimal | None:
|
|
238
|
+
return max(self.bids) if self.bids else None
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def best_ask(self) -> Decimal | None:
|
|
242
|
+
return min(self.asks) if self.asks else None
|
|
243
|
+
|
|
244
|
+
def levels(self, depth: int | None = None) -> tuple[tuple[BookLevel, ...], tuple[BookLevel, ...]]:
|
|
245
|
+
"""Bids best first (descending), asks best first (ascending)."""
|
|
246
|
+
bids = sorted(self.bids.items(), key=lambda kv: kv[0], reverse=True)
|
|
247
|
+
asks = sorted(self.asks.items(), key=lambda kv: kv[0])
|
|
248
|
+
if depth is not None:
|
|
249
|
+
bids, asks = bids[:depth], asks[:depth]
|
|
250
|
+
return tuple(BookLevel(p, s) for p, s in bids), tuple(BookLevel(p, s) for p, s in asks)
|
|
251
|
+
|
|
252
|
+
def mirrored(self, face_value: Decimal = ONE) -> "LocalBook":
|
|
253
|
+
"""The other side's view: bids become asks at `1 - p`."""
|
|
254
|
+
other = LocalBook()
|
|
255
|
+
other.bids = {face_value - p: s for p, s in self.asks.items()}
|
|
256
|
+
other.asks = {face_value - p: s for p, s in self.bids.items()}
|
|
257
|
+
other.ready, other.sequence, other.timestamp = self.ready, self.sequence, self.timestamp
|
|
258
|
+
return other
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def D(value: Any) -> Decimal:
|
|
262
|
+
if isinstance(value, dict):
|
|
263
|
+
value = value.get("value")
|
|
264
|
+
return Decimal(str(value))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def maybe_D(value: Any) -> Decimal | None:
|
|
268
|
+
if isinstance(value, dict):
|
|
269
|
+
value = value.get("value")
|
|
270
|
+
if value in (None, ""):
|
|
271
|
+
return None
|
|
272
|
+
return Decimal(str(value))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Stream
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
Connector = Callable[..., Awaitable[Any]]
|
|
280
|
+
|
|
281
|
+
_CLOSED = object()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass
|
|
285
|
+
class StreamStats:
|
|
286
|
+
connects: int = 0
|
|
287
|
+
disconnects: int = 0
|
|
288
|
+
messages: int = 0
|
|
289
|
+
gaps: int = 0
|
|
290
|
+
resyncs: int = 0
|
|
291
|
+
errors: int = 0
|
|
292
|
+
last_message_at: int | None = None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class Stream:
|
|
296
|
+
"""One venue WebSocket, kept alive, turned into events.
|
|
297
|
+
|
|
298
|
+
Subclasses provide the endpoint (`url`, `headers()`), what to send after
|
|
299
|
+
connecting (`on_connect()`), how to read a message (`handle()`), and what
|
|
300
|
+
to forget when the connection drops (`on_disconnect()`). Subscribing is
|
|
301
|
+
recording an intent: it is sent now if connected and again after every
|
|
302
|
+
reconnect.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
venue: str = ""
|
|
306
|
+
name: str = "stream"
|
|
307
|
+
has: dict[str, Capability] = {}
|
|
308
|
+
private: bool = False
|
|
309
|
+
"""Carries account data the venue cannot replay after a disconnect."""
|
|
310
|
+
app_ping: str | None = None
|
|
311
|
+
"""A text frame the venue wants as its heartbeat, sent every `ping_interval`."""
|
|
312
|
+
data_heartbeat: bool = False
|
|
313
|
+
"""Whether the venue's keepalive arrives as data this stream can see (a
|
|
314
|
+
`PONG` text frame, a heartbeat message). Only then does silence mean a
|
|
315
|
+
dead connection: a venue that keeps the socket alive with protocol ping
|
|
316
|
+
frames can be quiet for minutes on a slow market, and there the
|
|
317
|
+
WebSocket library's own ping/pong is what detects a dead peer."""
|
|
318
|
+
|
|
319
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
320
|
+
super().__init_subclass__(**kwargs)
|
|
321
|
+
complete_capabilities(cls)
|
|
322
|
+
|
|
323
|
+
def __init__(
|
|
324
|
+
self,
|
|
325
|
+
url: str,
|
|
326
|
+
*,
|
|
327
|
+
connect: Connector | None = None,
|
|
328
|
+
ping_interval: float = 10.0,
|
|
329
|
+
idle_timeout: float | None | Literal["auto"] = "auto",
|
|
330
|
+
backoff_initial: float = 0.5,
|
|
331
|
+
backoff_max: float = 30.0,
|
|
332
|
+
max_queue: int = 0,
|
|
333
|
+
):
|
|
334
|
+
self.url = url
|
|
335
|
+
self._connect = connect
|
|
336
|
+
self.ping_interval = ping_interval
|
|
337
|
+
self.idle_timeout = (60.0 if self.data_heartbeat else None) if idle_timeout == "auto" else idle_timeout
|
|
338
|
+
self.backoff_initial = backoff_initial
|
|
339
|
+
self.backoff_max = backoff_max
|
|
340
|
+
self._queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue)
|
|
341
|
+
self._last_seen = 0
|
|
342
|
+
self._stale = ""
|
|
343
|
+
self._ws: Any = None
|
|
344
|
+
self._task: asyncio.Task | None = None
|
|
345
|
+
self._closing = False
|
|
346
|
+
self.stats = StreamStats()
|
|
347
|
+
self.connected = asyncio.Event()
|
|
348
|
+
|
|
349
|
+
# -- venue hooks ----------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def headers(self) -> dict[str, str]:
|
|
352
|
+
"""Handshake headers, built fresh for every connection attempt."""
|
|
353
|
+
return {}
|
|
354
|
+
|
|
355
|
+
async def on_connect(self) -> None:
|
|
356
|
+
"""Send every recorded subscription."""
|
|
357
|
+
|
|
358
|
+
def on_disconnect(self) -> None:
|
|
359
|
+
"""Forget connection-scoped state: subscription ids, sequence numbers, book readiness."""
|
|
360
|
+
|
|
361
|
+
def handle(self, message: Any) -> list[Event]:
|
|
362
|
+
"""One decoded message as events."""
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
# -- lifecycle ------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def start(self) -> "Stream":
|
|
368
|
+
if self._task is None or self._task.done():
|
|
369
|
+
self._closing = False
|
|
370
|
+
self._task = asyncio.get_running_loop().create_task(self._run(), name=f"synpath-{self.venue}-{self.name}")
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
async def close(self) -> None:
|
|
374
|
+
self._closing = True
|
|
375
|
+
ws, self._ws = self._ws, None
|
|
376
|
+
if ws is not None:
|
|
377
|
+
try:
|
|
378
|
+
await ws.close()
|
|
379
|
+
except Exception: # pragma: no cover - closing a dead socket
|
|
380
|
+
pass
|
|
381
|
+
if self._task is not None:
|
|
382
|
+
self._task.cancel()
|
|
383
|
+
try:
|
|
384
|
+
await self._task
|
|
385
|
+
except (asyncio.CancelledError, Exception):
|
|
386
|
+
pass
|
|
387
|
+
self._task = None
|
|
388
|
+
self._queue.put_nowait(_CLOSED)
|
|
389
|
+
|
|
390
|
+
async def __aenter__(self) -> "Stream":
|
|
391
|
+
return self.start()
|
|
392
|
+
|
|
393
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
394
|
+
await self.close()
|
|
395
|
+
|
|
396
|
+
def __aiter__(self) -> AsyncIterator[Event]:
|
|
397
|
+
return self.events()
|
|
398
|
+
|
|
399
|
+
async def events(self) -> AsyncIterator[Event]:
|
|
400
|
+
"""Every event, in the order it was produced, until `close()`. Starts
|
|
401
|
+
the stream if it has not been started; a closed stream stays closed."""
|
|
402
|
+
if self._task is None and not self._closing:
|
|
403
|
+
self.start()
|
|
404
|
+
while True:
|
|
405
|
+
item = await self._queue.get()
|
|
406
|
+
if item is _CLOSED:
|
|
407
|
+
return
|
|
408
|
+
yield item
|
|
409
|
+
|
|
410
|
+
def emit(self, event: Event) -> None:
|
|
411
|
+
self._queue.put_nowait(event)
|
|
412
|
+
|
|
413
|
+
def status(self, state: StreamState, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None:
|
|
414
|
+
if state == "gap":
|
|
415
|
+
self.stats.gaps += 1
|
|
416
|
+
elif state == "resynced":
|
|
417
|
+
self.stats.resyncs += 1
|
|
418
|
+
elif state == "error":
|
|
419
|
+
self.stats.errors += 1
|
|
420
|
+
self.emit(StreamStatusEvent(
|
|
421
|
+
venue=self.venue, stream=self.name, state=state, detail=detail, key=key, reconcile_required=reconcile,
|
|
422
|
+
))
|
|
423
|
+
|
|
424
|
+
async def send(self, frame: Any) -> bool:
|
|
425
|
+
"""Send if connected; `False` when the frame will instead go out on the next connect."""
|
|
426
|
+
ws = self._ws
|
|
427
|
+
if ws is None:
|
|
428
|
+
return False
|
|
429
|
+
await ws.send(frame if isinstance(frame, str) else json.dumps(frame))
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
def backoff(self, attempt: int) -> float:
|
|
433
|
+
base = min(self.backoff_max, self.backoff_initial * (2 ** attempt))
|
|
434
|
+
return base * (0.5 + random.random() / 2)
|
|
435
|
+
|
|
436
|
+
async def _open(self) -> Any:
|
|
437
|
+
if self._connect is not None:
|
|
438
|
+
return await self._connect(self.url, additional_headers=self.headers())
|
|
439
|
+
import websockets
|
|
440
|
+
|
|
441
|
+
return await websockets.connect(
|
|
442
|
+
self.url, additional_headers=self.headers(),
|
|
443
|
+
ping_interval=None if self.app_ping else 20, max_size=None,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
async def _run(self) -> None:
|
|
447
|
+
attempt = 0
|
|
448
|
+
while not self._closing:
|
|
449
|
+
try:
|
|
450
|
+
ws = await self._open()
|
|
451
|
+
except asyncio.CancelledError:
|
|
452
|
+
raise
|
|
453
|
+
except Exception as exc:
|
|
454
|
+
self.status("connect_failed", f"{type(exc).__name__}: {exc}")
|
|
455
|
+
await asyncio.sleep(self.backoff(attempt))
|
|
456
|
+
attempt += 1
|
|
457
|
+
continue
|
|
458
|
+
attempt = 0
|
|
459
|
+
self._ws = ws
|
|
460
|
+
reconnect = self.stats.connects > 0
|
|
461
|
+
self.stats.connects += 1
|
|
462
|
+
self.connected.set()
|
|
463
|
+
self.status("connected", "reconnected" if reconnect else "", reconcile=reconnect and self.private)
|
|
464
|
+
pinger = watchdog = None
|
|
465
|
+
reason = "closed"
|
|
466
|
+
self._last_seen, self._stale = now_ms(), ""
|
|
467
|
+
try:
|
|
468
|
+
await self.on_connect()
|
|
469
|
+
if self.app_ping:
|
|
470
|
+
pinger = asyncio.get_running_loop().create_task(self._pinger(ws))
|
|
471
|
+
if self.idle_timeout:
|
|
472
|
+
watchdog = asyncio.get_running_loop().create_task(self._watchdog(ws))
|
|
473
|
+
while not self._closing:
|
|
474
|
+
if self.idle_timeout:
|
|
475
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=self.idle_timeout)
|
|
476
|
+
else:
|
|
477
|
+
raw = await ws.recv()
|
|
478
|
+
self._dispatch(raw)
|
|
479
|
+
except asyncio.CancelledError:
|
|
480
|
+
raise
|
|
481
|
+
except asyncio.TimeoutError:
|
|
482
|
+
reason = f"no message for {self.idle_timeout}s"
|
|
483
|
+
except Exception as exc:
|
|
484
|
+
reason = self._stale or f"{type(exc).__name__}: {exc}"
|
|
485
|
+
finally:
|
|
486
|
+
for task in (pinger, watchdog):
|
|
487
|
+
if task:
|
|
488
|
+
task.cancel()
|
|
489
|
+
self._ws = None
|
|
490
|
+
self.connected.clear()
|
|
491
|
+
self.stats.disconnects += 1
|
|
492
|
+
self.on_disconnect()
|
|
493
|
+
if self._closing:
|
|
494
|
+
return
|
|
495
|
+
self.status("disconnected", reason)
|
|
496
|
+
try:
|
|
497
|
+
await ws.close()
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
await asyncio.sleep(self.backoff(0))
|
|
501
|
+
|
|
502
|
+
async def _watchdog(self, ws: Any) -> None:
|
|
503
|
+
"""Silence measured on the wall clock, not the event loop's.
|
|
504
|
+
|
|
505
|
+
`asyncio`'s timers stop while the machine is suspended, so a laptop
|
|
506
|
+
that sleeps for twenty minutes wakes with a socket the venue closed
|
|
507
|
+
long ago and an idle timer that believes no time has passed. The
|
|
508
|
+
operating system can take another quarter of an hour to notice the
|
|
509
|
+
connection is gone. This wakes with the loop, sees the gap in real
|
|
510
|
+
time, and drops the connection so the stream reconnects.
|
|
511
|
+
"""
|
|
512
|
+
step = max(1.0, (self.idle_timeout or 0) / 4)
|
|
513
|
+
while True:
|
|
514
|
+
await asyncio.sleep(step)
|
|
515
|
+
silent = (now_ms() - self._last_seen) / 1000
|
|
516
|
+
if self.idle_timeout and silent > self.idle_timeout:
|
|
517
|
+
self._stale = f"no message for {round(silent)}s (wall clock)"
|
|
518
|
+
try:
|
|
519
|
+
await ws.close()
|
|
520
|
+
except Exception: # pragma: no cover - closing a dead socket
|
|
521
|
+
pass
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
async def _pinger(self, ws: Any) -> None:
|
|
525
|
+
while True:
|
|
526
|
+
await asyncio.sleep(self.ping_interval)
|
|
527
|
+
await ws.send(self.app_ping)
|
|
528
|
+
|
|
529
|
+
def _dispatch(self, raw: Any) -> None:
|
|
530
|
+
self.stats.messages += 1
|
|
531
|
+
self._last_seen = now_ms()
|
|
532
|
+
self.stats.last_message_at = now_ms()
|
|
533
|
+
if isinstance(raw, bytes):
|
|
534
|
+
raw = raw.decode("utf-8")
|
|
535
|
+
try:
|
|
536
|
+
message = json.loads(raw)
|
|
537
|
+
except ValueError:
|
|
538
|
+
message = raw
|
|
539
|
+
try:
|
|
540
|
+
for event in self.handle(message):
|
|
541
|
+
self.emit(event)
|
|
542
|
+
except Exception as exc:
|
|
543
|
+
log.debug("synpath.ws %s %s: unreadable message %r", self.venue, self.name, raw, exc_info=True)
|
|
544
|
+
self.status("error", f"unreadable message: {type(exc).__name__}: {exc}")
|