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/polymarket_us.py
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
"""Polymarket US: the CFTC-regulated exchange, through its public gateway.
|
|
2
|
+
|
|
3
|
+
Public reads, no credentials.
|
|
4
|
+
|
|
5
|
+
It shares a brand with Polymarket and nothing else. This is a conventional
|
|
6
|
+
exchange -- one order book per market, fiat settlement, a matching engine with
|
|
7
|
+
a state machine -- rather than the on-chain CLOB the `polymarket` adapter
|
|
8
|
+
talks to. Four facts shape the adapter:
|
|
9
|
+
|
|
10
|
+
**One book, two views.** The venue lists one instrument per market, the YES
|
|
11
|
+
side; "to trade against an outcome, you sell YES". The NO view offered here is
|
|
12
|
+
the YES book reflected through the face value, the same transform the Kalshi
|
|
13
|
+
adapter makes, and it is marked `derived=True` rather than passed off as a
|
|
14
|
+
second book. The YES view is the venue's own and is not derived.
|
|
15
|
+
|
|
16
|
+
**Every data endpoint keys on the slug.** Book, best bid/offer, settlement and
|
|
17
|
+
price history are addressed by slug and nothing else, so the slug is the
|
|
18
|
+
market id here and the venue's numeric id rides along in `info`.
|
|
19
|
+
|
|
20
|
+
**The catalog carries no volume, no last trade and no sizes.** A market
|
|
21
|
+
payload gives best bid and best ask and nothing more about the book. Those
|
|
22
|
+
appear as `None` rather than zero, and `refresh_quotes` reads the book -- one
|
|
23
|
+
request -- for the last trade, the sizes, shares traded, open interest and the
|
|
24
|
+
market's live state.
|
|
25
|
+
|
|
26
|
+
**Sort parameters are accepted and ignored.** `orderBy=volume24hr` answers 200
|
|
27
|
+
with the same rows in the same order as no sort at all; only `orderBy=id`
|
|
28
|
+
changes anything. So the listing walks the catalog in id order, which keeps
|
|
29
|
+
offset paging stable against insertions, and `sort` orders the page here
|
|
30
|
+
after it is read: `newest` from the catalog's own `createdAt`, and `volume`
|
|
31
|
+
and `liquidity` from one best-bid/offer read per market on the page, because
|
|
32
|
+
the catalog carries neither figure.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import base64
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import re
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from typing import Any, Iterator
|
|
42
|
+
|
|
43
|
+
from . import ids
|
|
44
|
+
from .base import (
|
|
45
|
+
MAX_PAGE_LIMIT, Capability, Exchange, HttpClient, RateLimiter, check_sort,
|
|
46
|
+
check_status, enough_bars, page_limit, pick_bars, sort_page, timeframe_seconds,
|
|
47
|
+
)
|
|
48
|
+
from .errors import BadRequest, MarketNotFound, NotSupported
|
|
49
|
+
from .types import (
|
|
50
|
+
BookSide, Candle, Event, FeeSchedule, Market, MarketStats, OrderBook, Outcome,
|
|
51
|
+
OrderLevel, Page, Quote, Series, Trade, iso,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
GATEWAY_URL = "https://gateway.polymarket.us/v1"
|
|
55
|
+
SITE_URL = "https://polymarket.us"
|
|
56
|
+
VENUE = "polymarket_us"
|
|
57
|
+
|
|
58
|
+
LIMITER = RateLimiter(15.0, burst=15)
|
|
59
|
+
"""The venue documents 20 requests per second per IP for the public gateway,
|
|
60
|
+
answering 429 with no Retry-After beyond that. Set below the ceiling because
|
|
61
|
+
the limit is per IP and this budget is per process: two processes on one
|
|
62
|
+
machine each at 20/s would be at 40/s. Shared process-wide, as the others are."""
|
|
63
|
+
|
|
64
|
+
MARKET_BATCH = 50
|
|
65
|
+
"""Slugs per `/markets?slug=` call. The ceiling is URI length; 50 slugs of the
|
|
66
|
+
lengths seen here stay under 2,500 characters."""
|
|
67
|
+
|
|
68
|
+
MAKER_THETA = -0.0125
|
|
69
|
+
"""The venue's published maker coefficient, a rebate. Fees are `theta * C * p
|
|
70
|
+
* (1 - p)`; the taker theta is on each market as `feeCoefficient`, the maker
|
|
71
|
+
theta is venue-wide and appears only in the fee schedule document."""
|
|
72
|
+
|
|
73
|
+
MAX_SEARCH_PAGES = 5
|
|
74
|
+
"""Most search pages one `search_markets` call reads while filling `limit`
|
|
75
|
+
markets. Past it the call returns what it has, with a cursor."""
|
|
76
|
+
|
|
77
|
+
SEARCH_SKIP = 1_000_000
|
|
78
|
+
"""Packs a search cursor's page and markets-to-skip into one number:
|
|
79
|
+
`page * SEARCH_SKIP + skip`."""
|
|
80
|
+
|
|
81
|
+
SEARCH_PAGE = 20
|
|
82
|
+
"""Results per search page when the caller gives no limit."""
|
|
83
|
+
|
|
84
|
+
_SETTLED_STATUSES = {"MARKET_STATUS_RESOLVED", "MARKET_STATUS_SETTLED"}
|
|
85
|
+
_OPEN_STATES = {"MARKET_STATE_OPEN"}
|
|
86
|
+
"""Book states in which the venue accepts orders. Everything else --
|
|
87
|
+
pre-open, suspended, halted, expired, terminated, auction -- is listed and not
|
|
88
|
+
trading, which is `status="open"` with `active=False`."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Pure normalizers. No network, no client state -- a recorded payload in, a
|
|
93
|
+
# unified type out.
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def to_float(value: Any) -> float | None:
|
|
97
|
+
if value in (None, ""):
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
return float(value)
|
|
101
|
+
except (TypeError, ValueError):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def amount(value: Any) -> float | None:
|
|
106
|
+
"""A gateway money field, `{"value": "0.1060", "currency": "USD"}`, as a float."""
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
return to_float(value.get("value"))
|
|
109
|
+
return to_float(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_ts(value: Any) -> int | None:
|
|
113
|
+
"""ISO 8601, which this venue writes with nine fractional digits.
|
|
114
|
+
|
|
115
|
+
`datetime.fromisoformat` takes at most six, so the fraction is trimmed
|
|
116
|
+
first -- only the run of digits directly after the dot, never anything
|
|
117
|
+
from the timezone suffix behind it. A timestamp that arrives without a
|
|
118
|
+
zone is read as UTC, the same rule `types.ms` applies: reading it as the
|
|
119
|
+
machine's local time put every book timestamp an hour off on a machine in
|
|
120
|
+
London, and nothing in the payload's own numbers would have said so.
|
|
121
|
+
Unix seconds also appear, on price history.
|
|
122
|
+
"""
|
|
123
|
+
if value in (None, ""):
|
|
124
|
+
return None
|
|
125
|
+
if isinstance(value, (int, float)):
|
|
126
|
+
return int(value if value > 1e11 else value * 1000)
|
|
127
|
+
text = str(value).replace("Z", "+00:00")
|
|
128
|
+
match = _FRACTION.match(text)
|
|
129
|
+
if match:
|
|
130
|
+
head, digits, rest = match.groups()
|
|
131
|
+
text = f"{head}.{digits[:6]}{rest}"
|
|
132
|
+
try:
|
|
133
|
+
parsed = datetime.fromisoformat(text)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return None
|
|
136
|
+
if parsed.tzinfo is None:
|
|
137
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
138
|
+
return int(parsed.timestamp() * 1000)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_FRACTION = re.compile(r"^([^.]*)\.(\d+)(.*)$")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def quoted(value: Any, *, face_value: float = 1.0) -> float | None:
|
|
145
|
+
"""A price, or `None` when the venue's placeholder means the side is empty.
|
|
146
|
+
|
|
147
|
+
A resolved market prints its sides at 1 and 0 and a best bid or ask of
|
|
148
|
+
`null`; a live book rests strictly inside (0, face_value). None of the
|
|
149
|
+
edge values is a price anyone can trade at.
|
|
150
|
+
"""
|
|
151
|
+
price = amount(value)
|
|
152
|
+
if price is None or price <= 0 or price >= face_value:
|
|
153
|
+
return None
|
|
154
|
+
return price
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def status_of(market: dict[str, Any]) -> tuple[str, str | None, bool]:
|
|
158
|
+
"""(normalized status, native status, accepting orders).
|
|
159
|
+
|
|
160
|
+
The venue's `status` is an enum (`MARKET_STATUS_OPEN`,
|
|
161
|
+
`MARKET_STATUS_RESOLVED`, ...) and `closed` a flag; a resolved market is
|
|
162
|
+
still `active: true` and `closed: true` at once, so the enum is read
|
|
163
|
+
first and the flags only where it says nothing.
|
|
164
|
+
"""
|
|
165
|
+
native = str(market.get("status") or market.get("ep3Status") or "").upper() or None
|
|
166
|
+
if native in _SETTLED_STATUSES:
|
|
167
|
+
return "settled", native, False
|
|
168
|
+
if market.get("closed"):
|
|
169
|
+
return "closed", native, False
|
|
170
|
+
if market.get("active"):
|
|
171
|
+
accepting = native is None or native.endswith("OPEN")
|
|
172
|
+
return "open", native, accepting
|
|
173
|
+
return "unopened", native, False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def sides_of(market: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
177
|
+
"""(YES side, NO side) by the venue's `long` flag, not by position."""
|
|
178
|
+
sides = [s for s in (market.get("marketSides") or []) if isinstance(s, dict)]
|
|
179
|
+
longs = [s for s in sides if s.get("long") is True]
|
|
180
|
+
shorts = [s for s in sides if s.get("long") is False]
|
|
181
|
+
yes = longs[0] if longs else (sides[0] if sides else {})
|
|
182
|
+
no = shorts[0] if shorts else (sides[1] if len(sides) > 1 else {})
|
|
183
|
+
return yes, no
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _labels(market: dict[str, Any], yes: dict[str, Any], no: dict[str, Any]) -> tuple[str, str]:
|
|
187
|
+
"""Display labels: the side's own text, else the market's outcomes list."""
|
|
188
|
+
outcomes = market.get("outcomes")
|
|
189
|
+
if isinstance(outcomes, str):
|
|
190
|
+
try:
|
|
191
|
+
outcomes = json.loads(outcomes)
|
|
192
|
+
except ValueError:
|
|
193
|
+
outcomes = []
|
|
194
|
+
outcomes = [str(o) for o in (outcomes or [])]
|
|
195
|
+
yes_label = yes.get("description") or (outcomes[0] if outcomes else "Yes")
|
|
196
|
+
no_label = no.get("description") or (outcomes[1] if len(outcomes) > 1 else "No")
|
|
197
|
+
return str(yes_label), str(no_label)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _tags_of(*holders: dict[str, Any] | None) -> list[str]:
|
|
201
|
+
tags: list[str] = []
|
|
202
|
+
for holder in holders:
|
|
203
|
+
for tag in (holder or {}).get("tags") or []:
|
|
204
|
+
if isinstance(tag, dict):
|
|
205
|
+
text = str(tag.get("slug") or tag.get("label") or "").lower()
|
|
206
|
+
if text and text not in tags:
|
|
207
|
+
tags.append(text)
|
|
208
|
+
return tags
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def normalize_market(market: dict[str, Any], event: dict[str, Any] | None = None) -> Market:
|
|
212
|
+
"""One gateway market payload as a unified `Market`."""
|
|
213
|
+
event = event or {}
|
|
214
|
+
slug = str(market.get("slug") or market.get("id"))
|
|
215
|
+
face_value = 1.0
|
|
216
|
+
status, native, accepting = status_of(market)
|
|
217
|
+
yes_side, no_side = sides_of(market)
|
|
218
|
+
yes_label, no_label = _labels(market, yes_side, no_side)
|
|
219
|
+
|
|
220
|
+
# The catalog quotes the YES book's top only. The NO view is the same two
|
|
221
|
+
# resting orders seen from the other side: a YES bid at 0.106 is a NO ask
|
|
222
|
+
# at 0.894. Sizes and the last trade are not in the catalog at all; they
|
|
223
|
+
# come from the book, through `refresh_quotes`.
|
|
224
|
+
bid = quoted(market.get("bestBidQuote"), face_value=face_value)
|
|
225
|
+
ask = quoted(market.get("bestAskQuote"), face_value=face_value)
|
|
226
|
+
no_bid = round(face_value - ask, 6) if ask is not None else None
|
|
227
|
+
no_ask = round(face_value - bid, 6) if bid is not None else None
|
|
228
|
+
|
|
229
|
+
def mid(a: float | None, b: float | None) -> float | None:
|
|
230
|
+
return round((a + b) / 2, 6) if a is not None and b is not None else None
|
|
231
|
+
|
|
232
|
+
yes = Outcome(
|
|
233
|
+
label=yes_label, quote=Quote(bid=bid, ask=ask, mid=mid(bid, ask)),
|
|
234
|
+
info={k: v for k, v in yes_side.items() if k != "team"},
|
|
235
|
+
)
|
|
236
|
+
no = Outcome(
|
|
237
|
+
label=no_label, quote=Quote(bid=no_bid, ask=no_ask, mid=mid(no_bid, no_ask)),
|
|
238
|
+
info={k: v for k, v in no_side.items() if k != "team"},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
event_slug = event.get("slug")
|
|
242
|
+
tags = _tags_of(event, market)
|
|
243
|
+
question = market.get("question") or market.get("title") or slug
|
|
244
|
+
short = market.get("titleShort") or market.get("title") or None
|
|
245
|
+
return Market(
|
|
246
|
+
id=ids.qualify(VENUE, slug),
|
|
247
|
+
venue=VENUE,
|
|
248
|
+
venue_market_id=slug,
|
|
249
|
+
event_id=ids.qualify(VENUE, str(event_slug)) if event_slug else None,
|
|
250
|
+
title=question,
|
|
251
|
+
description=market.get("description") or None,
|
|
252
|
+
slug=slug,
|
|
253
|
+
yes=yes,
|
|
254
|
+
no=no,
|
|
255
|
+
status=status, # type: ignore[arg-type]
|
|
256
|
+
native_status=native,
|
|
257
|
+
active=accepting,
|
|
258
|
+
market_type="binary",
|
|
259
|
+
open_timestamp=parse_ts(market.get("startDate")),
|
|
260
|
+
open_datetime=iso(parse_ts(market.get("startDate"))),
|
|
261
|
+
close_timestamp=parse_ts(market.get("endDate")),
|
|
262
|
+
close_datetime=iso(parse_ts(market.get("endDate"))),
|
|
263
|
+
resolution_timestamp=parse_ts(market.get("endDate")),
|
|
264
|
+
resolution_datetime=iso(parse_ts(market.get("endDate"))),
|
|
265
|
+
tick_size=to_float(market.get("orderPriceMinTickSize")),
|
|
266
|
+
face_value=face_value,
|
|
267
|
+
book_model="shared_complement",
|
|
268
|
+
# Nothing about volume, liquidity or open interest is in the catalog
|
|
269
|
+
# payload. Absent is `None`; a zero would claim the venue said so.
|
|
270
|
+
stats=MarketStats(
|
|
271
|
+
volume_unit="contracts",
|
|
272
|
+
# Every liquidity figure this venue publishes is in shares
|
|
273
|
+
# (`bidShares`, `askShares` on the best bid/offer), never dollars.
|
|
274
|
+
liquidity_unit="contracts",
|
|
275
|
+
as_of=parse_ts(market.get("updatedAt")),
|
|
276
|
+
),
|
|
277
|
+
url=f"{SITE_URL}/event/{event_slug}" if event_slug else f"{SITE_URL}/market/{slug}",
|
|
278
|
+
image_url=market.get("image") or None,
|
|
279
|
+
category=market.get("category") or event.get("category") or (tags or [None])[0],
|
|
280
|
+
tags=tags,
|
|
281
|
+
series_id=event.get("seriesSlug") or None,
|
|
282
|
+
# `question` is the whole market ("National League Champion"); the
|
|
283
|
+
# short title is the row inside it ("Atlanta Braves"). Where the two
|
|
284
|
+
# are the same string there is no separate label to report.
|
|
285
|
+
outcome_label=short if short and short != question else None,
|
|
286
|
+
neg_risk=None,
|
|
287
|
+
settlement_sources=[],
|
|
288
|
+
info=market,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def normalize_event(event: dict[str, Any]) -> Event:
|
|
293
|
+
markets = [normalize_market(m, event) for m in (event.get("markets") or [])]
|
|
294
|
+
statuses = {m.status for m in markets}
|
|
295
|
+
for candidate in ("open", "closed", "settled", "unopened"):
|
|
296
|
+
if candidate in statuses:
|
|
297
|
+
status = candidate
|
|
298
|
+
break
|
|
299
|
+
else:
|
|
300
|
+
status = "unopened"
|
|
301
|
+
slug = str(event.get("slug") or event.get("id"))
|
|
302
|
+
tags = _tags_of(event)
|
|
303
|
+
return Event(
|
|
304
|
+
id=ids.qualify(VENUE, slug),
|
|
305
|
+
venue=VENUE,
|
|
306
|
+
venue_event_id=slug,
|
|
307
|
+
title=event.get("title") or slug,
|
|
308
|
+
description=event.get("description") or None,
|
|
309
|
+
slug=slug,
|
|
310
|
+
markets=markets,
|
|
311
|
+
status=status, # type: ignore[arg-type]
|
|
312
|
+
native_status="closed" if event.get("closed") else "active" if event.get("active") else None,
|
|
313
|
+
category=event.get("category") or (tags or [None])[0],
|
|
314
|
+
tags=tags,
|
|
315
|
+
series_id=event.get("seriesSlug") or None,
|
|
316
|
+
# The venue publishes nothing about whether an event's markets exclude
|
|
317
|
+
# one another. `None` is that answer; it is not defaulted to False.
|
|
318
|
+
mutually_exclusive=None,
|
|
319
|
+
close_timestamp=parse_ts(event.get("endDate")),
|
|
320
|
+
close_datetime=iso(parse_ts(event.get("endDate"))),
|
|
321
|
+
url=f"{SITE_URL}/event/{slug}",
|
|
322
|
+
image_url=event.get("image") or None,
|
|
323
|
+
settlement_sources=[],
|
|
324
|
+
info={k: v for k, v in event.items() if k != "markets"},
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def normalize_order_book(
|
|
329
|
+
payload: dict[str, Any], *, slug: str, side: str, face_value: float = 1.0,
|
|
330
|
+
depth: int | None = None,
|
|
331
|
+
) -> OrderBook:
|
|
332
|
+
"""The venue's YES book, presented from one side.
|
|
333
|
+
|
|
334
|
+
`/markets/{slug}/book` answers `marketData.bids` and `marketData.offers`,
|
|
335
|
+
both best-first, on the YES instrument. The YES view is those two ladders
|
|
336
|
+
as sent. The NO view reflects them: a YES offer at 0.107 is a NO bid at
|
|
337
|
+
`face_value - 0.107`, and the sides swap.
|
|
338
|
+
"""
|
|
339
|
+
data = payload.get("marketData") or payload
|
|
340
|
+
|
|
341
|
+
def levels(rows: Any) -> list[OrderLevel]:
|
|
342
|
+
out = []
|
|
343
|
+
for row in rows or []:
|
|
344
|
+
if not isinstance(row, dict):
|
|
345
|
+
continue
|
|
346
|
+
price, size = amount(row.get("px")), to_float(row.get("qty"))
|
|
347
|
+
if price is None or size is None or size <= 0:
|
|
348
|
+
continue
|
|
349
|
+
out.append(OrderLevel(price=price, size=size))
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
yes_bids = levels(data.get("bids"))
|
|
353
|
+
yes_asks = levels(data.get("offers"))
|
|
354
|
+
if side == "yes":
|
|
355
|
+
bids, asks, derived = yes_bids, yes_asks, False
|
|
356
|
+
else:
|
|
357
|
+
bids = [OrderLevel(price=round(face_value - level.price, 6), size=level.size) for level in yes_asks]
|
|
358
|
+
asks = [OrderLevel(price=round(face_value - level.price, 6), size=level.size) for level in yes_bids]
|
|
359
|
+
derived = True
|
|
360
|
+
bids = sorted(bids, key=lambda level: level.price, reverse=True)
|
|
361
|
+
asks = sorted(asks, key=lambda level: level.price)
|
|
362
|
+
if depth:
|
|
363
|
+
bids, asks = bids[:depth], asks[:depth]
|
|
364
|
+
timestamp = parse_ts(data.get("transactTime"))
|
|
365
|
+
return OrderBook(
|
|
366
|
+
market_id=ids.qualify(VENUE, slug),
|
|
367
|
+
side=side, # type: ignore[arg-type]
|
|
368
|
+
venue=VENUE,
|
|
369
|
+
bids=bids,
|
|
370
|
+
asks=asks,
|
|
371
|
+
timestamp=timestamp,
|
|
372
|
+
datetime=iso(timestamp),
|
|
373
|
+
book_model="shared_complement",
|
|
374
|
+
derived=derived,
|
|
375
|
+
depth_scope="top_n" if depth else "full",
|
|
376
|
+
info=payload,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def candles_from_price_history(
|
|
381
|
+
history: list[dict[str, Any]], *, interval_seconds: int, face_value: float = 1.0,
|
|
382
|
+
) -> list[Candle]:
|
|
383
|
+
"""Bucket the venue's price samples into bars, labelled for what they are.
|
|
384
|
+
|
|
385
|
+
`/price-history` returns `{timestamp, longPrice, shortPrice}` samples at
|
|
386
|
+
an irregular cadence. The venue documents `longPrice` as the YES display
|
|
387
|
+
price "normally derived from the best ask" and `shortPrice` as the NO
|
|
388
|
+
display price "derived from one minus the best bid" -- and notes the two
|
|
389
|
+
can sum to more than 1 because they keep the spread. So each sample is a
|
|
390
|
+
bid/ask pair in disguise: `ask = longPrice`, `bid = face_value -
|
|
391
|
+
shortPrice`. The bar's OHLC is of their midpoint, `price_source` says
|
|
392
|
+
`bid_ask_mid`, and `bid_close`/`ask_close` carry the pair at the close.
|
|
393
|
+
No trades, so no volume: `None`, not zero.
|
|
394
|
+
"""
|
|
395
|
+
buckets: dict[int, list[tuple[float, float]]] = {}
|
|
396
|
+
for point in history:
|
|
397
|
+
seconds = to_float(point.get("timestamp"))
|
|
398
|
+
ask = to_float(point.get("longPrice"))
|
|
399
|
+
short = to_float(point.get("shortPrice"))
|
|
400
|
+
if seconds is None or ask is None or short is None:
|
|
401
|
+
continue
|
|
402
|
+
bid = round(face_value - short, 6)
|
|
403
|
+
start = int(seconds // interval_seconds) * interval_seconds
|
|
404
|
+
buckets.setdefault(start, []).append((bid, ask))
|
|
405
|
+
|
|
406
|
+
candles = []
|
|
407
|
+
for start in sorted(buckets):
|
|
408
|
+
pairs = buckets[start]
|
|
409
|
+
mids = [round((bid + ask) / 2, 6) for bid, ask in pairs]
|
|
410
|
+
candles.append(Candle(
|
|
411
|
+
timestamp=start * 1000,
|
|
412
|
+
datetime=iso(start * 1000) or "",
|
|
413
|
+
open=mids[0], high=max(mids), low=min(mids), close=mids[-1],
|
|
414
|
+
volume=None,
|
|
415
|
+
trade_count=None,
|
|
416
|
+
price_source="bid_ask_mid",
|
|
417
|
+
bid_close=pairs[-1][0],
|
|
418
|
+
ask_close=pairs[-1][1],
|
|
419
|
+
info={"samples": len(pairs)},
|
|
420
|
+
))
|
|
421
|
+
return candles
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def reflect_candle(candle: Candle, *, face_value: float = 1.0) -> Candle:
|
|
425
|
+
"""A YES-denominated bar seen from the NO side: prices invert and the
|
|
426
|
+
extremes swap, as do bid and ask."""
|
|
427
|
+
def flip(price: float | None) -> float | None:
|
|
428
|
+
return round(face_value - price, 6) if price is not None else None
|
|
429
|
+
|
|
430
|
+
return candle.model_copy(update={
|
|
431
|
+
"open": flip(candle.open),
|
|
432
|
+
"high": flip(candle.low),
|
|
433
|
+
"low": flip(candle.high),
|
|
434
|
+
"close": flip(candle.close),
|
|
435
|
+
"bid_close": flip(candle.ask_close),
|
|
436
|
+
"ask_close": flip(candle.bid_close),
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def fee_schedule_of(market: dict[str, Any]) -> FeeSchedule | None:
|
|
441
|
+
"""The venue's published fee for one market.
|
|
442
|
+
|
|
443
|
+
Fees are `theta * C * p * (1 - p)`, the same quadratic shape as Kalshi's
|
|
444
|
+
but with the coefficient published directly: the taker theta sits on each
|
|
445
|
+
market as `feeCoefficient`, the maker theta is venue-wide and negative,
|
|
446
|
+
a rebate paid at execution. Rounded to the cent, banker's rounding.
|
|
447
|
+
"""
|
|
448
|
+
theta = to_float(market.get("feeCoefficient"))
|
|
449
|
+
if theta is None:
|
|
450
|
+
return None
|
|
451
|
+
return FeeSchedule(
|
|
452
|
+
venue=VENUE,
|
|
453
|
+
scope="market",
|
|
454
|
+
scope_id=str(market.get("slug") or market.get("id") or ""),
|
|
455
|
+
fee_type="quadratic_theta",
|
|
456
|
+
taker_rate=theta,
|
|
457
|
+
maker_rate=MAKER_THETA,
|
|
458
|
+
rounding="nearest_cent_bankers",
|
|
459
|
+
info={"feeCoefficient": theta, "maker_theta": MAKER_THETA},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def normalize_series(series: dict[str, Any]) -> Series:
|
|
464
|
+
slug = str(series.get("slug") or series.get("id"))
|
|
465
|
+
return Series(
|
|
466
|
+
id=slug,
|
|
467
|
+
venue=VENUE,
|
|
468
|
+
title=series.get("title"),
|
|
469
|
+
category=None,
|
|
470
|
+
tags=[],
|
|
471
|
+
# Fees on this venue are per market, not per series.
|
|
472
|
+
fee=None,
|
|
473
|
+
settlement_sources=[],
|
|
474
|
+
info=series,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ---------------------------------------------------------------------------
|
|
479
|
+
# Adapter
|
|
480
|
+
# ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
class PolymarketUS(Exchange):
|
|
483
|
+
"""Polymarket US, read-only.
|
|
484
|
+
|
|
485
|
+
```python
|
|
486
|
+
import synpath
|
|
487
|
+
|
|
488
|
+
venue = synpath.PolymarketUS()
|
|
489
|
+
markets = venue.fetch_markets(query="Fed", limit=5)
|
|
490
|
+
book = venue.fetch_order_book(markets[0].id)
|
|
491
|
+
```
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
id = VENUE
|
|
495
|
+
name = "Polymarket US"
|
|
496
|
+
book_model = "shared_complement"
|
|
497
|
+
has: dict[str, Capability] = {
|
|
498
|
+
"fetch_markets": True,
|
|
499
|
+
"fetch_events": True,
|
|
500
|
+
"fetch_market": True,
|
|
501
|
+
"fetch_markets_by_ids": True,
|
|
502
|
+
# `orderBy=volume24hr` and `orderBy=createdAt` answer 200 with the
|
|
503
|
+
# rows in the venue's default order, so the page is ordered here
|
|
504
|
+
# after it is read. See `fetch_markets`.
|
|
505
|
+
"sort": True,
|
|
506
|
+
"fetch_order_book": True,
|
|
507
|
+
# No batch endpoint: one request per market, both sides of it from
|
|
508
|
+
# the same response. See `fetch_order_books`.
|
|
509
|
+
"fetch_order_books": True,
|
|
510
|
+
# No public REST trade tape. The markets WebSocket carries trades, and
|
|
511
|
+
# the book's `stats` block carries the last one.
|
|
512
|
+
"fetch_trades": False,
|
|
513
|
+
# Quote-derived samples, no executions, no volume.
|
|
514
|
+
"fetch_ohlcv": "partial",
|
|
515
|
+
"fetch_series": True,
|
|
516
|
+
"fetch_fee_schedule": True,
|
|
517
|
+
"search": True,
|
|
518
|
+
"watch_order_book": False,
|
|
519
|
+
"match_market": False,
|
|
520
|
+
"match_event": False,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
def __init__(
|
|
524
|
+
self,
|
|
525
|
+
*,
|
|
526
|
+
gateway_url: str = GATEWAY_URL,
|
|
527
|
+
timeout: float = 30.0,
|
|
528
|
+
limiter: RateLimiter | None = LIMITER,
|
|
529
|
+
client: Any = None,
|
|
530
|
+
):
|
|
531
|
+
self.http = HttpClient(gateway_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE)
|
|
532
|
+
|
|
533
|
+
# -- catalog ------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
def fetch_events(
|
|
536
|
+
self, *, query: str | None = None, limit: int | None = None,
|
|
537
|
+
cursor: str | None = None, status: str = "open",
|
|
538
|
+
) -> Page[Event]:
|
|
539
|
+
"""One page of events with their markets nested.
|
|
540
|
+
|
|
541
|
+
With `query`, this asks the venue's search. Without one it pages the
|
|
542
|
+
catalog in id order. The venue offers offset paging only, so the
|
|
543
|
+
cursor carries an offset rather than a key: a market listed mid-walk
|
|
544
|
+
gets a higher id and lands after the cursor, so insertions never shift
|
|
545
|
+
a row; a market removed mid-walk does shift the rows after it by one,
|
|
546
|
+
which offset paging cannot see. That is the venue's limit, stated
|
|
547
|
+
rather than hidden.
|
|
548
|
+
"""
|
|
549
|
+
if query:
|
|
550
|
+
return self.search_events(query, limit=limit, cursor=cursor, status=status)
|
|
551
|
+
active, closed = _status_flags(status)
|
|
552
|
+
wanted = page_limit(limit) or MAX_PAGE_LIMIT
|
|
553
|
+
fingerprint = query_fingerprint(status=status, kind="events")
|
|
554
|
+
offset = decode_cursor(cursor, fingerprint)
|
|
555
|
+
payload = self.http.get("/events", {
|
|
556
|
+
"limit": wanted, "offset": offset or None,
|
|
557
|
+
"active": active, "closed": closed,
|
|
558
|
+
"orderBy": "id", "orderDirection": "ASC",
|
|
559
|
+
})
|
|
560
|
+
rows = payload.get("events") or []
|
|
561
|
+
events = _with_status([normalize_event(raw) for raw in rows], status)
|
|
562
|
+
return Page(events, next_cursor=_next_offset(offset, len(rows), wanted, fingerprint))
|
|
563
|
+
|
|
564
|
+
def fetch_markets(
|
|
565
|
+
self, *, query: str | None = None, limit: int | None = None,
|
|
566
|
+
cursor: str | None = None, status: str = "open", sort: str | None = None,
|
|
567
|
+
) -> Page[Market]:
|
|
568
|
+
"""One page of markets, in id order unless `sort` says otherwise.
|
|
569
|
+
|
|
570
|
+
`query` goes to the venue's search. Paging is by offset underneath,
|
|
571
|
+
for the reasons `fetch_events` gives.
|
|
572
|
+
|
|
573
|
+
`sort` orders the page after it is read, since the venue ignores its
|
|
574
|
+
own sort parameter: "this page, ordered by", not "the top of the
|
|
575
|
+
catalog". `newest` reads the catalog's `createdAt` and costs nothing
|
|
576
|
+
extra. `volume` and `liquidity` are not in the catalog at all, so
|
|
577
|
+
each market on the page is read once from `/bbo` first -- up to a
|
|
578
|
+
hundred requests for a full page, against the shared rate budget --
|
|
579
|
+
and the page then carries those figures too: lifetime shares traded
|
|
580
|
+
as volume, resting bid and ask shares as liquidity, open interest
|
|
581
|
+
alongside. A market without the figure sorts last.
|
|
582
|
+
"""
|
|
583
|
+
check_sort(sort, venue=VENUE, supported=bool(self.has["sort"]))
|
|
584
|
+
if query:
|
|
585
|
+
page = self.search_markets(query, limit=limit, cursor=cursor, status=status)
|
|
586
|
+
if sort:
|
|
587
|
+
return Page(self._sorted(list(page), sort), next_cursor=page.next_cursor)
|
|
588
|
+
return page
|
|
589
|
+
active, closed = _status_flags(status)
|
|
590
|
+
wanted = page_limit(limit) or MAX_PAGE_LIMIT
|
|
591
|
+
fingerprint = query_fingerprint(status=status, kind="markets")
|
|
592
|
+
offset = decode_cursor(cursor, fingerprint)
|
|
593
|
+
payload = self.http.get("/markets", {
|
|
594
|
+
"limit": wanted, "offset": offset or None,
|
|
595
|
+
"active": active, "closed": closed,
|
|
596
|
+
"orderBy": "id", "orderDirection": "ASC",
|
|
597
|
+
})
|
|
598
|
+
rows = payload.get("markets") or []
|
|
599
|
+
markets = _with_status([normalize_market(raw) for raw in rows], status)
|
|
600
|
+
if sort:
|
|
601
|
+
markets = self._sorted(markets, sort)
|
|
602
|
+
return Page(markets, next_cursor=_next_offset(offset, len(rows), wanted, fingerprint))
|
|
603
|
+
|
|
604
|
+
def _sorted(self, markets: list[Market], sort: str) -> list[Market]:
|
|
605
|
+
if sort == "newest":
|
|
606
|
+
return sort_page(markets, lambda m: parse_ts(m.info.get("createdAt")) or m.open_timestamp)
|
|
607
|
+
enriched = [self._with_bbo_stats(market) for market in markets]
|
|
608
|
+
if sort == "volume":
|
|
609
|
+
return sort_page(enriched, lambda m: m.stats.volume_total)
|
|
610
|
+
return sort_page(enriched, lambda m: m.stats.liquidity)
|
|
611
|
+
|
|
612
|
+
def _with_bbo_stats(self, market: Market) -> Market:
|
|
613
|
+
"""`market` with the figures the catalog lacks, from one `/bbo` read."""
|
|
614
|
+
payload = self.http.get(f"/markets/{market.venue_market_id}/bbo")
|
|
615
|
+
data = (payload.get("marketData") if isinstance(payload, dict) else None) or {}
|
|
616
|
+
bid_shares, ask_shares = to_float(data.get("bidShares")), to_float(data.get("askShares"))
|
|
617
|
+
liquidity = (
|
|
618
|
+
(bid_shares or 0.0) + (ask_shares or 0.0)
|
|
619
|
+
if bid_shares is not None or ask_shares is not None else None
|
|
620
|
+
)
|
|
621
|
+
return market.model_copy(update={"stats": market.stats.model_copy(update={
|
|
622
|
+
"volume_total": to_float(data.get("sharesTraded")),
|
|
623
|
+
"open_interest": to_float(data.get("openInterest")),
|
|
624
|
+
"liquidity": liquidity,
|
|
625
|
+
})})
|
|
626
|
+
|
|
627
|
+
def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]:
|
|
628
|
+
"""Many markets in one request, in the order asked for.
|
|
629
|
+
|
|
630
|
+
Slugs are batched through `?slug=`, numeric ids through `?id=`; either
|
|
631
|
+
form of id is accepted, as is an instrument id. A market the venue no
|
|
632
|
+
longer lists is left out rather than raised.
|
|
633
|
+
"""
|
|
634
|
+
keys = [_slug_of(self.native(market_id)) for market_id in market_ids]
|
|
635
|
+
found: dict[str, Market] = {}
|
|
636
|
+
slugs = [k for k in dict.fromkeys(keys) if not k.isdigit()]
|
|
637
|
+
numeric = [k for k in dict.fromkeys(keys) if k.isdigit()]
|
|
638
|
+
for field, batch_keys in (("slug", slugs), ("id", numeric)):
|
|
639
|
+
for start in range(0, len(batch_keys), MARKET_BATCH):
|
|
640
|
+
batch = batch_keys[start:start + MARKET_BATCH]
|
|
641
|
+
payload = self.http.get(
|
|
642
|
+
"/markets", [(field, key) for key in batch] + [("limit", len(batch))],
|
|
643
|
+
)
|
|
644
|
+
for raw in payload.get("markets") or []:
|
|
645
|
+
market = normalize_market(raw)
|
|
646
|
+
found[market.venue_market_id] = market
|
|
647
|
+
if raw.get("id") is not None:
|
|
648
|
+
found[str(raw["id"])] = market
|
|
649
|
+
return [found[key] for key in keys if key in found]
|
|
650
|
+
|
|
651
|
+
def fetch_market(self, market_id: str) -> Market:
|
|
652
|
+
key = _slug_of(self.native(market_id))
|
|
653
|
+
path = f"/market/id/{key}" if key.isdigit() else f"/market/slug/{key}"
|
|
654
|
+
payload = self.http.get(path)
|
|
655
|
+
raw = payload.get("market") if isinstance(payload, dict) else None
|
|
656
|
+
if not raw:
|
|
657
|
+
raise MarketNotFound(f"{VENUE}: no market {market_id}")
|
|
658
|
+
return normalize_market(raw)
|
|
659
|
+
|
|
660
|
+
def iter_events(self, *, status: str = "open") -> Iterator[Event]:
|
|
661
|
+
cursor: str | None = None
|
|
662
|
+
while True:
|
|
663
|
+
page = self.fetch_events(cursor=cursor, status=status)
|
|
664
|
+
yield from page
|
|
665
|
+
cursor = page.next_cursor
|
|
666
|
+
if not cursor or not page:
|
|
667
|
+
return
|
|
668
|
+
|
|
669
|
+
def search_events(
|
|
670
|
+
self, query: str, *, limit: int | None = None, cursor: str | None = None,
|
|
671
|
+
status: str = "open",
|
|
672
|
+
) -> Page[Event]:
|
|
673
|
+
"""Events matching `query`, most relevant first, with markets nested.
|
|
674
|
+
|
|
675
|
+
The venue's search is paged by page number; the cursor carries it,
|
|
676
|
+
tagged with the query it belongs to so a cursor from one search cannot
|
|
677
|
+
be replayed into another.
|
|
678
|
+
"""
|
|
679
|
+
check_status(status)
|
|
680
|
+
wanted = page_limit(limit) or SEARCH_PAGE
|
|
681
|
+
fingerprint = query_fingerprint(q=query, status=status, kind="search")
|
|
682
|
+
page_no = decode_cursor(cursor, fingerprint) or 1
|
|
683
|
+
payload = self.http.get("/search", {"query": query, "limit": wanted, "page": page_no})
|
|
684
|
+
rows = payload.get("events") or []
|
|
685
|
+
events = _with_status([normalize_event(raw) for raw in rows], status)
|
|
686
|
+
next_cursor = encode_cursor(page_no + 1, fingerprint) if len(rows) >= wanted else None
|
|
687
|
+
return Page(events, next_cursor=next_cursor)
|
|
688
|
+
|
|
689
|
+
def search_markets(
|
|
690
|
+
self, query: str, *, limit: int | None = None, cursor: str | None = None,
|
|
691
|
+
status: str = "open",
|
|
692
|
+
) -> Page[Market]:
|
|
693
|
+
"""Markets matching `query`: the search's events, flattened, at most
|
|
694
|
+
`limit` of them.
|
|
695
|
+
|
|
696
|
+
The venue searches events, so this reads search pages and flattens
|
|
697
|
+
their markets until it has `limit` (at most `MAX_SEARCH_PAGES` pages a
|
|
698
|
+
call). The cursor records the search page and how many of its markets
|
|
699
|
+
were already handed out, so an event whose markets straddle two pages
|
|
700
|
+
of results is neither repeated nor cut short.
|
|
701
|
+
"""
|
|
702
|
+
check_status(status)
|
|
703
|
+
wanted = page_limit(limit) or MAX_PAGE_LIMIT
|
|
704
|
+
fingerprint = query_fingerprint(q=query, status=status, kind="search_markets")
|
|
705
|
+
position = decode_cursor(cursor, fingerprint)
|
|
706
|
+
page_no, skip = (divmod(position, SEARCH_SKIP) if position else (1, 0))
|
|
707
|
+
collected: list[Market] = []
|
|
708
|
+
next_cursor: str | None = None
|
|
709
|
+
for _ in range(MAX_SEARCH_PAGES):
|
|
710
|
+
payload = self.http.get("/search", {"query": query, "limit": SEARCH_PAGE, "page": page_no})
|
|
711
|
+
rows = payload.get("events") or []
|
|
712
|
+
markets = [m for raw in rows for m in _with_status(normalize_event(raw).markets, status)][skip:]
|
|
713
|
+
room = wanted - len(collected)
|
|
714
|
+
collected += markets[:room]
|
|
715
|
+
if len(markets) > room:
|
|
716
|
+
next_cursor = encode_cursor(page_no * SEARCH_SKIP + skip + room, fingerprint)
|
|
717
|
+
break
|
|
718
|
+
if len(rows) < SEARCH_PAGE:
|
|
719
|
+
next_cursor = None
|
|
720
|
+
break
|
|
721
|
+
page_no, skip = page_no + 1, 0
|
|
722
|
+
next_cursor = encode_cursor(page_no * SEARCH_SKIP, fingerprint)
|
|
723
|
+
if len(collected) >= wanted:
|
|
724
|
+
break
|
|
725
|
+
return Page(collected, next_cursor=next_cursor)
|
|
726
|
+
|
|
727
|
+
# -- market data --------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
def fetch_order_book(
|
|
730
|
+
self, market_id: str, *, side: BookSide = "yes", depth: int | None = None,
|
|
731
|
+
) -> OrderBook:
|
|
732
|
+
slug = _slug_of(self.native(market_id))
|
|
733
|
+
_check_side(side)
|
|
734
|
+
payload = self.http.get(f"/markets/{slug}/book")
|
|
735
|
+
return normalize_order_book(payload, slug=slug, side=side, depth=depth)
|
|
736
|
+
|
|
737
|
+
def fetch_order_books(
|
|
738
|
+
self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None,
|
|
739
|
+
) -> dict[str, OrderBook]:
|
|
740
|
+
"""Books for many markets, keyed by Synpath market id. No batch
|
|
741
|
+
endpoint here either, so one request per market against the shared
|
|
742
|
+
budget."""
|
|
743
|
+
_check_side(side)
|
|
744
|
+
books: dict[str, OrderBook] = {}
|
|
745
|
+
for slug in dict.fromkeys(_slug_of(self.native(m)) for m in market_ids):
|
|
746
|
+
payload = self.http.get(f"/markets/{slug}/book")
|
|
747
|
+
books[ids.qualify(VENUE, slug)] = normalize_order_book(payload, slug=slug, side=side, depth=depth)
|
|
748
|
+
return books
|
|
749
|
+
|
|
750
|
+
def refresh_quotes(self, market: Market) -> Market:
|
|
751
|
+
"""`market` with its quotes and stats re-read from the live book.
|
|
752
|
+
|
|
753
|
+
The catalog gives best bid and ask and nothing else about the book.
|
|
754
|
+
One request here fills both instruments' sizes and last trade, the
|
|
755
|
+
market's shares traded and open interest, and its live state --
|
|
756
|
+
`active` becomes whether the book is actually open right now.
|
|
757
|
+
"""
|
|
758
|
+
slug = market.venue_market_id
|
|
759
|
+
payload = self.http.get(f"/markets/{slug}/book")
|
|
760
|
+
data = payload.get("marketData") or {}
|
|
761
|
+
stats = data.get("stats") or {}
|
|
762
|
+
refreshed = market.model_copy(deep=True)
|
|
763
|
+
face_value = market.face_value
|
|
764
|
+
|
|
765
|
+
yes_book = normalize_order_book(payload, slug=slug, side="yes", face_value=face_value)
|
|
766
|
+
last = quoted(stats.get("lastTradePx"), face_value=face_value)
|
|
767
|
+
last_ts = parse_ts(stats.get("lastTradeSetTime"))
|
|
768
|
+
for index, instrument in enumerate((refreshed.yes, refreshed.no)):
|
|
769
|
+
book = yes_book if index == 0 else normalize_order_book(
|
|
770
|
+
payload, slug=slug, side="no", face_value=face_value,
|
|
771
|
+
)
|
|
772
|
+
best_bid, best_ask = book.best_bid, book.best_ask
|
|
773
|
+
bid = best_bid.price if best_bid else None
|
|
774
|
+
ask = best_ask.price if best_ask else None
|
|
775
|
+
own_last = last if index == 0 else (
|
|
776
|
+
round(face_value - last, 6) if last is not None else None
|
|
777
|
+
)
|
|
778
|
+
instrument.quote = Quote(
|
|
779
|
+
bid=bid, bid_size=best_bid.size if best_bid else None,
|
|
780
|
+
ask=ask, ask_size=best_ask.size if best_ask else None,
|
|
781
|
+
mid=round((bid + ask) / 2, 6) if bid is not None and ask is not None else None,
|
|
782
|
+
last=own_last,
|
|
783
|
+
last_timestamp=last_ts if own_last is not None else None,
|
|
784
|
+
last_datetime=iso(last_ts) if own_last is not None else None,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
refreshed.stats = refreshed.stats.model_copy(update={
|
|
788
|
+
"volume_total": to_float(stats.get("sharesTraded")),
|
|
789
|
+
"open_interest": to_float(stats.get("openInterest")),
|
|
790
|
+
"as_of": yes_book.timestamp or refreshed.stats.as_of,
|
|
791
|
+
})
|
|
792
|
+
state = data.get("state")
|
|
793
|
+
if state:
|
|
794
|
+
refreshed.native_status = str(state)
|
|
795
|
+
refreshed.active = refreshed.status == "open" and str(state) in _OPEN_STATES
|
|
796
|
+
return refreshed
|
|
797
|
+
|
|
798
|
+
def fetch_trades(
|
|
799
|
+
self, market_id: str, *, since: int | None = None, limit: int | None = None,
|
|
800
|
+
cursor: str | None = None,
|
|
801
|
+
) -> Page[Trade]:
|
|
802
|
+
"""Not offered over the public REST gateway.
|
|
803
|
+
|
|
804
|
+
The venue publishes no trade tape there; executions stream on its
|
|
805
|
+
markets WebSocket, and the book's `stats` block carries the last one
|
|
806
|
+
(see `refresh_quotes`). Raised rather than answered empty so "cannot"
|
|
807
|
+
is not read as "nothing traded".
|
|
808
|
+
"""
|
|
809
|
+
raise NotSupported(
|
|
810
|
+
f"{VENUE}: fetch_trades -- the public gateway has no trade tape. The "
|
|
811
|
+
f"last trade is on the book (`refresh_quotes`); the full tape is on "
|
|
812
|
+
f"the markets WebSocket."
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
def fetch_ohlcv(
|
|
816
|
+
self, market_id: str, *, timeframe: str = "1h", since: int | None = None,
|
|
817
|
+
until: int | None = None, limit: int | None = None,
|
|
818
|
+
) -> list[Candle]:
|
|
819
|
+
"""Bars built from the venue's quote-derived price samples, in the YES price.
|
|
820
|
+
|
|
821
|
+
Every bar is `price_source="bid_ask_mid"` with `volume=None`: the venue
|
|
822
|
+
publishes display prices derived from the best ask and best bid, not
|
|
823
|
+
executions.
|
|
824
|
+
|
|
825
|
+
Without `since` or `until`, the venue's fixed window is used: one week
|
|
826
|
+
for intraday timeframes, the whole history for daily. With either, an
|
|
827
|
+
absolute window is sent and the result trimmed to it, because the
|
|
828
|
+
venue can answer with samples from outside the window asked for.
|
|
829
|
+
|
|
830
|
+
Samples are asked for at one-minute fidelity and bucketed here, except
|
|
831
|
+
for daily bars, which come from the whole-history daily series (one
|
|
832
|
+
small request) and are trimmed to the window. The venue's coarser
|
|
833
|
+
fidelities come back empty for most windows (60 minutes over one week,
|
|
834
|
+
any fidelity above one over an absolute window), while one-minute
|
|
835
|
+
samples come back for every window.
|
|
836
|
+
|
|
837
|
+
With `since` and `limit`, the window is read forward in pieces of
|
|
838
|
+
`limit` periods and stops once it has `limit` bars, rather than
|
|
839
|
+
downloading every one-minute sample up to now.
|
|
840
|
+
"""
|
|
841
|
+
slug = _slug_of(self.native(market_id))
|
|
842
|
+
seconds = timeframe_seconds(timeframe)
|
|
843
|
+
params: dict[str, Any] = {"symbol": slug, "fidelity": 1}
|
|
844
|
+
if seconds >= 86400 or not (since or until):
|
|
845
|
+
if seconds <= 3600 * 6:
|
|
846
|
+
params["fixedInterval"] = "INTERVAL_1W"
|
|
847
|
+
else:
|
|
848
|
+
params["fixedInterval"], params["fidelity"] = "INTERVAL_ALL", 1440
|
|
849
|
+
payload = self.http.get("/price-history", params)
|
|
850
|
+
candles = candles_from_price_history(payload.get("history") or [], interval_seconds=seconds)
|
|
851
|
+
if since or until:
|
|
852
|
+
end = int((until or _now_ms()) / 1000)
|
|
853
|
+
start = int(since / 1000) if since else end - seconds * (limit or 100)
|
|
854
|
+
candles = [c for c in candles if start * 1000 <= c.timestamp <= end * 1000]
|
|
855
|
+
return pick_bars(candles, since=since, limit=limit)
|
|
856
|
+
end = int((until or _now_ms()) / 1000)
|
|
857
|
+
start = int(since / 1000) if since else end - seconds * (limit or 100)
|
|
858
|
+
span = max(end - start, 1)
|
|
859
|
+
if since and limit:
|
|
860
|
+
span = max(seconds * (limit + 1), 86400)
|
|
861
|
+
samples: dict[float, dict[str, Any]] = {}
|
|
862
|
+
candles = []
|
|
863
|
+
for piece_start in range(start, max(end, start + 1), span):
|
|
864
|
+
piece = {**params, "timestamp.startTimestamp": piece_start,
|
|
865
|
+
"timestamp.endTimestamp": min(piece_start + span, end)}
|
|
866
|
+
for point in self.http.get("/price-history", piece).get("history") or []:
|
|
867
|
+
stamp = to_float(point.get("timestamp", point.get("t")))
|
|
868
|
+
if stamp is not None:
|
|
869
|
+
samples[stamp] = point
|
|
870
|
+
history = [samples[stamp] for stamp in sorted(samples)]
|
|
871
|
+
# The venue can answer with samples from outside the window asked for.
|
|
872
|
+
candles = [c for c in candles_from_price_history(history, interval_seconds=seconds)
|
|
873
|
+
if start * 1000 <= c.timestamp <= end * 1000]
|
|
874
|
+
if enough_bars(candles, since=since, limit=limit, strict=True):
|
|
875
|
+
break
|
|
876
|
+
return pick_bars(candles, since=since, limit=limit)
|
|
877
|
+
|
|
878
|
+
# -- reference ----------------------------------------------------------
|
|
879
|
+
|
|
880
|
+
def fetch_series(self, series_id: str) -> Series:
|
|
881
|
+
if series_id.isdigit():
|
|
882
|
+
payload = self.http.get(f"/series/id/{series_id}")
|
|
883
|
+
raw = payload.get("series") if isinstance(payload, dict) else None
|
|
884
|
+
if isinstance(raw, list):
|
|
885
|
+
raw = raw[0] if raw else None
|
|
886
|
+
else:
|
|
887
|
+
payload = self.http.get("/series", {"slug": series_id, "limit": 1})
|
|
888
|
+
rows = payload.get("series") if isinstance(payload, dict) else None
|
|
889
|
+
raw = rows[0] if isinstance(rows, list) and rows else None
|
|
890
|
+
if not raw:
|
|
891
|
+
raise MarketNotFound(f"{VENUE}: no series {series_id}")
|
|
892
|
+
return normalize_series(raw)
|
|
893
|
+
|
|
894
|
+
def fetch_fee_schedule(self, market_id: str) -> FeeSchedule:
|
|
895
|
+
"""The fee the venue publishes for this market: its `feeCoefficient`
|
|
896
|
+
as the taker theta, the venue-wide maker rebate alongside."""
|
|
897
|
+
market = self.fetch_market(market_id)
|
|
898
|
+
schedule = fee_schedule_of(market.info)
|
|
899
|
+
if schedule is None:
|
|
900
|
+
raise MarketNotFound(f"{VENUE}: market {market_id} publishes no fee coefficient")
|
|
901
|
+
return schedule
|
|
902
|
+
|
|
903
|
+
def close(self) -> None:
|
|
904
|
+
self.http.close()
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# ---------------------------------------------------------------------------
|
|
908
|
+
# Helpers
|
|
909
|
+
# ---------------------------------------------------------------------------
|
|
910
|
+
|
|
911
|
+
def _now_ms() -> int:
|
|
912
|
+
return int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _status_flags(status: str) -> tuple[str | None, str | None]:
|
|
916
|
+
"""The venue's `active` / `closed` flags for one of the shared words.
|
|
917
|
+
|
|
918
|
+
`closed` and `settled` both ask the venue for closed markets; which of the
|
|
919
|
+
two a row is comes from its own status enum, so `_with_status` decides
|
|
920
|
+
after the fact. The venue's flags cannot tell them apart.
|
|
921
|
+
"""
|
|
922
|
+
check_status(status)
|
|
923
|
+
if status == "open":
|
|
924
|
+
return "true", "false"
|
|
925
|
+
if status in ("closed", "settled"):
|
|
926
|
+
return None, "true"
|
|
927
|
+
return None, None
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _with_status(items: list, status: str) -> list:
|
|
931
|
+
if status == "all":
|
|
932
|
+
return items
|
|
933
|
+
return [item for item in items if item.status == status]
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _next_offset(offset: int, received: int, wanted: int, fingerprint: str) -> str | None:
|
|
937
|
+
"""A cursor for the next page, or `None` when this one was short."""
|
|
938
|
+
if received < wanted:
|
|
939
|
+
return None
|
|
940
|
+
return encode_cursor(offset + received, fingerprint)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def query_fingerprint(**terms: Any) -> str:
|
|
944
|
+
material = json.dumps(terms, sort_keys=True, separators=(",", ":"))
|
|
945
|
+
return hashlib.sha256(material.encode()).hexdigest()[:8]
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def encode_cursor(position: int, fingerprint: str) -> str:
|
|
949
|
+
"""Pack an offset (or a search page) with the query it counts rows of.
|
|
950
|
+
|
|
951
|
+
An offset only means something for one particular query; resuming it
|
|
952
|
+
under a different status or search term lands somewhere arbitrary. The
|
|
953
|
+
fingerprint turns that from silent misalignment into an error.
|
|
954
|
+
"""
|
|
955
|
+
packed = json.dumps({"o": position, "f": fingerprint}, separators=(",", ":"))
|
|
956
|
+
return base64.urlsafe_b64encode(packed.encode()).decode().rstrip("=")
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def decode_cursor(cursor: str | None, fingerprint: str) -> int:
|
|
960
|
+
if not cursor:
|
|
961
|
+
return 0
|
|
962
|
+
try:
|
|
963
|
+
padded = cursor + "=" * (-len(cursor) % 4)
|
|
964
|
+
state = json.loads(base64.urlsafe_b64decode(padded.encode()))
|
|
965
|
+
position, carried = state["o"], state["f"]
|
|
966
|
+
except Exception:
|
|
967
|
+
raise BadRequest(
|
|
968
|
+
f"{VENUE}: {cursor!r} is not a cursor this API issued; pass the "
|
|
969
|
+
f"`next_cursor` from a previous page, or omit it to start over"
|
|
970
|
+
) from None
|
|
971
|
+
if not isinstance(position, int) or isinstance(position, bool) or position < 0:
|
|
972
|
+
raise BadRequest(f"{VENUE}: malformed cursor {cursor!r}")
|
|
973
|
+
if carried != fingerprint:
|
|
974
|
+
raise BadRequest(
|
|
975
|
+
f"{VENUE}: this cursor belongs to a different query -- the offset it "
|
|
976
|
+
f"carries counts rows of the search and status it was issued for. Keep "
|
|
977
|
+
f"those arguments the same while paging, or start over without a cursor."
|
|
978
|
+
)
|
|
979
|
+
return position
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _check_side(side: str) -> None:
|
|
983
|
+
if side not in ("yes", "no"):
|
|
984
|
+
raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'")
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _slug_of(market_id: str) -> str:
|
|
988
|
+
"""The slug or numeric id the venue keys on, from a bare native id."""
|
|
989
|
+
return market_id
|