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.py
ADDED
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
"""Polymarket: Gamma for the catalog, CLOB for books, Data API for trades.
|
|
2
|
+
|
|
3
|
+
Public reads, no credentials.
|
|
4
|
+
|
|
5
|
+
Three things this adapter does that a thin wrapper does not:
|
|
6
|
+
|
|
7
|
+
**Prices come from the book, not the catalog.** Gamma's `bestBid`, `bestAsk`
|
|
8
|
+
and `outcomePrices` are cached summaries that lag during fast trading — on an
|
|
9
|
+
in-play match Gamma has read 39c while the CLOB book asked 52c at the same
|
|
10
|
+
instant, and the site itself shows the book. Catalog calls carry Gamma's
|
|
11
|
+
numbers because pulling a book per market would cost one request each;
|
|
12
|
+
`fetch_order_book` and `refresh_quotes` read the real book.
|
|
13
|
+
|
|
14
|
+
**The CLOB returns bids ascending and asks descending.** Best bid is the *last*
|
|
15
|
+
bid, best ask the *last* ask. Reading `bids[0]` gives the worst price on the
|
|
16
|
+
book, which is a quiet way to mis-price everything.
|
|
17
|
+
|
|
18
|
+
**Each instrument owns its book.** Unlike Kalshi, YES and NO are separate CLOB
|
|
19
|
+
tokens with independently addressable order books, so nothing here is derived.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from typing import Any, Iterator
|
|
26
|
+
|
|
27
|
+
from . import ids
|
|
28
|
+
from .base import (
|
|
29
|
+
Capability, Exchange, HttpClient, RateLimiter, check_sort, check_status,
|
|
30
|
+
enough_bars, page_limit, pick_bars, timeframe_seconds,
|
|
31
|
+
)
|
|
32
|
+
from .errors import BadRequest, MarketNotFound, NotSupported
|
|
33
|
+
from .types import (
|
|
34
|
+
BookSide, Candle, Event, FeeSchedule, Market, MarketStats, OrderBook, Outcome,
|
|
35
|
+
OrderLevel, Page, Quote, Trade, iso,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
GAMMA_URL = "https://gamma-api.polymarket.com"
|
|
39
|
+
CLOB_URL = "https://clob.polymarket.com"
|
|
40
|
+
DATA_URL = "https://data-api.polymarket.com"
|
|
41
|
+
VENUE = "polymarket"
|
|
42
|
+
|
|
43
|
+
LIMITER = RateLimiter(20.0, burst=20)
|
|
44
|
+
"""Gamma took 30 requests at 43/s without complaint, so this is headroom rather
|
|
45
|
+
than a measured ceiling: present so an undocumented limit throttles us instead
|
|
46
|
+
of failing us. Shared process-wide, as Kalshi's is."""
|
|
47
|
+
|
|
48
|
+
BOOK_BATCH = 500
|
|
49
|
+
"""Tokens per POST /books. 500 answered in 0.32s; 672 was "Payload exceeds the limit"."""
|
|
50
|
+
|
|
51
|
+
MARKET_BATCH = 100
|
|
52
|
+
"""Gamma enforces this: 200 ids returns `422 expected array length <= 100`."""
|
|
53
|
+
|
|
54
|
+
TRADE_PAGE = 500
|
|
55
|
+
"""Rows per Data API `/trades` call, its maximum."""
|
|
56
|
+
|
|
57
|
+
MAX_TRADE_PAGES = 20
|
|
58
|
+
"""Most tape pages one `fetch_ohlcv` call reads: 10,000 trades. A hot market
|
|
59
|
+
was measured printing 500 trades in 68 minutes, so this is a few hours of
|
|
60
|
+
one and weeks of a quiet one. Past it the call stops and marks the earliest
|
|
61
|
+
bar incomplete rather than walking the tape indefinitely."""
|
|
62
|
+
|
|
63
|
+
QUOTE_WINDOW = 14 * 86400
|
|
64
|
+
"""Widest `startTs`..`endTs` span, in seconds, one `prices-history` request is
|
|
65
|
+
sent. The venue refuses a wider one (`interval is too long`; 15 days measured
|
|
66
|
+
fine, 16 refused), so longer windows are read in pieces and joined."""
|
|
67
|
+
|
|
68
|
+
DEFAULT_BARS = 100
|
|
69
|
+
"""Bars looked back when neither `since` nor `limit` bounds the window."""
|
|
70
|
+
|
|
71
|
+
SEARCH_PAGE = 50
|
|
72
|
+
"""Events per `/public-search` page: its maximum; a larger `limit_per_type`
|
|
73
|
+
is answered with 50."""
|
|
74
|
+
|
|
75
|
+
MAX_SEARCH_PAGES = 5
|
|
76
|
+
"""Most search pages one `fetch_markets(query=...)` call reads while filling
|
|
77
|
+
`limit` markets. Past it the call returns what it has, with a cursor."""
|
|
78
|
+
|
|
79
|
+
MAX_TRADE_OFFSET = 10_000
|
|
80
|
+
"""The deepest offset the Data API's `/trades` accepts; past it the venue
|
|
81
|
+
answers `max historical trades offset of 10000 exceeded`."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
SORT_FIELDS = {"volume": "volume24hr", "liquidity": "liquidityNum", "newest": "startDate"}
|
|
85
|
+
"""This library's sort keys as Gamma's own field names."""
|
|
86
|
+
|
|
87
|
+
from .base import MAX_PAGE_LIMIT # noqa: E402 (kept next to the cap it mirrors)
|
|
88
|
+
"""Gamma's keyset endpoints cap a page at 100 rows whatever is asked for, which
|
|
89
|
+
is why `MAX_PAGE_LIMIT` is 100 for every venue."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Pure normalizers
|
|
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 parse_ts(value: Any) -> int | None:
|
|
106
|
+
"""Gamma sends ISO strings; the CLOB sends epoch milliseconds as a string;
|
|
107
|
+
the Data API sends epoch seconds as a number. All three appear here."""
|
|
108
|
+
if value in (None, ""):
|
|
109
|
+
return None
|
|
110
|
+
if isinstance(value, (int, float)):
|
|
111
|
+
# Seconds or milliseconds, told apart by magnitude: 10^11 seconds is
|
|
112
|
+
# the year 5138, so anything larger is already milliseconds.
|
|
113
|
+
return int(value if value > 1e11 else value * 1000)
|
|
114
|
+
text = str(value)
|
|
115
|
+
if text.isdigit():
|
|
116
|
+
return parse_ts(int(text))
|
|
117
|
+
try:
|
|
118
|
+
return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() * 1000)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def json_list(value: Any) -> list[Any]:
|
|
124
|
+
"""Gamma returns several array fields as JSON-encoded strings."""
|
|
125
|
+
if value in (None, ""):
|
|
126
|
+
return []
|
|
127
|
+
if isinstance(value, list):
|
|
128
|
+
return value
|
|
129
|
+
try:
|
|
130
|
+
parsed = json.loads(value)
|
|
131
|
+
return parsed if isinstance(parsed, list) else []
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def quoted(value: Any) -> float | None:
|
|
137
|
+
"""A price, or `None` when the book is empty on that side.
|
|
138
|
+
|
|
139
|
+
Polymarket reports an empty side as 0 or 1 the way Kalshi does. A resting
|
|
140
|
+
order sits strictly inside.
|
|
141
|
+
"""
|
|
142
|
+
price = to_float(value)
|
|
143
|
+
if price is None or price <= 0 or price >= 1:
|
|
144
|
+
return None
|
|
145
|
+
return price
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def status_of(market: dict[str, Any]) -> tuple[str, str | None, bool]:
|
|
149
|
+
"""(normalized status, native status, accepting orders)."""
|
|
150
|
+
accepting = bool(market.get("acceptingOrders"))
|
|
151
|
+
if market.get("closed"):
|
|
152
|
+
statuses = [str(s).lower() for s in json_list(market.get("umaResolutionStatuses"))]
|
|
153
|
+
resolved = market.get("umaResolutionStatus") == "resolved" or "resolved" in statuses
|
|
154
|
+
return ("settled" if resolved else "closed"), "closed", False
|
|
155
|
+
if market.get("active"):
|
|
156
|
+
return "open", "active", accepting
|
|
157
|
+
return "unopened", "inactive", False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fee_schedule_of(market: dict[str, Any]) -> FeeSchedule | None:
|
|
161
|
+
"""Polymarket's published per-market fee, when fees are switched on.
|
|
162
|
+
|
|
163
|
+
The venue charges `C * rate * (P * (1 - P)) ** exponent` to the taker
|
|
164
|
+
at match time (its fee docs, and the V2 client's own arithmetic), so it
|
|
165
|
+
is normalized to `quadratic_theta`. Makers are never charged: with
|
|
166
|
+
`takerOnly` the maker rate is a known zero, not an unknown. `rebateRate`
|
|
167
|
+
is the share of fees paid back to makers daily, not a per-trade figure,
|
|
168
|
+
and stays in `info`.
|
|
169
|
+
"""
|
|
170
|
+
if not market.get("feesEnabled"):
|
|
171
|
+
return None
|
|
172
|
+
schedule = market.get("feeSchedule") or {}
|
|
173
|
+
rate = to_float(schedule.get("rate"))
|
|
174
|
+
if rate is None:
|
|
175
|
+
return None
|
|
176
|
+
taker_only = bool(schedule.get("takerOnly"))
|
|
177
|
+
return FeeSchedule(
|
|
178
|
+
venue=VENUE,
|
|
179
|
+
scope="market",
|
|
180
|
+
scope_id=str(market.get("id") or ""),
|
|
181
|
+
fee_type="quadratic_theta",
|
|
182
|
+
taker_rate=rate,
|
|
183
|
+
maker_rate=0.0 if taker_only else rate,
|
|
184
|
+
exponent=to_float(schedule.get("exponent")),
|
|
185
|
+
info={"feeType": market.get("feeType"), "feeSchedule": schedule},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def settlement_sources_of(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
|
190
|
+
"""Gamma's `resolutionSource`, in the shape every venue reports here.
|
|
191
|
+
|
|
192
|
+
It is one free-text field rather than a list, and it is often a bare URL
|
|
193
|
+
and often empty. Normalized into `{"name", "url"}` so a caller reads one
|
|
194
|
+
shape across venues; an empty field stays an empty list rather than
|
|
195
|
+
becoming a source named "".
|
|
196
|
+
"""
|
|
197
|
+
source = (raw.get("resolutionSource") or "").strip()
|
|
198
|
+
if not source:
|
|
199
|
+
return []
|
|
200
|
+
return [{"name": source, "url": source if source.startswith("http") else None}]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def normalize_market(market: dict[str, Any], event: dict[str, Any] | None = None) -> Market:
|
|
204
|
+
"""One Gamma market payload as a unified `Market`."""
|
|
205
|
+
event = event or {}
|
|
206
|
+
market_id = str(market["id"])
|
|
207
|
+
status, native, accepting = status_of(market)
|
|
208
|
+
labels = [str(o) for o in json_list(market.get("outcomes"))] or ["Yes", "No"]
|
|
209
|
+
token_ids = [str(t) for t in json_list(market.get("clobTokenIds"))]
|
|
210
|
+
prices = [to_float(p) for p in json_list(market.get("outcomePrices"))]
|
|
211
|
+
|
|
212
|
+
best_bid = quoted(market.get("bestBid"))
|
|
213
|
+
best_ask = quoted(market.get("bestAsk"))
|
|
214
|
+
last = quoted(market.get("lastTradePrice"))
|
|
215
|
+
last_ts = parse_ts(market.get("updatedAt"))
|
|
216
|
+
change = to_float(market.get("oneDayPriceChange"))
|
|
217
|
+
|
|
218
|
+
if len(labels) != 2:
|
|
219
|
+
raise BadRequest(f"polymarket: market {market_id} has {len(labels)} outcomes; a Synpath market is binary")
|
|
220
|
+
|
|
221
|
+
def outcome(index: int) -> Outcome:
|
|
222
|
+
token = token_ids[index] if index < len(token_ids) else None
|
|
223
|
+
# Gamma publishes one summary bid/ask pair, for the first outcome only.
|
|
224
|
+
# Giving the complement a mirrored copy would be inventing a quote the
|
|
225
|
+
# venue never sent, so the other side carries only its own price until
|
|
226
|
+
# someone asks for its book.
|
|
227
|
+
if index == 0:
|
|
228
|
+
bid, ask = best_bid, best_ask
|
|
229
|
+
outcome_last = last
|
|
230
|
+
else:
|
|
231
|
+
bid = ask = None
|
|
232
|
+
outcome_last = round(1 - last, 6) if last is not None else None
|
|
233
|
+
mid = round((bid + ask) / 2, 6) if bid is not None and ask is not None else None
|
|
234
|
+
# `outcomePrices` is Gamma's own mark for the outcome — a price, but not
|
|
235
|
+
# one of the four the book defines, so it is kept in `info` rather than
|
|
236
|
+
# promoted into a field it would misrepresent.
|
|
237
|
+
return Outcome(
|
|
238
|
+
label=labels[index],
|
|
239
|
+
quote=Quote(
|
|
240
|
+
bid=bid, ask=ask, mid=mid,
|
|
241
|
+
last=outcome_last,
|
|
242
|
+
last_timestamp=last_ts if outcome_last is not None else None,
|
|
243
|
+
last_datetime=iso(last_ts) if outcome_last is not None else None,
|
|
244
|
+
),
|
|
245
|
+
venue_token_id=token,
|
|
246
|
+
price_change_24h=change if index == 0 else (-change if change is not None else None),
|
|
247
|
+
info={"gamma_outcome_price": prices[index] if index < len(prices) else None},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
event_slug = event.get("slug") or ""
|
|
251
|
+
slug = market.get("slug") or ""
|
|
252
|
+
url = (
|
|
253
|
+
f"https://polymarket.com/event/{event_slug}/{slug}" if event_slug and slug
|
|
254
|
+
else f"https://polymarket.com/market/{slug}" if slug else None
|
|
255
|
+
)
|
|
256
|
+
tags = [
|
|
257
|
+
str(tag.get("slug") or tag.get("label") or "").lower()
|
|
258
|
+
for tag in (event.get("tags") or []) if isinstance(tag, dict)
|
|
259
|
+
]
|
|
260
|
+
return Market(
|
|
261
|
+
id=ids.qualify(VENUE, market_id),
|
|
262
|
+
venue=VENUE,
|
|
263
|
+
venue_market_id=market_id,
|
|
264
|
+
event_id=ids.qualify(VENUE, str(event["id"])) if event.get("id") is not None else None,
|
|
265
|
+
title=market.get("question") or market_id,
|
|
266
|
+
description=market.get("description"),
|
|
267
|
+
slug=slug or None,
|
|
268
|
+
yes=outcome(0),
|
|
269
|
+
no=outcome(1),
|
|
270
|
+
status=status, # type: ignore[arg-type]
|
|
271
|
+
native_status=native,
|
|
272
|
+
active=accepting,
|
|
273
|
+
open_timestamp=parse_ts(market.get("startDate")),
|
|
274
|
+
open_datetime=iso(parse_ts(market.get("startDate"))),
|
|
275
|
+
close_timestamp=parse_ts(market.get("endDate")),
|
|
276
|
+
close_datetime=iso(parse_ts(market.get("endDate"))),
|
|
277
|
+
resolution_timestamp=parse_ts(market.get("endDate")),
|
|
278
|
+
resolution_datetime=iso(parse_ts(market.get("endDate"))),
|
|
279
|
+
tick_size=to_float(market.get("orderPriceMinTickSize")),
|
|
280
|
+
face_value=1.0,
|
|
281
|
+
book_model="native_per_outcome",
|
|
282
|
+
stats=MarketStats(
|
|
283
|
+
volume_24h=to_float(market.get("volume24hr")),
|
|
284
|
+
volume_total=to_float(market.get("volumeNum")) or to_float(market.get("volume")),
|
|
285
|
+
liquidity=to_float(market.get("liquidityNum")) or to_float(market.get("liquidity")),
|
|
286
|
+
# Gamma publishes no open interest for a market.
|
|
287
|
+
open_interest=None,
|
|
288
|
+
# Both figures are USDC notional here, which is *not* the unit
|
|
289
|
+
# Kalshi reports volume in. Comparing the two raw numbers across
|
|
290
|
+
# venues is a category error, so each says which it is.
|
|
291
|
+
volume_unit="collateral",
|
|
292
|
+
liquidity_unit="collateral",
|
|
293
|
+
as_of=parse_ts(market.get("updatedAt")),
|
|
294
|
+
),
|
|
295
|
+
url=url,
|
|
296
|
+
image_url=market.get("image") or market.get("icon"),
|
|
297
|
+
category=(tags or [None])[0],
|
|
298
|
+
tags=tags,
|
|
299
|
+
series_id=None,
|
|
300
|
+
outcome_label=market.get("groupItemTitle") or None,
|
|
301
|
+
neg_risk=market.get("negRisk"),
|
|
302
|
+
settlement_sources=settlement_sources_of(market) or settlement_sources_of(event),
|
|
303
|
+
info=market,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def normalize_event(event: dict[str, Any]) -> Event:
|
|
308
|
+
markets = [normalize_market(m, event) for m in (event.get("markets") or [])]
|
|
309
|
+
statuses = {m.status for m in markets}
|
|
310
|
+
for candidate in ("open", "closed", "settled", "unopened"):
|
|
311
|
+
if candidate in statuses:
|
|
312
|
+
status = candidate
|
|
313
|
+
break
|
|
314
|
+
else:
|
|
315
|
+
status = "unopened"
|
|
316
|
+
tags = [
|
|
317
|
+
str(tag.get("slug") or tag.get("label") or "").lower()
|
|
318
|
+
for tag in (event.get("tags") or []) if isinstance(tag, dict)
|
|
319
|
+
]
|
|
320
|
+
event_id = str(event["id"])
|
|
321
|
+
return Event(
|
|
322
|
+
id=ids.qualify(VENUE, event_id),
|
|
323
|
+
venue=VENUE,
|
|
324
|
+
venue_event_id=event_id,
|
|
325
|
+
title=event.get("title") or event_id,
|
|
326
|
+
description=event.get("description"),
|
|
327
|
+
slug=event.get("slug"),
|
|
328
|
+
markets=markets,
|
|
329
|
+
status=status, # type: ignore[arg-type]
|
|
330
|
+
native_status="closed" if event.get("closed") else "active" if event.get("active") else None,
|
|
331
|
+
category=(tags or [None])[0],
|
|
332
|
+
tags=tags,
|
|
333
|
+
mutually_exclusive=(
|
|
334
|
+
bool(event["negRisk"]) if event.get("negRisk") is not None else None
|
|
335
|
+
),
|
|
336
|
+
close_timestamp=parse_ts(event.get("endDate")),
|
|
337
|
+
close_datetime=iso(parse_ts(event.get("endDate"))),
|
|
338
|
+
url=f"https://polymarket.com/event/{event.get('slug')}" if event.get("slug") else None,
|
|
339
|
+
image_url=event.get("image") or event.get("icon"),
|
|
340
|
+
settlement_sources=settlement_sources_of(event),
|
|
341
|
+
info={k: v for k, v in event.items() if k != "markets"},
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def normalize_order_book(
|
|
346
|
+
payload: dict[str, Any], *, market_id: str = "", side: BookSide = "yes", depth: int | None = None,
|
|
347
|
+
) -> OrderBook:
|
|
348
|
+
"""A CLOB book for one token, sorted best-first, labelled with the market
|
|
349
|
+
and side the token belongs to.
|
|
350
|
+
|
|
351
|
+
The venue sends bids ascending and asks descending — worst price first on
|
|
352
|
+
both sides. They are re-sorted here so `bids[0]` and `asks[0]` mean what
|
|
353
|
+
every other order book in the world means.
|
|
354
|
+
"""
|
|
355
|
+
def levels(rows: Any) -> list[OrderLevel]:
|
|
356
|
+
out = []
|
|
357
|
+
for row in rows or []:
|
|
358
|
+
price, size = to_float(row.get("price")), to_float(row.get("size"))
|
|
359
|
+
if price is None or size is None or size <= 0:
|
|
360
|
+
continue
|
|
361
|
+
out.append(OrderLevel(price=price, size=size))
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
bids = sorted(levels(payload.get("bids")), key=lambda level: level.price, reverse=True)
|
|
365
|
+
asks = sorted(levels(payload.get("asks")), key=lambda level: level.price)
|
|
366
|
+
if depth:
|
|
367
|
+
bids, asks = bids[:depth], asks[:depth]
|
|
368
|
+
timestamp = parse_ts(payload.get("timestamp"))
|
|
369
|
+
return OrderBook(
|
|
370
|
+
market_id=market_id or str(payload.get("market") or ""),
|
|
371
|
+
side=side,
|
|
372
|
+
venue=VENUE,
|
|
373
|
+
bids=bids,
|
|
374
|
+
asks=asks,
|
|
375
|
+
timestamp=timestamp,
|
|
376
|
+
datetime=iso(timestamp),
|
|
377
|
+
book_model="native_per_outcome",
|
|
378
|
+
derived=False,
|
|
379
|
+
depth_scope="top_n" if depth else "full",
|
|
380
|
+
info=payload,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def normalize_trade(trade: dict[str, Any], *, market_id: str = "", no_token: str | None = None) -> Trade:
|
|
385
|
+
"""One execution in the YES price. The Data API reports a trade on the
|
|
386
|
+
token it happened on; a NO trade at 0.30 is the same execution as a YES
|
|
387
|
+
trade at 0.70 with the sides swapped, and is reported that way."""
|
|
388
|
+
timestamp = parse_ts(trade.get("timestamp")) or 0
|
|
389
|
+
side = str(trade.get("side") or "").lower()
|
|
390
|
+
price = to_float(trade.get("price")) or 0.0
|
|
391
|
+
on_no = no_token is not None and str(trade.get("asset") or "") == no_token
|
|
392
|
+
if not on_no and no_token is None and trade.get("outcomeIndex") not in (None, 0, "0"):
|
|
393
|
+
on_no = True
|
|
394
|
+
if on_no:
|
|
395
|
+
price = round(1 - price, 6)
|
|
396
|
+
side = {"buy": "sell", "sell": "buy"}.get(side, side)
|
|
397
|
+
return Trade(
|
|
398
|
+
id=str(trade.get("transactionHash") or trade.get("id") or ""),
|
|
399
|
+
market_id=market_id or str(trade.get("conditionId") or ""),
|
|
400
|
+
timestamp=timestamp,
|
|
401
|
+
datetime=iso(timestamp) or "",
|
|
402
|
+
price=price,
|
|
403
|
+
amount=to_float(trade.get("size")) or 0.0,
|
|
404
|
+
side=side if side in ("buy", "sell") else "unknown",
|
|
405
|
+
info=trade,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def candles_from_price_history(
|
|
410
|
+
history: list[dict[str, Any]], *, interval_seconds: int,
|
|
411
|
+
) -> list[Candle]:
|
|
412
|
+
"""Bucket Polymarket's price samples into bars.
|
|
413
|
+
|
|
414
|
+
The venue publishes no candles — `prices-history` returns `{t, p}` samples
|
|
415
|
+
of the midpoint at a fixed cadence, with no volume and no trade count. What
|
|
416
|
+
comes back from bucketing them is therefore not an OHLCV bar: open, high,
|
|
417
|
+
low and close are of the *quoted* price rather than executions, and volume
|
|
418
|
+
is `None` rather than 0. Every bar says so in `price_source`.
|
|
419
|
+
"""
|
|
420
|
+
buckets: dict[int, list[float]] = {}
|
|
421
|
+
for point in history:
|
|
422
|
+
seconds = to_float(point.get("t"))
|
|
423
|
+
price = to_float(point.get("p"))
|
|
424
|
+
if seconds is None or price is None:
|
|
425
|
+
continue
|
|
426
|
+
start = int(seconds // interval_seconds) * interval_seconds
|
|
427
|
+
buckets.setdefault(start, []).append(price)
|
|
428
|
+
|
|
429
|
+
candles = []
|
|
430
|
+
for start in sorted(buckets):
|
|
431
|
+
prices = buckets[start]
|
|
432
|
+
candles.append(Candle(
|
|
433
|
+
timestamp=start * 1000,
|
|
434
|
+
datetime=iso(start * 1000) or "",
|
|
435
|
+
open=prices[0], high=max(prices), low=min(prices), close=prices[-1],
|
|
436
|
+
volume=None,
|
|
437
|
+
trade_count=None,
|
|
438
|
+
price_source="sampled_mid",
|
|
439
|
+
info={"samples": len(prices)},
|
|
440
|
+
))
|
|
441
|
+
return candles
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def candles_from_trades(
|
|
445
|
+
trades: list[dict[str, Any]], *, yes_token: str, interval_seconds: int,
|
|
446
|
+
face_value: float = 1.0,
|
|
447
|
+
) -> list[Candle]:
|
|
448
|
+
"""Bars built from executions, in the YES price.
|
|
449
|
+
|
|
450
|
+
A Polymarket market trades as two tokens, YES and NO, and the Data API
|
|
451
|
+
returns both under the market's condition id with `asset` and
|
|
452
|
+
`outcomeIndex` on each row. A trade on the other token is the same
|
|
453
|
+
execution seen from the other side, so it is folded in at `face_value -
|
|
454
|
+
price` rather than dropped -- the way an exchange-wide tape would show it,
|
|
455
|
+
and the way Predexon's condition-level candles combine the two. Volume is
|
|
456
|
+
in contracts (the venue's `size`), and `trade_count` counts every
|
|
457
|
+
execution in the bar, both tokens.
|
|
458
|
+
"""
|
|
459
|
+
buckets: dict[int, list[tuple[float, float, float]]] = {}
|
|
460
|
+
for trade in trades:
|
|
461
|
+
seconds = to_float(trade.get("timestamp"))
|
|
462
|
+
price = to_float(trade.get("price"))
|
|
463
|
+
size = to_float(trade.get("size"))
|
|
464
|
+
if seconds is None or price is None or size is None:
|
|
465
|
+
continue
|
|
466
|
+
if str(trade.get("asset") or "") != yes_token:
|
|
467
|
+
price = round(face_value - price, 6)
|
|
468
|
+
start = int(seconds // interval_seconds) * interval_seconds
|
|
469
|
+
buckets.setdefault(start, []).append((seconds, price, size))
|
|
470
|
+
|
|
471
|
+
candles = []
|
|
472
|
+
for start in sorted(buckets):
|
|
473
|
+
rows = sorted(buckets[start], key=lambda row: row[0])
|
|
474
|
+
prices = [row[1] for row in rows]
|
|
475
|
+
candles.append(Candle(
|
|
476
|
+
timestamp=start * 1000,
|
|
477
|
+
datetime=iso(start * 1000) or "",
|
|
478
|
+
open=prices[0], high=max(prices), low=min(prices), close=prices[-1],
|
|
479
|
+
volume=round(sum(row[2] for row in rows), 6),
|
|
480
|
+
trade_count=len(rows),
|
|
481
|
+
price_source="trade",
|
|
482
|
+
info={},
|
|
483
|
+
))
|
|
484
|
+
return candles
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
# Adapter
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
class Polymarket(Exchange):
|
|
492
|
+
"""Polymarket, read-only.
|
|
493
|
+
|
|
494
|
+
```python
|
|
495
|
+
import synpath
|
|
496
|
+
|
|
497
|
+
poly = synpath.Polymarket()
|
|
498
|
+
markets = poly.fetch_markets(query="Fed", limit=5)
|
|
499
|
+
book = poly.fetch_order_book(markets[0].id)
|
|
500
|
+
```
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
id = VENUE
|
|
504
|
+
name = "Polymarket"
|
|
505
|
+
book_model = "native_per_outcome"
|
|
506
|
+
has: dict[str, Capability] = {
|
|
507
|
+
"fetch_markets": True,
|
|
508
|
+
"fetch_events": True,
|
|
509
|
+
"fetch_market": True,
|
|
510
|
+
"fetch_markets_by_ids": True,
|
|
511
|
+
"sort": True,
|
|
512
|
+
"fetch_order_book": True,
|
|
513
|
+
"fetch_order_books": True,
|
|
514
|
+
"fetch_trades": True,
|
|
515
|
+
# Built from the Data API's trade tape, both tokens folded into the
|
|
516
|
+
# asked-for side, with volume and a trade count. The venue's own
|
|
517
|
+
# `prices-history` is quote samples and is reachable as an option.
|
|
518
|
+
"fetch_ohlcv": True,
|
|
519
|
+
# No series tier on this venue.
|
|
520
|
+
"fetch_series": False,
|
|
521
|
+
"fetch_fee_schedule": True,
|
|
522
|
+
"search": True,
|
|
523
|
+
"watch_order_book": False,
|
|
524
|
+
# Whether a market here is the same question as one somewhere else is
|
|
525
|
+
# not a question this venue can be asked. See `synpath.match_market`.
|
|
526
|
+
"match_market": False,
|
|
527
|
+
"match_event": False,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
def __init__(
|
|
531
|
+
self,
|
|
532
|
+
*,
|
|
533
|
+
gamma_url: str = GAMMA_URL,
|
|
534
|
+
clob_url: str = CLOB_URL,
|
|
535
|
+
data_url: str = DATA_URL,
|
|
536
|
+
timeout: float = 30.0,
|
|
537
|
+
limiter: RateLimiter | None = LIMITER,
|
|
538
|
+
client: Any = None,
|
|
539
|
+
):
|
|
540
|
+
self.gamma = HttpClient(gamma_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE)
|
|
541
|
+
self.clob = HttpClient(clob_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE)
|
|
542
|
+
self.data = HttpClient(data_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE)
|
|
543
|
+
self._tokens: dict[str, tuple[str, str, str]] = {}
|
|
544
|
+
"""Per market: `(yes_token, no_token, condition_id)`, filled by every catalog read."""
|
|
545
|
+
|
|
546
|
+
# -- catalog ------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
def fetch_events(
|
|
549
|
+
self, *, query: str | None = None, limit: int | None = None,
|
|
550
|
+
cursor: str | None = None, status: str = "open",
|
|
551
|
+
) -> Page[Event]:
|
|
552
|
+
"""One page of events with their markets nested.
|
|
553
|
+
|
|
554
|
+
With `query`, this uses Gamma's server-side search, paged by its page
|
|
555
|
+
number (up to `SEARCH_PAGE` events a page) and filtered to `status`.
|
|
556
|
+
Without one it walks the keyset cursor: the plain `/events` endpoint
|
|
557
|
+
rejects offset beyond 2000 and says in the error to use keyset, so
|
|
558
|
+
keyset is the only complete way through the catalog.
|
|
559
|
+
"""
|
|
560
|
+
if query:
|
|
561
|
+
page_no, _ = _search_cursor(cursor)
|
|
562
|
+
events, more = self._search_page(query, page_no, status=status,
|
|
563
|
+
size=min(page_limit(limit) or SEARCH_PAGE, SEARCH_PAGE))
|
|
564
|
+
return Page(events, next_cursor=_encode_search_cursor(page_no + 1, 0) if more else None)
|
|
565
|
+
|
|
566
|
+
active, closed = _status_flags(status)
|
|
567
|
+
wanted = page_limit(limit)
|
|
568
|
+
payload = self.gamma.get("/events/keyset", {
|
|
569
|
+
"limit": wanted or MAX_PAGE_LIMIT,
|
|
570
|
+
"active": active,
|
|
571
|
+
"closed": closed,
|
|
572
|
+
"after_cursor": cursor,
|
|
573
|
+
})
|
|
574
|
+
events = [normalize_event(e) for e in (payload.get("events") or [])]
|
|
575
|
+
return Page(
|
|
576
|
+
events[:wanted] if wanted else events,
|
|
577
|
+
next_cursor=payload.get("next_cursor"),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def fetch_markets(
|
|
581
|
+
self, *, query: str | None = None, limit: int | None = None,
|
|
582
|
+
cursor: str | None = None, status: str = "open",
|
|
583
|
+
sort: str | None = "volume",
|
|
584
|
+
) -> Page[Market]:
|
|
585
|
+
"""One page of markets, by default the highest 24h volume first.
|
|
586
|
+
|
|
587
|
+
Paged through `/markets/keyset`, not the plain `/markets` endpoint: the
|
|
588
|
+
latter offers only `offset` and rejects it past 2000, so it cannot walk
|
|
589
|
+
the catalog. The keyset cursor keeps the requested ordering.
|
|
590
|
+
|
|
591
|
+
With `query` this searches events and flattens their markets, since
|
|
592
|
+
Gamma's search is over events, keeping the markets in `status`. It
|
|
593
|
+
reads search pages until it has `limit` markets (at most
|
|
594
|
+
`MAX_SEARCH_PAGES` a call) and returns a cursor for the rest.
|
|
595
|
+
"""
|
|
596
|
+
if query:
|
|
597
|
+
wanted = page_limit(limit) or MAX_PAGE_LIMIT
|
|
598
|
+
page_no, skip = _search_cursor(cursor)
|
|
599
|
+
collected: list[Market] = []
|
|
600
|
+
next_cursor: str | None = None
|
|
601
|
+
for _ in range(MAX_SEARCH_PAGES):
|
|
602
|
+
events, more = self._search_page(query, page_no, status=status, size=SEARCH_PAGE)
|
|
603
|
+
markets = _with_status([m for event in events for m in event.markets], status)[skip:]
|
|
604
|
+
room = wanted - len(collected)
|
|
605
|
+
collected += markets[:room]
|
|
606
|
+
if len(markets) > room:
|
|
607
|
+
next_cursor = _encode_search_cursor(page_no, skip + room)
|
|
608
|
+
break
|
|
609
|
+
if not more:
|
|
610
|
+
next_cursor = None
|
|
611
|
+
break
|
|
612
|
+
page_no, skip = page_no + 1, 0
|
|
613
|
+
next_cursor = _encode_search_cursor(page_no, 0)
|
|
614
|
+
if len(collected) >= wanted:
|
|
615
|
+
break
|
|
616
|
+
return Page(collected, next_cursor=next_cursor)
|
|
617
|
+
active, closed = _status_flags(status)
|
|
618
|
+
wanted = page_limit(limit)
|
|
619
|
+
order = SORT_FIELDS[check_sort(sort, venue=VENUE, supported=True)] if sort else None
|
|
620
|
+
payload = self.gamma.get("/markets/keyset", {
|
|
621
|
+
"limit": wanted or MAX_PAGE_LIMIT,
|
|
622
|
+
"active": active,
|
|
623
|
+
"closed": closed,
|
|
624
|
+
"order": order,
|
|
625
|
+
"ascending": "false" if order else None,
|
|
626
|
+
"after_cursor": cursor,
|
|
627
|
+
})
|
|
628
|
+
rows = payload.get("markets") or [] if isinstance(payload, dict) else payload
|
|
629
|
+
markets = [normalize_market(market) for market in rows]
|
|
630
|
+
return Page(
|
|
631
|
+
markets[:wanted] if wanted else markets,
|
|
632
|
+
next_cursor=payload.get("next_cursor") if isinstance(payload, dict) else None,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
def _search_page(self, query: str, page_no: int, *, status: str, size: int) -> tuple[list[Event], bool]:
|
|
636
|
+
"""One page of Gamma's event search, in `status`, and whether the
|
|
637
|
+
venue has more. `events_status=active` narrows the search itself;
|
|
638
|
+
Gamma ignores its `closed` counterpart, so closed is filtered here."""
|
|
639
|
+
_status_flags(status)
|
|
640
|
+
payload = self.gamma.get("/public-search", {
|
|
641
|
+
"q": query, "limit_per_type": size, "page": page_no,
|
|
642
|
+
"events_status": "active" if status == "open" else None,
|
|
643
|
+
})
|
|
644
|
+
events = [normalize_event(e) for e in (payload.get("events") or [])]
|
|
645
|
+
if status != "all":
|
|
646
|
+
events = [e.model_copy(update={"markets": _with_status(e.markets, status)}) for e in events]
|
|
647
|
+
events = [e for e in events if e.markets]
|
|
648
|
+
more = bool((payload.get("pagination") or {}).get("hasMore"))
|
|
649
|
+
return events, more
|
|
650
|
+
|
|
651
|
+
def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]:
|
|
652
|
+
"""Many markets, in the order asked for, closed ones included.
|
|
653
|
+
|
|
654
|
+
Gamma caps a batch at 100, and its batch lookup quietly leaves out
|
|
655
|
+
closed markets unless asked for them with `closed=true`, so the ids a
|
|
656
|
+
first pass did not find are asked for again that way.
|
|
657
|
+
"""
|
|
658
|
+
natives = [self.native(market_id) for market_id in market_ids]
|
|
659
|
+
found: dict[str, Market] = {}
|
|
660
|
+
for closed in (None, "true"):
|
|
661
|
+
missing = [n for n in dict.fromkeys(natives) if n not in found]
|
|
662
|
+
for start in range(0, len(missing), MARKET_BATCH):
|
|
663
|
+
batch = missing[start:start + MARKET_BATCH]
|
|
664
|
+
params = [("id", market_id) for market_id in batch] + [("limit", len(batch))]
|
|
665
|
+
payload = self.gamma.get("/markets", params + ([("closed", closed)] if closed else []))
|
|
666
|
+
for raw in payload if isinstance(payload, list) else []:
|
|
667
|
+
if raw.get("id") is not None:
|
|
668
|
+
found[str(raw["id"])] = self._remember(normalize_market(raw))
|
|
669
|
+
return [found[market_id] for market_id in natives if market_id in found]
|
|
670
|
+
|
|
671
|
+
def fetch_market(self, market_id: str) -> Market:
|
|
672
|
+
native = self.native(market_id)
|
|
673
|
+
payload = self.gamma.get(f"/markets/{native}")
|
|
674
|
+
if not payload or not isinstance(payload, dict) or "id" not in payload:
|
|
675
|
+
raise MarketNotFound(f"polymarket: no market {native}")
|
|
676
|
+
return self._remember(normalize_market(payload))
|
|
677
|
+
|
|
678
|
+
# -- tokens ---------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
def _remember(self, market: Market) -> Market:
|
|
681
|
+
"""Keep the token ids behind a market, so a later book or tape call
|
|
682
|
+
for it needs no catalog round trip."""
|
|
683
|
+
if market.yes.venue_token_id and market.no.venue_token_id:
|
|
684
|
+
self._tokens[market.venue_market_id] = (
|
|
685
|
+
market.yes.venue_token_id, market.no.venue_token_id,
|
|
686
|
+
str(market.info.get("conditionId") or ""),
|
|
687
|
+
)
|
|
688
|
+
return market
|
|
689
|
+
|
|
690
|
+
def _tokens_of(self, market_id: str) -> tuple[str, str, str]:
|
|
691
|
+
"""`(yes_token, no_token, condition_id)` for a market, from the cache
|
|
692
|
+
or one catalog read. Every venue call that touches a book or the tape
|
|
693
|
+
goes through here: the venue keys those on tokens and condition ids,
|
|
694
|
+
the caller holds the market id."""
|
|
695
|
+
native = self.native(market_id)
|
|
696
|
+
if native not in self._tokens:
|
|
697
|
+
self.fetch_market(native)
|
|
698
|
+
if native not in self._tokens:
|
|
699
|
+
raise MarketNotFound(f"polymarket: market {native} publishes no token ids")
|
|
700
|
+
return self._tokens[native]
|
|
701
|
+
|
|
702
|
+
def iter_events(self, *, status: str = "open") -> Iterator[Event]:
|
|
703
|
+
cursor: str | None = None
|
|
704
|
+
while True:
|
|
705
|
+
page = self.fetch_events(cursor=cursor, status=status)
|
|
706
|
+
yield from page
|
|
707
|
+
cursor = page.next_cursor
|
|
708
|
+
if not cursor or not page:
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
# -- market data --------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
def fetch_order_book(
|
|
714
|
+
self, market_id: str, *, side: BookSide = "yes", depth: int | None = None,
|
|
715
|
+
) -> OrderBook:
|
|
716
|
+
"""The live CLOB book on one side of a market. YES and NO are separate
|
|
717
|
+
tokens here with separate books, so `side="no"` is a real book, not a
|
|
718
|
+
reflection."""
|
|
719
|
+
yes_token, no_token, _ = self._tokens_of(market_id)
|
|
720
|
+
token = _token_for(side, yes_token, no_token)
|
|
721
|
+
payload = self.clob.get("/book", {"token_id": token})
|
|
722
|
+
if payload.get("error"):
|
|
723
|
+
raise MarketNotFound(f"polymarket: {payload['error']} ({market_id} {side})")
|
|
724
|
+
payload.setdefault("asset_id", token)
|
|
725
|
+
return normalize_order_book(payload, market_id=self.qualify(market_id), side=side, depth=depth)
|
|
726
|
+
|
|
727
|
+
def fetch_order_books(
|
|
728
|
+
self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None,
|
|
729
|
+
) -> dict[str, OrderBook]:
|
|
730
|
+
"""Books for many markets in one round trip, keyed by Synpath market id.
|
|
731
|
+
|
|
732
|
+
Worth using whenever more than a couple of books are needed: 500 books
|
|
733
|
+
answer in about a third of a second, where the same 500 one at a time
|
|
734
|
+
is 500 requests against a shared rate budget. Markets not yet seen by
|
|
735
|
+
this client cost one catalog read each first, for their token ids.
|
|
736
|
+
"""
|
|
737
|
+
tokens: dict[str, str] = {}
|
|
738
|
+
for market_id in market_ids:
|
|
739
|
+
yes_token, no_token, _ = self._tokens_of(market_id)
|
|
740
|
+
tokens[_token_for(side, yes_token, no_token)] = self.qualify(market_id)
|
|
741
|
+
return {
|
|
742
|
+
tokens[token]: book
|
|
743
|
+
for token, book in self.fetch_order_books_by_token(list(tokens), depth=depth).items()
|
|
744
|
+
if token in tokens
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
def fetch_order_books_by_token(self, token_ids: list[str], *, depth: int | None = None) -> dict[str, OrderBook]:
|
|
748
|
+
"""The venue's batch book call, keyed by CLOB token id, for a caller
|
|
749
|
+
that already holds token ids (`Outcome.venue_token_id`) and wants
|
|
750
|
+
both sides of many markets in one round trip. Polymarket only."""
|
|
751
|
+
books: dict[str, OrderBook] = {}
|
|
752
|
+
for start in range(0, len(token_ids), BOOK_BATCH):
|
|
753
|
+
batch = token_ids[start:start + BOOK_BATCH]
|
|
754
|
+
payload = self.clob.post("/books", json=[{"token_id": t} for t in batch])
|
|
755
|
+
for raw in payload or []:
|
|
756
|
+
token = str(raw.get("asset_id") or "")
|
|
757
|
+
if token:
|
|
758
|
+
books[token] = normalize_order_book(raw, depth=depth)
|
|
759
|
+
return books
|
|
760
|
+
|
|
761
|
+
def refresh_quotes(self, market: Market) -> Market:
|
|
762
|
+
"""`market` with every instrument's quote re-read from the live book.
|
|
763
|
+
|
|
764
|
+
Catalog responses carry Gamma's cached summary, which lags during fast
|
|
765
|
+
trading and only covers the first outcome. This replaces all of it with
|
|
766
|
+
the real book, in one request for the whole market.
|
|
767
|
+
"""
|
|
768
|
+
tokens = [o.venue_token_id for o in (market.yes, market.no) if o.venue_token_id]
|
|
769
|
+
if not tokens:
|
|
770
|
+
return market
|
|
771
|
+
books = self.fetch_order_books_by_token(tokens)
|
|
772
|
+
refreshed = market.model_copy(deep=True)
|
|
773
|
+
for instrument in (refreshed.yes, refreshed.no):
|
|
774
|
+
book = books.get(instrument.venue_token_id or "")
|
|
775
|
+
if book is None:
|
|
776
|
+
continue
|
|
777
|
+
best_bid, best_ask = book.best_bid, book.best_ask
|
|
778
|
+
bid = best_bid.price if best_bid else None
|
|
779
|
+
ask = best_ask.price if best_ask else None
|
|
780
|
+
last = quoted((book.info or {}).get("last_trade_price"))
|
|
781
|
+
instrument.quote = Quote(
|
|
782
|
+
bid=bid,
|
|
783
|
+
bid_size=best_bid.size if best_bid else None,
|
|
784
|
+
ask=ask,
|
|
785
|
+
ask_size=best_ask.size if best_ask else None,
|
|
786
|
+
mid=round((bid + ask) / 2, 6) if bid is not None and ask is not None else None,
|
|
787
|
+
last=last if last is not None else instrument.quote.last,
|
|
788
|
+
last_timestamp=book.timestamp if last is not None else instrument.quote.last_timestamp,
|
|
789
|
+
last_datetime=book.datetime if last is not None else instrument.quote.last_datetime,
|
|
790
|
+
)
|
|
791
|
+
return refreshed
|
|
792
|
+
|
|
793
|
+
def fetch_trades(
|
|
794
|
+
self, market_id: str, *, since: int | None = None, limit: int | None = None,
|
|
795
|
+
cursor: str | None = None,
|
|
796
|
+
) -> Page[Trade]:
|
|
797
|
+
"""Executions for a market, newest page first, each page oldest first.
|
|
798
|
+
|
|
799
|
+
`market_id` is the on-chain `conditionId`, which is what the Data API
|
|
800
|
+
keys on. A Gamma numeric market id is resolved to it first.
|
|
801
|
+
|
|
802
|
+
Paging ends (`next_cursor` null) when the tape runs out, when a page
|
|
803
|
+
reaches back past `since`, or at the venue's deepest offset
|
|
804
|
+
(`MAX_TRADE_OFFSET`): the Data API serves only about the newest 10,500
|
|
805
|
+
trades of a market.
|
|
806
|
+
"""
|
|
807
|
+
_, no_token, condition_id = self._tokens_of(market_id)
|
|
808
|
+
offset = int(cursor) if cursor and cursor.isdigit() else 0
|
|
809
|
+
if cursor and not cursor.isdigit():
|
|
810
|
+
raise BadRequest(f"polymarket: {cursor!r} is not a cursor this API issued")
|
|
811
|
+
wanted = min(limit or 100, TRADE_PAGE)
|
|
812
|
+
payload = self.data.get("/trades", {
|
|
813
|
+
"market": condition_id, "limit": wanted, "offset": offset or None,
|
|
814
|
+
})
|
|
815
|
+
rows = payload if isinstance(payload, list) else (payload.get("data") or [])
|
|
816
|
+
trades = [normalize_trade(trade, market_id=self.qualify(market_id), no_token=no_token) for trade in rows]
|
|
817
|
+
reached_since = False
|
|
818
|
+
if since:
|
|
819
|
+
reached_since = any(trade.timestamp < since for trade in trades)
|
|
820
|
+
trades = [trade for trade in trades if trade.timestamp >= since]
|
|
821
|
+
# The Data API pages by offset, so the next cursor is where this page
|
|
822
|
+
# ended. Only meaningful while the tape is not being appended to
|
|
823
|
+
# underneath us, which is why it is not the paging story for the catalog.
|
|
824
|
+
following = offset + len(rows)
|
|
825
|
+
done = len(rows) < wanted or reached_since or following > MAX_TRADE_OFFSET
|
|
826
|
+
return Page(sorted(trades, key=lambda trade: trade.timestamp),
|
|
827
|
+
next_cursor=None if done else str(following))
|
|
828
|
+
|
|
829
|
+
def fetch_ohlcv(
|
|
830
|
+
self, market_id: str, *, timeframe: str = "1h", since: int | None = None,
|
|
831
|
+
until: int | None = None, limit: int | None = None, source: str = "trades",
|
|
832
|
+
) -> list[Candle]:
|
|
833
|
+
"""Bars for a market in the YES price, built from executions by default.
|
|
834
|
+
|
|
835
|
+
`source="trades"` reads the Data API's tape for the market behind the
|
|
836
|
+
token and aggregates it: every bar carries `price_source="trade"`,
|
|
837
|
+
volume in contracts and a trade count, and trades on the market's
|
|
838
|
+
other token are folded in at `1 - price`, since they are the same
|
|
839
|
+
executions seen from the other side. That is how Predexon's
|
|
840
|
+
condition-level candles are built, and it is the only OHLCV this venue
|
|
841
|
+
can honestly be said to have.
|
|
842
|
+
|
|
843
|
+
The tape pages by offset from the newest trade, so a call reads back
|
|
844
|
+
until it passes the start of the window or runs into
|
|
845
|
+
`MAX_TRADE_PAGES`. Past the cap the earliest bar returned is marked
|
|
846
|
+
`info["complete"] = False`: it may be missing older trades. This is
|
|
847
|
+
for the live edge of a market -- the last few bars -- not for deep
|
|
848
|
+
history; a hot market prints several trades a second, and a day of it
|
|
849
|
+
is more tape than one call should read.
|
|
850
|
+
|
|
851
|
+
`source="quotes"` returns the venue's `prices-history` instead:
|
|
852
|
+
sampled midpoints bucketed into bars, `price_source="sampled_mid"`,
|
|
853
|
+
no volume. It reaches back further, and it is not made of trades. The
|
|
854
|
+
venue answers at most about 15 days per request, so a longer window is
|
|
855
|
+
read in 14-day pieces (`QUOTE_WINDOW`), one request each.
|
|
856
|
+
"""
|
|
857
|
+
yes_token, _, condition_id = self._tokens_of(market_id)
|
|
858
|
+
if source == "quotes":
|
|
859
|
+
return self._quote_candles(yes_token, timeframe=timeframe, since=since, until=until, limit=limit)
|
|
860
|
+
if source != "trades":
|
|
861
|
+
raise BadRequest(f"polymarket: unknown source {source!r}; expected 'trades' or 'quotes'")
|
|
862
|
+
seconds = timeframe_seconds(timeframe)
|
|
863
|
+
end = int((until or _now_ms()) / 1000)
|
|
864
|
+
start = int(since / 1000) if since else end - seconds * (limit or DEFAULT_BARS)
|
|
865
|
+
trades, complete = self._trades_between(condition_id, start, end)
|
|
866
|
+
candles = candles_from_trades(trades, yes_token=yes_token, interval_seconds=seconds)
|
|
867
|
+
candles = [c for c in candles if start * 1000 <= c.timestamp <= end * 1000]
|
|
868
|
+
if candles and not complete:
|
|
869
|
+
candles[0].info["complete"] = False
|
|
870
|
+
return pick_bars(candles, since=since, limit=limit)
|
|
871
|
+
|
|
872
|
+
def _trades_between(self, condition_id: str, start: int, end: int) -> tuple[list[dict[str, Any]], bool]:
|
|
873
|
+
"""Trades in `[start, end]` seconds, newest page first, and whether the
|
|
874
|
+
walk reached past `start` before the page cap."""
|
|
875
|
+
collected: list[dict[str, Any]] = []
|
|
876
|
+
for page in range(MAX_TRADE_PAGES):
|
|
877
|
+
rows = self.data.get("/trades", {
|
|
878
|
+
"market": condition_id, "limit": TRADE_PAGE, "offset": page * TRADE_PAGE,
|
|
879
|
+
})
|
|
880
|
+
rows = rows if isinstance(rows, list) else (rows.get("data") or [])
|
|
881
|
+
if not rows:
|
|
882
|
+
return collected, True
|
|
883
|
+
for trade in rows:
|
|
884
|
+
stamp = to_float(trade.get("timestamp"))
|
|
885
|
+
if stamp is not None and start <= stamp <= end:
|
|
886
|
+
collected.append(trade)
|
|
887
|
+
oldest = to_float(rows[-1].get("timestamp"))
|
|
888
|
+
if oldest is not None and oldest < start:
|
|
889
|
+
return collected, True
|
|
890
|
+
if len(rows) < TRADE_PAGE:
|
|
891
|
+
return collected, True
|
|
892
|
+
return collected, False
|
|
893
|
+
|
|
894
|
+
def _quote_candles(
|
|
895
|
+
self, token_id: str, *, timeframe: str, since: int | None,
|
|
896
|
+
until: int | None, limit: int | None,
|
|
897
|
+
) -> list[Candle]:
|
|
898
|
+
seconds = timeframe_seconds(timeframe)
|
|
899
|
+
params: dict[str, Any] = {"market": token_id, "fidelity": max(1, seconds // 60)}
|
|
900
|
+
if since or until:
|
|
901
|
+
# An absolute window whenever either end is given. `until` used to
|
|
902
|
+
# be read only alongside `since`, so asking for bars ending a month
|
|
903
|
+
# ago silently returned today's -- a backtest stepping back through
|
|
904
|
+
# history would re-evaluate the present on every iteration and look
|
|
905
|
+
# like it was working. The missing end is derived from `limit`,
|
|
906
|
+
# counting back from the end the caller did give.
|
|
907
|
+
end = int((until or _now_ms()) / 1000)
|
|
908
|
+
start = int(since / 1000) if since else end - seconds * (limit or 100)
|
|
909
|
+
samples: dict[float, dict[str, Any]] = {}
|
|
910
|
+
candles: list[Candle] = []
|
|
911
|
+
for piece_start in range(start, max(end, start + 1), QUOTE_WINDOW):
|
|
912
|
+
piece = {**params, "startTs": piece_start, "endTs": min(piece_start + QUOTE_WINDOW, end)}
|
|
913
|
+
for point in self.clob.get("/prices-history", piece).get("history") or []:
|
|
914
|
+
stamp = to_float(point.get("t"))
|
|
915
|
+
if stamp is not None:
|
|
916
|
+
samples[stamp] = point # a sample on a piece boundary comes back twice
|
|
917
|
+
history = [samples[stamp] for stamp in sorted(samples)]
|
|
918
|
+
# The venue appends a sample at the current price whatever window
|
|
919
|
+
# was asked for, so a request ending a month ago comes back with a
|
|
920
|
+
# bar stamped today. Left in, that single bar is the one a backtest
|
|
921
|
+
# would treat as the future it is trying not to see.
|
|
922
|
+
candles = [c for c in candles_from_price_history(history, interval_seconds=seconds)
|
|
923
|
+
if start * 1000 <= c.timestamp <= end * 1000]
|
|
924
|
+
if enough_bars(candles, since=since, limit=limit, strict=True):
|
|
925
|
+
break
|
|
926
|
+
else:
|
|
927
|
+
params["interval"] = "1d" if seconds <= 3600 else "max"
|
|
928
|
+
history = self.clob.get("/prices-history", params).get("history") or []
|
|
929
|
+
candles = candles_from_price_history(history, interval_seconds=seconds)
|
|
930
|
+
return pick_bars(candles, since=since, limit=limit)
|
|
931
|
+
|
|
932
|
+
# -- reference ----------------------------------------------------------
|
|
933
|
+
|
|
934
|
+
def fetch_fee_schedule(self, market_id: str) -> FeeSchedule:
|
|
935
|
+
market = self.fetch_market(market_id)
|
|
936
|
+
schedule = fee_schedule_of(market.info)
|
|
937
|
+
if schedule is None:
|
|
938
|
+
raise MarketNotFound(f"polymarket: market {market_id} publishes no fee schedule")
|
|
939
|
+
return schedule
|
|
940
|
+
|
|
941
|
+
def close(self) -> None:
|
|
942
|
+
self.gamma.close()
|
|
943
|
+
self.clob.close()
|
|
944
|
+
self.data.close()
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _with_status(markets: list[Market], status: str) -> list[Market]:
|
|
948
|
+
"""The markets in `status`, judged by each one's own status. `closed` is
|
|
949
|
+
Gamma's flag, which resolved markets carry too, so it keeps both."""
|
|
950
|
+
if status == "all":
|
|
951
|
+
return markets
|
|
952
|
+
wanted = ("closed", "settled") if status == "closed" else (status,)
|
|
953
|
+
return [market for market in markets if market.status in wanted]
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _search_cursor(cursor: str | None) -> tuple[int, int]:
|
|
957
|
+
"""`(search page, markets to skip on it)` from a search cursor."""
|
|
958
|
+
if not cursor:
|
|
959
|
+
return 1, 0
|
|
960
|
+
page, _, skip = cursor.removeprefix("search:").partition(".")
|
|
961
|
+
if not cursor.startswith("search:") or not page.isdigit() or not (skip or "0").isdigit() or int(page) < 1:
|
|
962
|
+
raise BadRequest(f"polymarket: {cursor!r} is not a search cursor this API issued")
|
|
963
|
+
return int(page), int(skip or 0)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _encode_search_cursor(page: int, skip: int) -> str:
|
|
967
|
+
return f"search:{page}.{skip}" if skip else f"search:{page}"
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _status_flags(status: str) -> tuple[str | None, str | None]:
|
|
971
|
+
"""Gamma's `active` / `closed` flags for one of the shared status words.
|
|
972
|
+
|
|
973
|
+
`settled` is refused rather than approximated. Gamma's catalog carries no
|
|
974
|
+
resolution status -- a closed market's `umaResolutionStatus` is absent from
|
|
975
|
+
the listing payload -- so there is no way to filter for settled markets
|
|
976
|
+
here, and the previous behaviour of falling through to "no filter" answered
|
|
977
|
+
a completely different question: asking for settled markets returned live
|
|
978
|
+
ones, while the same call on Kalshi returned settled ones.
|
|
979
|
+
"""
|
|
980
|
+
check_status(status)
|
|
981
|
+
if status == "open":
|
|
982
|
+
return "true", "false"
|
|
983
|
+
if status == "closed":
|
|
984
|
+
return None, "true"
|
|
985
|
+
if status == "settled":
|
|
986
|
+
raise NotSupported(
|
|
987
|
+
"polymarket: cannot filter by settled -- Gamma's catalog does not "
|
|
988
|
+
"publish resolution status. Use status='closed' and check each "
|
|
989
|
+
"market's `status` field."
|
|
990
|
+
)
|
|
991
|
+
return None, None
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _token_for(side: str, yes_token: str, no_token: str) -> str:
|
|
995
|
+
if side == "yes":
|
|
996
|
+
return yes_token
|
|
997
|
+
if side == "no":
|
|
998
|
+
return no_token
|
|
999
|
+
raise BadRequest(f"polymarket: unknown side {side!r}; expected 'yes' or 'no'")
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _now_ms() -> int:
|
|
1003
|
+
from datetime import timezone
|
|
1004
|
+
return int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|