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