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/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}")