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/base.py ADDED
@@ -0,0 +1,723 @@
1
+ """The exchange interface, plus the HTTP plumbing every adapter shares.
2
+
3
+ Deliberate split, and the whole file is arranged around it:
4
+
5
+ * **Pure normalizers** live in each venue module as free functions taking a
6
+ raw payload and returning a unified type. They touch no network, so they
7
+ are testable against a recorded payload and portable to another runtime.
8
+ * **The client** does HTTP, pacing and error mapping, and nothing else.
9
+
10
+ Adapters compose the two. Nothing in this package reaches across venues — a
11
+ cross-venue concern (matching the same question on two exchanges, routing an
12
+ order) belongs a layer up, never in an adapter.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ import time
18
+ from abc import ABC, abstractmethod
19
+ from typing import Any, Callable, Literal
20
+
21
+ import httpx
22
+
23
+ from .errors import (
24
+ AuthenticationError,
25
+ BadRequest,
26
+ ExchangeError,
27
+ ExchangeNotAvailable,
28
+ MarketNotFound,
29
+ NetworkError,
30
+ NotSupported,
31
+ RateLimitExceeded,
32
+ RequestTimeout,
33
+ )
34
+ from . import ids
35
+ from .types import BookSide, Candle, Event, FeeSchedule, Market, OrderBook, Series, Trade
36
+
37
+ Capability = Literal[True, False, "partial"]
38
+ """What a venue can do, in the shape ccxt's `has` uses.
39
+
40
+ True — supported; the method's docstring says how the venue answers it
41
+ "partial" — supported, but the result is thinner than the type suggests;
42
+ the method's docstring says exactly how
43
+ False — not available; calling raises `NotSupported`
44
+ """
45
+
46
+ CAPABILITIES: tuple[str, ...] = (
47
+ # -- read --
48
+ "fetch_markets",
49
+ "fetch_market",
50
+ "fetch_markets_by_ids",
51
+ "fetch_events",
52
+ "sort",
53
+ "fetch_order_book",
54
+ "fetch_order_books",
55
+ "fetch_trades",
56
+ "fetch_ohlcv",
57
+ "fetch_series",
58
+ "fetch_fee_schedule",
59
+ "search",
60
+ "watch_order_book",
61
+ "watch_ticker",
62
+ "watch_trades",
63
+ "watch_market_status",
64
+ "match_market",
65
+ "match_event",
66
+ # -- trade: what the venue itself holds and answers. Anything the
67
+ # execution engine builds on top (a stop, an iceberg, a bracket) is the
68
+ # engine's capability, reported by the engine, never claimed here. --
69
+ "create_order",
70
+ "create_orders",
71
+ "cancel_order",
72
+ "cancel_orders",
73
+ "cancel_all_orders",
74
+ "edit_order",
75
+ "fetch_order",
76
+ "fetch_open_orders",
77
+ "fetch_orders",
78
+ "fetch_my_trades",
79
+ "fetch_positions",
80
+ "fetch_balance",
81
+ "fetch_settlements",
82
+ "fetch_queue_position",
83
+ "fetch_fee_estimate",
84
+ "rfq",
85
+ "split_merge",
86
+ "watch_orders",
87
+ "watch_my_trades",
88
+ "watch_positions",
89
+ "watch_balance",
90
+ )
91
+ """Every question `has` can be asked, for every venue.
92
+
93
+ This list is the single place a capability is named. `Exchange.__init_subclass__`
94
+ fills a venue's `has` from it, so a venue that says nothing about a capability
95
+ gets `False` rather than a hole. `has[key]` therefore never raises KeyError on
96
+ any venue — which matters, because the whole point of `has` is to be safe to
97
+ read *before* you know whether something is supported.
98
+
99
+ The read-only adapters answer `False` to every trading key; a trading
100
+ adapter for the same venue subclasses one and says what it adds.
101
+ """
102
+
103
+ LEGAL_CAPABILITY_VALUES = frozenset({True, False, "partial"})
104
+
105
+ MARKET_STATUSES: tuple[str, ...] = ("open", "closed", "settled", "all")
106
+ """The status words every venue understands, with one meaning each.
107
+
108
+ open — listed and trading
109
+ closed — trading stopped, outcome not yet final
110
+ settled — outcome final and paid
111
+ all — no filter
112
+
113
+ A venue that cannot filter by one of these raises `NotSupported` for it. What
114
+ it must not do is accept the word and quietly answer a different question:
115
+ `status="settled"` returning live markets on one venue and settled ones on
116
+ another is the kind of disagreement a unified API exists to remove.
117
+ """
118
+
119
+ MARKET_SORTS: tuple[str, ...] = ("volume", "liquidity", "newest")
120
+ """How a page of markets can be ordered.
121
+
122
+ Polymarket orders at the venue. Kalshi and Polymarket US accept a sort
123
+ parameter and ignore it, so on those the page is ordered here after it is
124
+ read, the way ccxt and pmxt do it: `sort` then means "this page, ordered by",
125
+ not "the first page of the whole catalog ordered by". Each adapter's
126
+ `fetch_markets` docstring says which venue figure the key reads.
127
+ """
128
+
129
+ MAX_PAGE_LIMIT = 100
130
+ """Rows one catalog call can return, on every venue.
131
+
132
+ Uniform rather than per-venue. Polymarket's keyset endpoint hard-caps at 100 no
133
+ matter what is asked, so a higher ceiling elsewhere would mean `limit=500`
134
+ returning 500 rows on one venue and 100 on another, with nothing saying it had
135
+ been reduced. Ask for more than this and it is clamped; the cursor is how you
136
+ get the rest.
137
+ """
138
+
139
+ MAX_RETRY_WAIT = 10.0
140
+ """Longest this library will sleep inside one call, in seconds.
141
+
142
+ A venue's `Retry-After` is a hint, not an instruction. Honouring a large one
143
+ literally would let the venue park a caller's thread for as long as it likes --
144
+ and in the server that thread belongs to a pool shared with every other
145
+ request, so a handful of rate-limited calls would take the whole service down.
146
+ The header's real value is still on the raised `RateLimitExceeded`, so a caller
147
+ that wants to wait longer can decide that for itself.
148
+ """
149
+
150
+
151
+ class RateLimiter:
152
+ """Token bucket, shared by every client for one venue in the process.
153
+
154
+ Shared on purpose. A venue counts requests per account and per IP, not per
155
+ client object, so six loops each politely under the limit still add up to
156
+ being over it. Making the process rather than the object the thing that
157
+ stays inside the budget is the only version that works.
158
+ """
159
+
160
+ def __init__(self, rate_per_second: float, burst: int | None = None):
161
+ self.rate = rate_per_second
162
+ self.capacity = float(burst if burst is not None else max(1, int(rate_per_second)))
163
+ self._tokens = self.capacity
164
+ self._updated = time.monotonic()
165
+ self._lock = threading.Lock()
166
+
167
+ def _take_or_wait(self) -> float:
168
+ """Take a token if one is there, else say how long until one is.
169
+
170
+ The bookkeeping under the lock, shared by the blocking and the async
171
+ paths so the two cannot drift apart on how a bucket refills.
172
+ """
173
+ with self._lock:
174
+ now = time.monotonic()
175
+ self._tokens = min(self.capacity, self._tokens + (now - self._updated) * self.rate)
176
+ self._updated = now
177
+ if self._tokens >= 1:
178
+ self._tokens -= 1
179
+ return 0.0
180
+ return (1 - self._tokens) / self.rate
181
+
182
+ def acquire(self) -> None:
183
+ while True:
184
+ wait = self._take_or_wait()
185
+ if wait == 0.0:
186
+ return
187
+ time.sleep(wait) # outside the lock, so waiters do not serialise on it
188
+
189
+ async def acquire_async(self) -> None:
190
+ """The same budget from an event loop, without blocking it.
191
+
192
+ The trading stack is async, and a `time.sleep` inside the loop would
193
+ stall every socket the engine holds for the length of the wait -- the
194
+ exact moment a stop needs to fire. Same bucket, same arithmetic, an
195
+ `await` instead of a block.
196
+ """
197
+ import asyncio
198
+
199
+ while True:
200
+ wait = self._take_or_wait()
201
+ if wait == 0.0:
202
+ return
203
+ await asyncio.sleep(wait)
204
+
205
+
206
+ def venue_reason(parsed: Any, text: str) -> str:
207
+ """The venue's own reason, in words, from the error payloads venues send:
208
+ `{"error": {"code", "message", "details"}}` (Kalshi), `{"error": "..."}`
209
+ (Polymarket's CLOB), or a top-level `message` / `detail`. Falls back to
210
+ the raw text, shortened."""
211
+ if isinstance(parsed, dict):
212
+ error = parsed.get("error", parsed.get("errors"))
213
+ if isinstance(error, dict):
214
+ message = error.get("message") or error.get("msg") or error.get("code")
215
+ details = error.get("details") or error.get("detail")
216
+ if message:
217
+ if isinstance(details, str) and details and details != message:
218
+ return f"{message} ({details})"
219
+ return str(message)
220
+ if isinstance(error, str) and error:
221
+ return error
222
+ for key in ("message", "errorMsg", "error_message", "detail", "msg", "reason"):
223
+ value = parsed.get(key)
224
+ if isinstance(value, str) and value:
225
+ return value
226
+ text = " ".join((text or "").split())
227
+ return text[:200]
228
+
229
+
230
+ def _map_status(response: httpx.Response, label: str) -> None:
231
+ """Turn a non-2xx response into the typed error a caller can branch on.
232
+ The message is `<venue>: <the venue's reason>`; the raw payload and the
233
+ status stay on the exception as `body` and `status`."""
234
+ code = response.status_code
235
+ if code < 400:
236
+ return
237
+ if code == 429:
238
+ retry = response.headers.get("retry-after")
239
+ raise RateLimitExceeded(
240
+ f"{label}: rate limited",
241
+ retry_after=float(retry) if retry and retry.replace(".", "", 1).isdigit() else None,
242
+ )
243
+ parsed: Any = None
244
+ try:
245
+ parsed = response.json()
246
+ except ValueError:
247
+ parsed = None
248
+ reason = venue_reason(parsed, response.text)
249
+ if code == 404:
250
+ what = reason if "not found" in reason.lower() else (f"not found: {reason}" if reason else "not found")
251
+ raise MarketNotFound(f"{label}: {what}", body=parsed, status=code)
252
+ if code in (502, 503, 504):
253
+ raise ExchangeNotAvailable(f"{label}: unavailable ({code})", body=parsed, status=code)
254
+ if 500 <= code:
255
+ raise ExchangeNotAvailable(f"{label}: server error ({code}){': ' + reason if reason else ''}", body=parsed, status=code)
256
+ if code in (401, 403):
257
+ raise AuthenticationError(f"{label}: credentials refused ({code}){': ' + reason if reason else ''}", body=parsed, status=code)
258
+ if code in (400, 409, 422):
259
+ raise BadRequest(f"{label}: {reason or f'rejected ({code})'}", body=parsed, status=code)
260
+ raise ExchangeError(f"{label}: unexpected status {code}{': ' + reason if reason else ''}", body=parsed, status=code)
261
+
262
+
263
+ class HttpClient:
264
+ """Paced, retrying HTTP with venue errors mapped to this library's types."""
265
+
266
+ def __init__(
267
+ self,
268
+ base_url: str,
269
+ *,
270
+ limiter: RateLimiter | None,
271
+ timeout: float = 30.0,
272
+ client: httpx.Client | None = None,
273
+ attempts: int = 4,
274
+ venue: str | None = None,
275
+ ):
276
+ self.base_url = base_url.rstrip("/")
277
+ self.limiter = limiter
278
+ self.attempts = attempts
279
+ self.venue = venue
280
+ """Named at the front of every error this client raises, so a caller
281
+ reads `kalshi: insufficient balance`, not a class name and a path."""
282
+ self._client = client or httpx.Client(timeout=timeout, follow_redirects=True)
283
+
284
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
285
+ """One call, paced and retried on rate limits only.
286
+
287
+ A 429 is retried with doubling backoff because it clears on its own. A
288
+ 404 or a 400 is raised immediately: waiting will not make the request
289
+ valid, and quietly retrying it wastes the caller's budget.
290
+ """
291
+ url = path if path.startswith("http") else f"{self.base_url}{path}"
292
+ label = self.venue or f"{self.__class__.__name__} {method} {path}"
293
+ delay = 0.5
294
+ last: RateLimitExceeded | None = None
295
+ for attempt in range(self.attempts):
296
+ if self.limiter is not None:
297
+ self.limiter.acquire()
298
+ try:
299
+ response = self._client.request(method, url, **kwargs)
300
+ except httpx.TimeoutException as exc:
301
+ raise RequestTimeout(f"{label}: {exc}") from exc
302
+ except httpx.HTTPError as exc:
303
+ raise NetworkError(f"{label}: {exc}") from exc
304
+ try:
305
+ _map_status(response, label)
306
+ except RateLimitExceeded as exc:
307
+ last = exc
308
+ if attempt == self.attempts - 1:
309
+ break
310
+ time.sleep(min(exc.retry_after or delay, MAX_RETRY_WAIT))
311
+ delay *= 2
312
+ continue
313
+ return response.json()
314
+ assert last is not None
315
+ raise last
316
+
317
+ def get(self, path: str, params: Any = None) -> Any:
318
+ return self.request("GET", path, params=_clean(params))
319
+
320
+ def post(self, path: str, json: Any = None) -> Any:
321
+ return self.request("POST", path, json=json)
322
+
323
+ def close(self) -> None:
324
+ self._client.close()
325
+
326
+
327
+ class AsyncHttpClient:
328
+ """`HttpClient` for the async trading stack: same pacing, retries and error
329
+ mapping, on `httpx.AsyncClient`.
330
+
331
+ The two share `_map_status` and the retry rule so a status code cannot
332
+ mean one thing on the read path and another on the trading path. Kept
333
+ separate rather than made one class with two personalities: a method
334
+ that is sometimes a coroutine is the kind of interface that works until
335
+ it does not.
336
+ """
337
+
338
+ def __init__(
339
+ self,
340
+ base_url: str,
341
+ *,
342
+ limiter: RateLimiter | None,
343
+ timeout: float = 30.0,
344
+ client: httpx.AsyncClient | None = None,
345
+ attempts: int = 4,
346
+ venue: str | None = None,
347
+ headers: dict[str, str] | None = None,
348
+ ):
349
+ self.base_url = base_url.rstrip("/")
350
+ self.limiter = limiter
351
+ self.attempts = attempts
352
+ self.venue = venue
353
+ """Named at the front of every error this client raises, so a caller
354
+ reads `kalshi: insufficient balance`, not a class name and a path."""
355
+ self._client = client or httpx.AsyncClient(
356
+ timeout=timeout, follow_redirects=True, headers=headers,
357
+ )
358
+
359
+ async def request(self, method: str, path: str, **kwargs: Any) -> Any:
360
+ """One call, paced and retried on rate limits only. See `HttpClient.request`."""
361
+ import asyncio
362
+
363
+ url = path if path.startswith("http") else f"{self.base_url}{path}"
364
+ label = self.venue or f"{self.__class__.__name__} {method} {path}"
365
+ delay = 0.5
366
+ last: RateLimitExceeded | None = None
367
+ for attempt in range(self.attempts):
368
+ if self.limiter is not None:
369
+ await self.limiter.acquire_async()
370
+ try:
371
+ response = await self._client.request(method, url, **kwargs)
372
+ except httpx.TimeoutException as exc:
373
+ raise RequestTimeout(f"{label}: {exc}") from exc
374
+ except httpx.HTTPError as exc:
375
+ raise NetworkError(f"{label}: {exc}") from exc
376
+ try:
377
+ _map_status(response, label)
378
+ except RateLimitExceeded as exc:
379
+ last = exc
380
+ if attempt == self.attempts - 1:
381
+ break
382
+ await asyncio.sleep(min(exc.retry_after or delay, MAX_RETRY_WAIT))
383
+ delay *= 2
384
+ continue
385
+ if not response.content:
386
+ return None
387
+ return response.json()
388
+ assert last is not None
389
+ raise last
390
+
391
+ async def get(self, path: str, params: Any = None, **kwargs: Any) -> Any:
392
+ return await self.request("GET", path, params=_clean(params), **kwargs)
393
+
394
+ async def post(self, path: str, json: Any = None, **kwargs: Any) -> Any:
395
+ return await self.request("POST", path, json=json, **kwargs)
396
+
397
+ async def put(self, path: str, json: Any = None, **kwargs: Any) -> Any:
398
+ return await self.request("PUT", path, json=json, **kwargs)
399
+
400
+ async def delete(self, path: str, **kwargs: Any) -> Any:
401
+ return await self.request("DELETE", path, **kwargs)
402
+
403
+ async def close(self) -> None:
404
+ await self._client.aclose()
405
+
406
+ async def __aenter__(self):
407
+ return self
408
+
409
+ async def __aexit__(self, *exc: Any) -> None:
410
+ await self.close()
411
+
412
+
413
+ def check_status(status: str) -> str:
414
+ """Validate a status word against the shared vocabulary.
415
+
416
+ Raised rather than passed through, because every venue has its own status
417
+ names and forwarding an unrecognised one means each venue decides for
418
+ itself what it meant.
419
+ """
420
+ if status not in MARKET_STATUSES:
421
+ raise BadRequest(
422
+ f"unknown status {status!r}; expected one of {', '.join(MARKET_STATUSES)}"
423
+ )
424
+ return status
425
+
426
+
427
+ def check_sort(sort: str | None, *, venue: str, supported: bool) -> str | None:
428
+ """Validate a sort key, and refuse one the venue cannot honour."""
429
+ if sort is None:
430
+ return None
431
+ if sort not in MARKET_SORTS:
432
+ raise BadRequest(
433
+ f"unknown sort {sort!r}; expected one of {', '.join(MARKET_SORTS)}"
434
+ )
435
+ if not supported:
436
+ raise NotSupported(
437
+ f"{venue}: cannot sort a listing. Its catalog endpoint accepts a sort "
438
+ f"parameter and ignores it, so honouring this would mean returning "
439
+ f"unsorted rows as if they were sorted."
440
+ )
441
+ return sort
442
+
443
+
444
+ def page_limit(limit: int | None) -> int | None:
445
+ """Clamp a requested page size to what every venue can actually serve."""
446
+ return None if limit is None else max(1, min(limit, MAX_PAGE_LIMIT))
447
+
448
+
449
+ def sort_page(items: list, key: Callable[[Any], float | None]) -> list:
450
+ """Order one page by a numeric key, largest first, rows without the figure last.
451
+
452
+ Stable, so rows that tie keep the venue's order. A row whose figure the
453
+ venue did not publish goes to the end rather than being read as zero,
454
+ which would rank an unreported market below a genuinely empty one.
455
+ """
456
+ return sorted(items, key=lambda item: (key(item) is None, -(key(item) or 0.0)))
457
+
458
+
459
+ def _clean(params: Any) -> Any:
460
+ """Drop `None` values so an unset optional never becomes the string "None".
461
+
462
+ Accepts a list of pairs as well as a mapping, because a repeated parameter
463
+ (`?id=1&id=2`) cannot be expressed as a dict and Gamma's batch lookup wants
464
+ exactly that.
465
+ """
466
+ if params is None:
467
+ return None
468
+ if isinstance(params, dict):
469
+ return {key: value for key, value in params.items() if value is not None}
470
+ return [(key, value) for key, value in params if value is not None]
471
+
472
+
473
+ def complete_capabilities(cls: type) -> None:
474
+ """Fill `cls.has` from `CAPABILITIES`, refusing typos and illegal values.
475
+
476
+ Shared by the read adapters and the trading adapters, so both answer
477
+ every question the same way. See `Exchange.__init_subclass__`.
478
+ """
479
+ declared = cls.__dict__.get("has", {})
480
+ if not isinstance(declared, dict):
481
+ raise TypeError(f"{cls.__name__}.has must be a dict, got {type(declared).__name__}")
482
+
483
+ unknown = sorted(set(declared) - set(CAPABILITIES))
484
+ if unknown:
485
+ raise TypeError(
486
+ f"{cls.__name__}.has declares unknown {'capabilities' if len(unknown) > 1 else 'capability'} "
487
+ f"{unknown}. Known: {sorted(CAPABILITIES)}. "
488
+ f"Add it to synpath.base.CAPABILITIES if it is real, or fix the spelling."
489
+ )
490
+ illegal = {
491
+ key: value for key, value in declared.items()
492
+ if value not in LEGAL_CAPABILITY_VALUES
493
+ }
494
+ if illegal:
495
+ raise TypeError(
496
+ f"{cls.__name__}.has has illegal values {illegal}; "
497
+ f"each must be True, False or 'partial'."
498
+ )
499
+
500
+ # Start from the nearest ancestor that declared one, so subclassing an
501
+ # adapter (a sandbox or demo venue) inherits rather than resets.
502
+ inherited: dict[str, Capability] = {}
503
+ for parent in cls.__mro__[1:]:
504
+ parent_has = parent.__dict__.get("has")
505
+ if isinstance(parent_has, dict) and parent_has:
506
+ inherited = parent_has
507
+ break
508
+ cls.has = {
509
+ key: declared.get(key, inherited.get(key, False)) for key in CAPABILITIES
510
+ }
511
+
512
+
513
+ class Exchange(ABC):
514
+ """What every venue adapter offers.
515
+
516
+ Methods are named after ccxt's so the reflexes transfer, with the tiers
517
+ prediction markets have and spot exchanges do not (`fetch_events`,
518
+ `fetch_series`) added alongside.
519
+
520
+ Check `has` before calling. A capability the venue lacks raises
521
+ `NotSupported` rather than returning an empty list, so "this venue cannot"
522
+ is never mistaken for "there is nothing".
523
+ """
524
+
525
+ id: str
526
+ name: str
527
+ has: dict[str, Capability] = {}
528
+ """What this venue can do. Always complete: see `__init_subclass__`.
529
+
530
+ A subclass declares only what differs from "no". Everything in
531
+ `CAPABILITIES` it stays silent about is filled in as `False`.
532
+ """
533
+ face_value: float = 1.0
534
+ book_model: str = "native_per_outcome"
535
+
536
+ def __init_subclass__(cls, **kwargs: Any) -> None:
537
+ """Complete the venue's capability map, and reject a broken one.
538
+
539
+ Three failures are made impossible here rather than left to review:
540
+
541
+ * **A gap.** Anything in `CAPABILITIES` the subclass did not mention
542
+ becomes `False`. A caller can read any capability on any venue.
543
+ * **A typo.** `fetch_orderbooks` for `fetch_order_books` used to be a
544
+ silent no-op that left the real key defaulting to `False` — a venue
545
+ quietly advertising that it cannot do something it can. It now fails
546
+ at import.
547
+ * **A nonsense value.** `"yes"` or `1` instead of a real capability
548
+ value fails at import too, so the server never serializes one.
549
+
550
+ Import time is the right moment for all three: the venue is broken for
551
+ every caller, so it should not be constructible at all.
552
+ """
553
+ super().__init_subclass__(**kwargs)
554
+ complete_capabilities(cls)
555
+
556
+ # -- catalog --------------------------------------------------------------
557
+
558
+ @abstractmethod
559
+ def fetch_markets(
560
+ self,
561
+ *,
562
+ query: str | None = None,
563
+ limit: int | None = None,
564
+ cursor: str | None = None,
565
+ status: str = "open",
566
+ sort: str | None = None,
567
+ ) -> list[Market]:
568
+ """One page of markets. `cursor` continues a previous page.
569
+
570
+ Paging is cursor-based on both venues and offset is deliberately not
571
+ offered: the underlying set changes between calls, and offset paging
572
+ silently drops or repeats rows when it does.
573
+
574
+ `sort` is one of `MARKET_SORTS` on a venue whose `has["sort"]` is true,
575
+ and raises otherwise.
576
+ """
577
+
578
+ def fetch_markets_by_ids(self, ids: list[str]) -> list[Market]:
579
+ """Many markets in one request, in the order asked for.
580
+
581
+ Both venues answer a batch of ids in one call, which is the difference
582
+ between one request and one per market when refreshing prices: 40
583
+ markets measured at 0.2s batched against 11.6s one at a time. Ids the
584
+ venue no longer serves are left out rather than raising -- a market
585
+ closing is normal, and deciding what that means is the caller's.
586
+ """
587
+ raise NotSupported(f"{self.id}: fetch_markets_by_ids")
588
+
589
+ @abstractmethod
590
+ def fetch_events(
591
+ self,
592
+ *,
593
+ query: str | None = None,
594
+ limit: int | None = None,
595
+ cursor: str | None = None,
596
+ status: str = "open",
597
+ ) -> list[Event]:
598
+ """Events with their markets nested."""
599
+
600
+ def fetch_market(self, market_id: str) -> Market:
601
+ """One market by id."""
602
+ raise NotSupported(f"{self.id}: fetch_market")
603
+
604
+ # -- market data ----------------------------------------------------------
605
+
606
+ @abstractmethod
607
+ def fetch_order_book(
608
+ self, market_id: str, *, side: BookSide = "yes", depth: int | None = None,
609
+ ) -> OrderBook:
610
+ """Resting orders on one side of a market, priced as that side sees
611
+ them. `side="no"` is what NO costs; on a `shared_complement` venue that
612
+ is the YES book mirrored and the result says so in `derived`."""
613
+
614
+ def fetch_order_books(
615
+ self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None,
616
+ ) -> dict[str, OrderBook]:
617
+ """Books for many markets, keyed by Synpath market id.
618
+
619
+ One round trip on a venue with a batch endpoint, one request per
620
+ market on a venue without; the docstring on each adapter says which,
621
+ because the difference is a shared rate budget.
622
+ """
623
+ raise NotSupported(f"{self.id}: fetch_order_books")
624
+
625
+ @abstractmethod
626
+ def fetch_trades(
627
+ self, market_id: str, *, since: int | None = None, limit: int | None = None,
628
+ cursor: str | None = None,
629
+ ) -> list[Trade]:
630
+ """Executions, oldest first. `since` is milliseconds since epoch."""
631
+
632
+ def fetch_ohlcv(
633
+ self,
634
+ market_id: str,
635
+ *,
636
+ timeframe: str = "1h",
637
+ since: int | None = None,
638
+ until: int | None = None,
639
+ limit: int | None = None,
640
+ ) -> list[Candle]:
641
+ """Candles in the YES price, oldest first. Read `Candle.price_source`
642
+ before using them."""
643
+ raise NotSupported(f"{self.id}: fetch_ohlcv")
644
+
645
+ # -- ids ------------------------------------------------------------------
646
+
647
+ def qualify(self, native_id: str) -> str:
648
+ """This venue's Synpath id for a native id: `venue:native`."""
649
+ return ids.qualify(self.id, native_id)
650
+
651
+ def native(self, market_id: str) -> str:
652
+ """The venue's own id from a Synpath id or a bare native id. An id
653
+ naming another venue is refused."""
654
+ return ids.native(self.id, market_id)
655
+
656
+ # -- reference ------------------------------------------------------------
657
+
658
+ def fetch_series(self, series_id: str) -> Series:
659
+ """One series, with its fee schedule where the venue publishes one."""
660
+ raise NotSupported(f"{self.id}: fetch_series")
661
+
662
+ def fetch_fee_schedule(self, market_id: str) -> FeeSchedule:
663
+ """What trading this market costs, before trading it."""
664
+ raise NotSupported(f"{self.id}: fetch_fee_schedule")
665
+
666
+ # -- housekeeping ---------------------------------------------------------
667
+
668
+ def close(self) -> None:
669
+ pass
670
+
671
+ def __enter__(self):
672
+ return self
673
+
674
+ def __exit__(self, *exc: Any) -> None:
675
+ self.close()
676
+
677
+ def __repr__(self) -> str:
678
+ return f"<{type(self).__name__} {self.id}>"
679
+
680
+
681
+ Exchange.has = {key: False for key in CAPABILITIES}
682
+
683
+ _UNDECLARED = {
684
+ name for name in vars(Exchange)
685
+ if name.startswith("fetch_") and name not in CAPABILITIES
686
+ }
687
+ if _UNDECLARED: # pragma: no cover - a development-time guard
688
+ raise TypeError(
689
+ f"Exchange defines {sorted(_UNDECLARED)} but CAPABILITIES does not name "
690
+ f"them, so no venue could advertise them. Add them to CAPABILITIES."
691
+ )
692
+
693
+
694
+ TIMEFRAME_SECONDS: dict[str, int] = {
695
+ "1m": 60, "5m": 300, "15m": 900, "30m": 1800,
696
+ "1h": 3600, "4h": 14400, "6h": 21600, "1d": 86400,
697
+ }
698
+
699
+
700
+ def timeframe_seconds(timeframe: str) -> int:
701
+ try:
702
+ return TIMEFRAME_SECONDS[timeframe]
703
+ except KeyError:
704
+ raise BadRequest(
705
+ f"unknown timeframe {timeframe!r}; expected one of {', '.join(TIMEFRAME_SECONDS)}"
706
+ ) from None
707
+
708
+
709
+ def enough_bars(candles: list[Any], *, since: int | None, limit: int | None, strict: bool = False) -> bool:
710
+ """Whether a forward read from `since` can stop fetching: it has `limit`
711
+ bars. `strict` asks for one more, for bars bucketed from samples, where
712
+ the last bar is only whole once a later one has started."""
713
+ if since is None or not limit:
714
+ return False
715
+ return len(candles) > limit if strict else len(candles) >= limit
716
+
717
+
718
+ def pick_bars(candles: list[Any], *, since: int | None, limit: int | None) -> list[Any]:
719
+ """Which `limit` bars a call returns: the first ones from `since` when it
720
+ is given (a forward read, as ccxt does), the newest ones otherwise."""
721
+ if not limit:
722
+ return candles
723
+ return candles[:limit] if since is not None else candles[-limit:]