synpath 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
synpath/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)