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/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:]
|