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/grpc.py ADDED
@@ -0,0 +1,578 @@
1
+ """gRPC server streams as `Stream`s, and the venue protos they are read with.
2
+
3
+ The Polymarket US exchange API streams over gRPC rather than WebSockets. A
4
+ `GrpcStream` keeps the promises of `synpath.ws.base.Stream` -- typed events
5
+ on one queue, reconnects with jittered backoff, `gap`, `reconcile_required`,
6
+ a bad message is an `error` and not the end -- over a server-streaming RPC.
7
+
8
+ **Protos are the user's.** Polymarket publishes its `.proto` files as a
9
+ download without a license, so synpath ships neither the protos nor code
10
+ generated from them. Point a stream at the unzipped bundle (or the zip
11
+ itself), or set `SYNPATH_POLYMARKET_US_PROTOS`; the bundle is compiled once
12
+ into a descriptor set cached under `~/.cache/synpath/protos`, keyed by the
13
+ protos' contents, and messages are built from that at run time. Nothing is
14
+ written into the package or onto `sys.path`.
15
+
16
+ Messages are handed to venue code as dicts in the protos' JSON mapping
17
+ (camelCase names, int64s as strings, enums by name, timestamps as RFC 3339),
18
+ the same shapes the exchange's REST API returns, so one set of normalizers
19
+ reads both. Fields left at their proto3 default are written out, because
20
+ the default of an enum is a real value here (`ORDER_STATE_NEW`,
21
+ `INSTRUMENT_STATE_CLOSED`).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import hashlib
27
+ import os
28
+ import zipfile
29
+ from collections import OrderedDict
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any, AsyncIterator, Awaitable, Callable, Literal
33
+
34
+ from .base import _CLOSED, Stream, log, now_ms
35
+
36
+ PROTO_ENV = "SYNPATH_POLYMARKET_US_PROTOS"
37
+
38
+ CallOpener = Callable[[str, dict[str, Any], list[tuple[str, str]]], Awaitable[AsyncIterator[Any]]]
39
+ """`(method, request, metadata) -> responses`: how a stream opens one call.
40
+ The default speaks gRPC; tests pass scripted responses."""
41
+
42
+ READY = object()
43
+ """Yielded by a call once the server has answered (its headers arrived)."""
44
+
45
+ Outcome = Literal["retry", "reauthenticate", "slow_down", "fatal"]
46
+
47
+ RETRY_CODES = {"UNAVAILABLE", "INTERNAL", "UNKNOWN", "DEADLINE_EXCEEDED", "ABORTED", "CANCELLED", "DATA_LOSS"}
48
+ FATAL_CODES = {"PERMISSION_DENIED", "INVALID_ARGUMENT", "UNIMPLEMENTED", "NOT_FOUND", "FAILED_PRECONDITION", "OUT_OF_RANGE"}
49
+
50
+
51
+ class ProtosMissing(RuntimeError):
52
+ """The venue's protos were not given or cannot be compiled."""
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Protos
57
+ # ---------------------------------------------------------------------------
58
+
59
+ @dataclass(frozen=True)
60
+ class RpcMethod:
61
+ path: str
62
+ request: type
63
+ response: type
64
+
65
+
66
+ def _cache_root() -> Path:
67
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
68
+ return Path(base) / "synpath" / "protos"
69
+
70
+
71
+ def _find_root(path: Path, anchor: str) -> Path | None:
72
+ if (path / anchor).is_file():
73
+ return path
74
+ for current, dirs, _files in os.walk(path):
75
+ dirs[:] = sorted(d for d in dirs if not d.startswith("."))
76
+ if (Path(current) / anchor).is_file():
77
+ return Path(current)
78
+ return None
79
+
80
+
81
+ class ProtoBundle:
82
+ """A venue's protos, compiled into a private descriptor pool.
83
+
84
+ `path` is a directory holding the bundle (at any depth) or the zip it was
85
+ downloaded as. `anchor` is a file whose presence marks the import root.
86
+ """
87
+
88
+ _loaded: dict[tuple[str, str], "ProtoBundle"] = {}
89
+
90
+ def __init__(self, path: str | os.PathLike[str] | None = None, *, anchor: str, files: tuple[str, ...], cache_dir: str | os.PathLike[str] | None = None):
91
+ try:
92
+ from google.protobuf import descriptor_pb2, descriptor_pool # noqa: F401
93
+ except ImportError as exc: # pragma: no cover - depends on the environment
94
+ raise ImportError("gRPC streams need grpcio and protobuf: pip install synpath[grpc]") from exc
95
+ given = path if path is not None else os.environ.get(PROTO_ENV)
96
+ if not given:
97
+ raise ProtosMissing(
98
+ f"the Polymarket US exchange protos are needed for its gRPC streams: download the bundle from the "
99
+ f"venue's documentation and pass its directory or zip as `protos=`, or set {PROTO_ENV}"
100
+ )
101
+ self.cache_dir = Path(cache_dir) if cache_dir is not None else _cache_root()
102
+ source = Path(given).expanduser()
103
+ if source.is_file() and zipfile.is_zipfile(source):
104
+ source = self._unzip(source)
105
+ if not source.is_dir():
106
+ raise ProtosMissing(f"no proto bundle at {given}")
107
+ root = _find_root(source, anchor)
108
+ if root is None:
109
+ raise ProtosMissing(f"{given} does not contain {anchor}")
110
+ self.root = root
111
+ self.files = files
112
+ self.pool = self._pool(self._descriptor_set())
113
+ self._classes: dict[str, type] = {}
114
+ self._methods: dict[str, RpcMethod] = {}
115
+
116
+ @classmethod
117
+ def load(cls, path: str | os.PathLike[str] | None = None, **kwargs: Any) -> "ProtoBundle":
118
+ """One bundle per location per process."""
119
+ key = (str(path or os.environ.get(PROTO_ENV) or ""), repr(sorted(kwargs.items())))
120
+ bundle = cls._loaded.get(key)
121
+ if bundle is None:
122
+ bundle = cls._loaded[key] = cls(path, **kwargs)
123
+ return bundle
124
+
125
+ def _unzip(self, archive: Path) -> Path:
126
+ digest = hashlib.sha256(archive.read_bytes()).hexdigest()[:16]
127
+ target = self.cache_dir / f"bundle-{digest}"
128
+ if not target.is_dir():
129
+ staging = self.cache_dir / f".bundle-{digest}-{os.getpid()}"
130
+ with zipfile.ZipFile(archive) as zf:
131
+ base = staging.resolve()
132
+ for member in zf.namelist():
133
+ if not (base / member).resolve().is_relative_to(base):
134
+ raise ProtosMissing(f"{archive} has an entry outside the archive: {member}")
135
+ zf.extractall(staging)
136
+ if target.exists():
137
+ # Another process unpacked the same archive first.
138
+ import shutil
139
+
140
+ shutil.rmtree(staging, ignore_errors=True)
141
+ else:
142
+ staging.replace(target)
143
+ return target
144
+
145
+ def _descriptor_set(self) -> bytes:
146
+ protos = sorted(p for p in self.root.rglob("*.proto") if p.is_file())
147
+ digest = hashlib.sha256()
148
+ for proto in protos:
149
+ digest.update(str(proto.relative_to(self.root)).encode())
150
+ digest.update(proto.read_bytes())
151
+ digest.update(repr(self.files).encode())
152
+ out = self.cache_dir / f"{digest.hexdigest()[:24]}.pb"
153
+ if out.is_file():
154
+ return out.read_bytes()
155
+ try:
156
+ from importlib.resources import files as resource_files
157
+
158
+ from grpc_tools import protoc
159
+ except ImportError as exc: # pragma: no cover - depends on the environment
160
+ raise ImportError("compiling the venue's protos needs grpcio-tools: pip install synpath[grpc]") from exc
161
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
162
+ staging = out.with_suffix(f".{os.getpid()}.tmp")
163
+ include = str(resource_files("grpc_tools") / "_proto")
164
+ missing = [f for f in self.files if not (self.root / f).is_file()]
165
+ if missing:
166
+ raise ProtosMissing(f"the bundle at {self.root} has no {missing[0]}")
167
+ code = protoc.main([
168
+ "protoc", f"-I{self.root}", f"-I{include}", "--include_imports", f"--descriptor_set_out={staging}", *self.files,
169
+ ])
170
+ if code != 0 or not staging.is_file():
171
+ raise ProtosMissing(f"protoc could not compile the bundle at {self.root} (exit {code})")
172
+ staging.replace(out)
173
+ return out.read_bytes()
174
+
175
+ @staticmethod
176
+ def _pool(data: bytes) -> Any:
177
+ from google.protobuf import descriptor_pb2, descriptor_pool
178
+
179
+ pool = descriptor_pool.DescriptorPool()
180
+ for proto in descriptor_pb2.FileDescriptorSet.FromString(data).file:
181
+ pool.Add(proto)
182
+ return pool
183
+
184
+ def message(self, full_name: str) -> type:
185
+ cls = self._classes.get(full_name)
186
+ if cls is None:
187
+ from google.protobuf import message_factory
188
+
189
+ cls = self._classes[full_name] = message_factory.GetMessageClass(self.pool.FindMessageTypeByName(full_name))
190
+ return cls
191
+
192
+ def method(self, name: str) -> RpcMethod:
193
+ """`"package.Service/Method"` as a path and its message classes."""
194
+ found = self._methods.get(name)
195
+ if found is None:
196
+ service, _, rpc = name.partition("/")
197
+ descriptor = self.pool.FindServiceByName(service).FindMethodByName(rpc)
198
+ found = self._methods[name] = RpcMethod(
199
+ path=f"/{service}/{rpc}",
200
+ request=self.message(descriptor.input_type.full_name),
201
+ response=self.message(descriptor.output_type.full_name),
202
+ )
203
+ return found
204
+
205
+ @staticmethod
206
+ def to_dict(message: Any) -> dict[str, Any]:
207
+ from google.protobuf import json_format
208
+
209
+ return json_format.MessageToDict(message, always_print_fields_with_no_presence=True)
210
+
211
+ @staticmethod
212
+ def from_dict(cls: type, data: dict[str, Any]) -> Any:
213
+ from google.protobuf import json_format
214
+
215
+ return json_format.ParseDict(data, cls())
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Helpers
220
+ # ---------------------------------------------------------------------------
221
+
222
+ class RecentIds:
223
+ """The last `capacity` keys seen, for at-least-once streams."""
224
+
225
+ def __init__(self, capacity: int = 50_000):
226
+ self.capacity = capacity
227
+ self._keys: OrderedDict[Any, None] = OrderedDict()
228
+
229
+ def seen(self, key: Any) -> bool:
230
+ """True if `key` was already seen; records it either way."""
231
+ if key in self._keys:
232
+ self._keys.move_to_end(key)
233
+ return True
234
+ self._keys[key] = None
235
+ if len(self._keys) > self.capacity:
236
+ self._keys.popitem(last=False)
237
+ return False
238
+
239
+ def __len__(self) -> int:
240
+ return len(self._keys)
241
+
242
+
243
+ def status_code(exc: BaseException) -> str | None:
244
+ """The gRPC status name of an error, if it is an RPC error."""
245
+ code = getattr(exc, "code", None)
246
+ if not callable(code):
247
+ return None
248
+ try:
249
+ value = code()
250
+ except Exception:
251
+ return None
252
+ return getattr(value, "name", None) or (str(value) if value is not None else None)
253
+
254
+
255
+ def status_details(exc: BaseException) -> str:
256
+ details = getattr(exc, "details", None)
257
+ if callable(details):
258
+ try:
259
+ return str(details() or "")
260
+ except Exception:
261
+ return ""
262
+ return str(exc)
263
+
264
+
265
+ class IdleTimeout(Exception):
266
+ pass
267
+
268
+
269
+ class StreamEnded(Exception):
270
+ pass
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Stream
275
+ # ---------------------------------------------------------------------------
276
+
277
+ class GrpcStream(Stream):
278
+ """One server-streaming RPC, kept alive, turned into events.
279
+
280
+ Subclasses name the RPC (`method`), build the request (`request()`,
281
+ rebuilt for every call so a resume point or a new symbol set goes out),
282
+ give the call's metadata (`metadata()`), may do asynchronous work before
283
+ reading a message (`prepare()`), and read it (`handle()`, on the message
284
+ as a dict). `resubscribe()` ends the current call and opens a new one.
285
+ """
286
+
287
+ method: str = ""
288
+
289
+ def __init__(
290
+ self,
291
+ target: str,
292
+ *,
293
+ protos: ProtoBundle | None = None,
294
+ call: CallOpener | None = None,
295
+ keepalive_s: float = 300.0,
296
+ insecure: bool = False,
297
+ **kwargs: Any,
298
+ ):
299
+ super().__init__(target, **kwargs)
300
+ self.insecure = insecure
301
+ """Plaintext, for a local test server only."""
302
+ self.protos = protos
303
+ self._call_opener = call
304
+ self.keepalive_s = keepalive_s
305
+ self._channel: Any = None
306
+ self._grpc_call: Any = None
307
+ self._wake = asyncio.Event()
308
+ self._resubscribe = asyncio.Event()
309
+ self.last_status: str | None = None
310
+
311
+ # -- venue hooks ----------------------------------------------------------
312
+
313
+ def request(self) -> dict[str, Any] | None:
314
+ """The request for the next call; `None` waits for `resubscribe()`."""
315
+ return {}
316
+
317
+ async def metadata(self) -> list[tuple[str, str]]:
318
+ return []
319
+
320
+ async def before_call(self, request: dict[str, Any]) -> dict[str, Any] | None:
321
+ """Asynchronous work before a call opens; may return a changed request,
322
+ or `None` to open none and wait for `resubscribe()`."""
323
+ return request
324
+
325
+ async def prepare(self, message: dict[str, Any]) -> None:
326
+ """Asynchronous work before `handle` reads a message (loading scales)."""
327
+
328
+ def reconcile_on_reconnect(self) -> bool:
329
+ return self.private
330
+
331
+ def classify(self, code: str | None) -> Outcome:
332
+ if code == "UNAUTHENTICATED":
333
+ return "reauthenticate"
334
+ if code == "RESOURCE_EXHAUSTED":
335
+ return "slow_down"
336
+ if code in FATAL_CODES:
337
+ return "fatal"
338
+ return "retry"
339
+
340
+ async def on_reauthenticate(self) -> None:
341
+ """Forget the access token so the next call gets a fresh one."""
342
+
343
+ # -- lifecycle ------------------------------------------------------------
344
+
345
+ def resubscribe(self) -> None:
346
+ """Open the call again with a fresh `request()`."""
347
+ self._resubscribe.set()
348
+ self._wake.set()
349
+
350
+ async def send(self, frame: Any) -> bool: # pragma: no cover - server streams take no frames
351
+ raise NotImplementedError("a server-streaming RPC takes no frames after its request")
352
+
353
+ async def close(self) -> None:
354
+ await super().close()
355
+ channel, self._channel = self._channel, None
356
+ if channel is not None:
357
+ try:
358
+ await channel.close()
359
+ except Exception: # pragma: no cover - closing a broken channel
360
+ pass
361
+
362
+ async def _open_call(self, request: dict[str, Any], metadata: list[tuple[str, str]]) -> AsyncIterator[Any]:
363
+ if self._call_opener is not None:
364
+ return await self._call_opener(self.method, request, metadata)
365
+ if self.protos is None:
366
+ raise ProtosMissing("no protos loaded")
367
+ import grpc
368
+
369
+ if self._channel is None:
370
+ options = [
371
+ ("grpc.keepalive_time_ms", int(self.keepalive_s * 1000)),
372
+ ("grpc.keepalive_timeout_ms", 20_000),
373
+ ("grpc.max_receive_message_length", 64 * 1024 * 1024),
374
+ ]
375
+ if self.insecure:
376
+ self._channel = grpc.aio.insecure_channel(self.url, options=options)
377
+ else:
378
+ self._channel = grpc.aio.secure_channel(self.url, grpc.ssl_channel_credentials(), options=options)
379
+ rpc = self.protos.method(self.method)
380
+ multi = self._channel.unary_stream(
381
+ rpc.path, request_serializer=rpc.request.SerializeToString, response_deserializer=rpc.response.FromString,
382
+ )
383
+ grpc_call = multi(self.protos.from_dict(rpc.request, request), metadata=metadata)
384
+ self._grpc_call = grpc_call
385
+ return self._responses(grpc_call)
386
+
387
+ async def _responses(self, grpc_call: Any) -> AsyncIterator[Any]:
388
+ try:
389
+ await grpc_call.initial_metadata()
390
+ # A call refused outright (bad token, too many streams) is already
391
+ # finished when its headers resolve: that is not a connection.
392
+ if not grpc_call.done():
393
+ yield READY
394
+ async for response in grpc_call:
395
+ yield response
396
+ finally:
397
+ grpc_call.cancel()
398
+
399
+ def _to_dict(self, response: Any) -> dict[str, Any]:
400
+ if isinstance(response, dict):
401
+ return response
402
+ if self.protos is not None:
403
+ return self.protos.to_dict(response)
404
+ from google.protobuf import json_format # pragma: no cover - a message without a bundle
405
+
406
+ return json_format.MessageToDict(response, always_print_fields_with_no_presence=True)
407
+
408
+ async def _consume(self, responses: AsyncIterator[Any], on_ready: Callable[[], None]) -> None:
409
+ iterator = responses.__aiter__()
410
+ while True:
411
+ try:
412
+ if self.idle_timeout:
413
+ item = await asyncio.wait_for(iterator.__anext__(), timeout=self.idle_timeout)
414
+ else:
415
+ item = await iterator.__anext__()
416
+ except StopAsyncIteration:
417
+ raise StreamEnded("the venue ended the stream") from None
418
+ except asyncio.TimeoutError:
419
+ raise IdleTimeout(f"no message for {self.idle_timeout}s") from None
420
+ on_ready()
421
+ self._last_seen = now_ms()
422
+ if item is READY:
423
+ continue
424
+ self.stats.messages += 1
425
+ self.stats.last_message_at = now_ms()
426
+ try:
427
+ message = self._to_dict(item)
428
+ await self.prepare(message)
429
+ events = self.handle(message)
430
+ except Exception as exc:
431
+ log.debug("synpath.ws %s %s: unreadable message %r", self.venue, self.name, item, exc_info=True)
432
+ self.status("error", f"unreadable message: {type(exc).__name__}: {exc}")
433
+ continue
434
+ for event in events:
435
+ self.emit(event)
436
+
437
+ def _fail(self, detail: str) -> None:
438
+ self._closing = True
439
+ self.status("failed", detail)
440
+ self._queue.put_nowait(_CLOSED)
441
+
442
+ async def _run(self) -> None:
443
+ attempt = 0
444
+ while not self._closing:
445
+ self._resubscribe.clear()
446
+ request = self.request()
447
+ if request is not None:
448
+ try:
449
+ request = await self.before_call(request)
450
+ except asyncio.CancelledError:
451
+ raise
452
+ except Exception as exc:
453
+ self.status("connect_failed", f"{type(exc).__name__}: {exc}")
454
+ await self._sleep(self.backoff(attempt))
455
+ attempt += 1
456
+ continue
457
+ if request is None:
458
+ self._wake.clear()
459
+ if not self._resubscribe.is_set():
460
+ await self._wake.wait()
461
+ continue
462
+ try:
463
+ metadata = await self.metadata()
464
+ responses = await self._open_call(request, metadata)
465
+ except asyncio.CancelledError:
466
+ raise
467
+ except Exception as exc:
468
+ code = status_code(exc)
469
+ self.last_status = code
470
+ self.status("connect_failed", f"{code or type(exc).__name__}: {status_details(exc) if code else exc}")
471
+ if await self._after_failure(code, f"{code}: {status_details(exc)}", attempt):
472
+ return
473
+ attempt += 1
474
+ continue
475
+
476
+ confirmed = False
477
+
478
+ def ready() -> None:
479
+ nonlocal confirmed, attempt
480
+ if confirmed:
481
+ return
482
+ confirmed = True
483
+ attempt = 0
484
+ reconnect = self.stats.connects > 0
485
+ self.stats.connects += 1
486
+ self.connected.set()
487
+ self.status("connected", "reconnected" if reconnect else "", reconcile=reconnect and self.reconcile_on_reconnect())
488
+
489
+ self._last_seen, self._stale = now_ms(), ""
490
+ loop = asyncio.get_running_loop()
491
+ reader = loop.create_task(self._consume(responses, ready))
492
+ waker = loop.create_task(self._resubscribe.wait())
493
+ watching = loop.create_task(self._stale_after_sleep()) if self.idle_timeout else None
494
+ watched = {reader, waker} | ({watching} if watching else set())
495
+ code: str | None = None
496
+ reason = "closed"
497
+ resubscribing = False
498
+ try:
499
+ done, _ = await asyncio.wait(watched, return_when=asyncio.FIRST_COMPLETED)
500
+ if reader in done:
501
+ exc = reader.exception()
502
+ code = status_code(exc) if exc else None
503
+ reason = f"{code}: {status_details(exc)}" if code else f"{type(exc).__name__}: {exc}" if exc else "ended"
504
+ elif watching is not None and watching in done:
505
+ reason = self._stale
506
+ else:
507
+ resubscribing = True
508
+ reason = "resubscribing"
509
+ finally:
510
+ for task in (reader, waker, watching):
511
+ if task is not None and not task.done():
512
+ task.cancel()
513
+ try:
514
+ await task
515
+ except (asyncio.CancelledError, Exception):
516
+ pass
517
+ aclose = getattr(responses, "aclose", None)
518
+ if aclose is not None:
519
+ try:
520
+ await aclose()
521
+ except (asyncio.CancelledError, Exception):
522
+ pass
523
+ self._grpc_call = None
524
+ if confirmed:
525
+ self.connected.clear()
526
+ self.stats.disconnects += 1
527
+ self.on_disconnect()
528
+ if self._closing:
529
+ return
530
+ self.last_status = code
531
+ # A call the venue refused before answering was never a connection,
532
+ # and one replaced before it answered was neither.
533
+ if confirmed:
534
+ self.status("disconnected", reason)
535
+ elif not resubscribing:
536
+ self.status("connect_failed", reason)
537
+ if resubscribing:
538
+ continue
539
+ if await self._after_failure(code, reason, attempt):
540
+ return
541
+ if not confirmed:
542
+ attempt += 1
543
+
544
+ async def _after_failure(self, code: str | None, reason: str, attempt: int) -> bool:
545
+ """Wait as the failure deserves; `True` when the stream must stop."""
546
+ outcome = self.classify(code)
547
+ if outcome == "fatal":
548
+ self._fail(reason)
549
+ return True
550
+ if outcome == "reauthenticate":
551
+ await self.on_reauthenticate()
552
+ await self._sleep(self.backoff(attempt) if attempt else 0)
553
+ elif outcome == "slow_down":
554
+ await self._sleep(self.backoff_max)
555
+ else:
556
+ await self._sleep(self.backoff(attempt))
557
+ return False
558
+
559
+ async def _stale_after_sleep(self) -> None:
560
+ """Wall-clock silence, for the same reason as the WebSocket streams'
561
+ watchdog: a suspended machine freezes the event loop's timers while
562
+ the connection dies underneath them."""
563
+ step = max(1.0, (self.idle_timeout or 0) / 4)
564
+ while True:
565
+ await asyncio.sleep(step)
566
+ silent = (now_ms() - self._last_seen) / 1000
567
+ if self.idle_timeout and silent > self.idle_timeout:
568
+ self._stale = f"no message for {round(silent)}s (wall clock)"
569
+ return
570
+
571
+ async def _sleep(self, seconds: float) -> None:
572
+ """Back off, cut short by `resubscribe()`."""
573
+ if seconds <= 0:
574
+ return
575
+ try:
576
+ await asyncio.wait_for(self._resubscribe.wait(), timeout=seconds)
577
+ except asyncio.TimeoutError:
578
+ pass