kevinkal commited on
Commit
d8a6c5f
·
verified ·
1 Parent(s): 834f680

Upload polymarket.py

Browse files
Files changed (1) hide show
  1. services/polymarket.py +963 -0
services/polymarket.py ADDED
@@ -0,0 +1,963 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read-only Polymarket market-data service.
2
+
3
+ The service uses the newer Gamma keyset endpoints for event/market discovery,
4
+ Gamma search and tag endpoints for navigation, and the CLOB API for live
5
+ prices, order books, and price history. It returns the normalized models from
6
+ ``schemas.predictions`` and is shared by the REST router and the agent tools.
7
+
8
+ Configuration (all optional):
9
+ POLYMARKET_GAMMA_URL: Gamma base URL
10
+ POLYMARKET_CLOB_URL: CLOB base URL
11
+ POLYMARKET_TIMEOUT: request timeout in seconds (default 15)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import os
19
+ import time
20
+ from collections.abc import Mapping
21
+ from datetime import datetime, timezone
22
+ from typing import Any
23
+ from urllib.parse import quote
24
+
25
+ import httpx
26
+
27
+ from schemas.predictions import (
28
+ EventDetail,
29
+ EventPage,
30
+ EventSummary,
31
+ MarketBooks,
32
+ MarketDetail,
33
+ MarketPage,
34
+ MarketSummary,
35
+ OrderBook,
36
+ OrderBookLevel,
37
+ OrderSide,
38
+ OutcomeOut,
39
+ PredictionSort,
40
+ PriceHistory,
41
+ PriceInterval,
42
+ PricePoint,
43
+ SearchPagination,
44
+ SearchResult,
45
+ TagOut,
46
+ TokenPrice,
47
+ )
48
+
49
+
50
+ def _env_float(name: str, default: float) -> float:
51
+ try:
52
+ return float(os.getenv(name, str(default)))
53
+ except ValueError:
54
+ return default
55
+
56
+
57
+ GAMMA_BASE_URL = os.getenv(
58
+ "POLYMARKET_GAMMA_URL", "https://gamma-api.polymarket.com"
59
+ ).rstrip("/")
60
+ CLOB_BASE_URL = os.getenv(
61
+ "POLYMARKET_CLOB_URL", "https://clob.polymarket.com"
62
+ ).rstrip("/")
63
+ DEFAULT_TIMEOUT = _env_float("POLYMARKET_TIMEOUT", 15.0)
64
+
65
+ _CACHE_MAX_ENTRIES = 128
66
+
67
+
68
+ class PolymarketError(Exception):
69
+ """Base class for Polymarket service failures."""
70
+
71
+
72
+ class PolymarketNotFound(PolymarketError):
73
+ """The requested event, market, or token does not exist."""
74
+
75
+
76
+ class PolymarketTimeout(PolymarketError):
77
+ """The upstream request timed out."""
78
+
79
+
80
+ class PolymarketUpstreamError(PolymarketError):
81
+ """The upstream API returned an error or an unusable response."""
82
+
83
+
84
+ _EVENT_SORT: dict[PredictionSort, tuple[str, bool]] = {
85
+ "volume_24h": ("volume24hr", False),
86
+ "volume": ("volume", False),
87
+ "liquidity": ("liquidity", False),
88
+ "newest": ("startDate", False),
89
+ "ending_soon": ("endDate", True),
90
+ }
91
+ _MARKET_SORT: dict[PredictionSort, tuple[str, bool]] = {
92
+ "volume_24h": ("volume24hr", False),
93
+ "volume": ("volumeNum", False),
94
+ "liquidity": ("liquidityNum", False),
95
+ "newest": ("startDate", False),
96
+ "ending_soon": ("endDate", True),
97
+ }
98
+
99
+
100
+ def _params(values: Mapping[str, Any]) -> dict[str, Any]:
101
+ return {key: value for key, value in values.items() if value is not None}
102
+
103
+
104
+ def _bool_param(value: bool) -> str:
105
+ return "true" if value else "false"
106
+
107
+
108
+ def _as_dict(value: Any) -> dict[str, Any]:
109
+ return dict(value) if isinstance(value, Mapping) else {}
110
+
111
+
112
+ def _as_list(value: Any) -> list[Any]:
113
+ return value if isinstance(value, list) else []
114
+
115
+
116
+ def _as_str(value: Any) -> str | None:
117
+ if value is None:
118
+ return None
119
+ text = str(value).strip()
120
+ return text or None
121
+
122
+
123
+ def _as_float(value: Any) -> float | None:
124
+ if value is None or isinstance(value, bool):
125
+ return None
126
+ try:
127
+ return float(value)
128
+ except (TypeError, ValueError):
129
+ return None
130
+
131
+
132
+ def _as_int(value: Any) -> int | None:
133
+ if value is None or isinstance(value, bool):
134
+ return None
135
+ try:
136
+ return int(value)
137
+ except (TypeError, ValueError):
138
+ return None
139
+
140
+
141
+ def _as_bool(value: Any) -> bool | None:
142
+ if isinstance(value, bool):
143
+ return value
144
+ if isinstance(value, str):
145
+ lowered = value.strip().lower()
146
+ if lowered in {"true", "1", "yes"}:
147
+ return True
148
+ if lowered in {"false", "0", "no"}:
149
+ return False
150
+ return None
151
+ if isinstance(value, (int, float)):
152
+ return bool(value)
153
+ return None
154
+
155
+
156
+ def _as_datetime(value: Any) -> datetime | None:
157
+ if value is None:
158
+ return None
159
+ if isinstance(value, datetime):
160
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
161
+ if isinstance(value, (int, float)):
162
+ number = float(value)
163
+ if number > 1_000_000_000_000:
164
+ number /= 1000
165
+ return datetime.fromtimestamp(number, tz=timezone.utc)
166
+
167
+ text = str(value).strip()
168
+ if not text:
169
+ return None
170
+ if text.isdigit() and len(text) >= 10:
171
+ number = float(text)
172
+ if len(text) >= 13:
173
+ number /= 1000
174
+ return datetime.fromtimestamp(number, tz=timezone.utc)
175
+
176
+ text = text.replace("Z", "+00:00")
177
+ try:
178
+ parsed = datetime.fromisoformat(text)
179
+ except ValueError:
180
+ try:
181
+ parsed = datetime.fromisoformat(text.replace(" ", "T", 1))
182
+ except ValueError:
183
+ return None
184
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
185
+
186
+
187
+ def _first_float(*values: Any) -> float | None:
188
+ for value in values:
189
+ parsed = _as_float(value)
190
+ if parsed is not None:
191
+ return parsed
192
+ return None
193
+
194
+
195
+ def _first_str(*values: Any) -> str | None:
196
+ for value in values:
197
+ parsed = _as_str(value)
198
+ if parsed is not None:
199
+ return parsed
200
+ return None
201
+
202
+
203
+ def _json_list(value: Any) -> list[Any]:
204
+ if isinstance(value, list):
205
+ return value
206
+ if isinstance(value, str):
207
+ try:
208
+ parsed = json.loads(value)
209
+ except json.JSONDecodeError:
210
+ return []
211
+ return parsed if isinstance(parsed, list) else []
212
+ return []
213
+
214
+
215
+ def _retry_delay(value: str | None) -> float:
216
+ try:
217
+ delay = float(value) if value is not None else 0.25
218
+ except ValueError:
219
+ delay = 0.25
220
+ return max(0.0, min(delay, 2.0))
221
+
222
+
223
+ def _error_message(response: httpx.Response) -> str:
224
+ try:
225
+ payload = response.json()
226
+ except ValueError:
227
+ payload = None
228
+ if isinstance(payload, Mapping):
229
+ message = payload.get("error") or payload.get("detail") or payload.get("message")
230
+ if message:
231
+ return str(message)[:300]
232
+ text = response.text.strip().replace("\n", " ")
233
+ return (text or response.reason_phrase)[:300]
234
+
235
+
236
+ def _to_tag(raw: Mapping[str, Any]) -> TagOut:
237
+ event_count = raw.get("eventCount")
238
+ if event_count is None:
239
+ event_count = raw.get("event_count")
240
+ return TagOut(
241
+ id=_as_str(raw.get("id")) or "",
242
+ label=_as_str(raw.get("label")),
243
+ slug=_as_str(raw.get("slug")),
244
+ event_count=_as_int(event_count),
245
+ )
246
+
247
+
248
+ def _to_tags(raw: Mapping[str, Any]) -> list[TagOut]:
249
+ return [
250
+ _to_tag(tag)
251
+ for tag in _as_list(raw.get("tags"))
252
+ if isinstance(tag, Mapping)
253
+ ]
254
+
255
+
256
+ def _to_outcomes(raw: Mapping[str, Any]) -> list[OutcomeOut]:
257
+ names = _json_list(raw.get("outcomes"))
258
+ prices = _json_list(raw.get("outcomePrices"))
259
+ token_ids = _json_list(raw.get("clobTokenIds"))
260
+
261
+ outcomes: list[OutcomeOut] = []
262
+ for index, name in enumerate(names):
263
+ outcomes.append(
264
+ OutcomeOut(
265
+ name=str(name),
266
+ price=_as_float(prices[index]) if index < len(prices) else None,
267
+ token_id=(
268
+ _as_str(token_ids[index]) if index < len(token_ids) else None
269
+ ),
270
+ )
271
+ )
272
+ return outcomes
273
+
274
+
275
+ def _event_context(
276
+ raw: Mapping[str, Any], event: Mapping[str, Any] | None
277
+ ) -> Mapping[str, Any] | None:
278
+ if event is not None:
279
+ return event
280
+ events = _as_list(raw.get("events"))
281
+ if events and isinstance(events[0], Mapping):
282
+ return events[0]
283
+ return None
284
+
285
+
286
+ def _market_url(event_slug: str | None, market_slug: str | None) -> str | None:
287
+ if event_slug:
288
+ return f"https://polymarket.com/event/{event_slug}"
289
+ if market_slug:
290
+ return f"https://polymarket.com/market/{market_slug}"
291
+ return None
292
+
293
+
294
+ def _to_market_summary(
295
+ raw: Mapping[str, Any], *, event: Mapping[str, Any] | None = None
296
+ ) -> MarketSummary:
297
+ context = _event_context(raw, event)
298
+ event_slug = _as_str(context.get("slug")) if context else None
299
+ market_slug = _as_str(raw.get("slug"))
300
+
301
+ return MarketSummary(
302
+ id=_as_str(raw.get("id")) or "",
303
+ question=_as_str(raw.get("question")),
304
+ slug=market_slug,
305
+ condition_id=_as_str(raw.get("conditionId")),
306
+ event_id=_as_str(context.get("id")) if context else None,
307
+ event_title=_as_str(context.get("title")) if context else None,
308
+ event_slug=event_slug,
309
+ outcomes=_to_outcomes(raw),
310
+ volume=_first_float(raw.get("volume"), raw.get("volumeNum"), raw.get("volumeClob")),
311
+ volume_24h=_first_float(raw.get("volume24hr"), raw.get("volume24hrClob")),
312
+ liquidity=_first_float(
313
+ raw.get("liquidity"), raw.get("liquidityNum"), raw.get("liquidityClob")
314
+ ),
315
+ open_interest=_first_float(
316
+ raw.get("openInterest"), context.get("openInterest") if context else None
317
+ ),
318
+ start_date=_as_datetime(raw.get("startDate")),
319
+ end_date=_as_datetime(raw.get("endDate")),
320
+ active=_as_bool(raw.get("active")),
321
+ closed=_as_bool(raw.get("closed")),
322
+ accepting_orders=_as_bool(raw.get("acceptingOrders")),
323
+ best_bid=_as_float(raw.get("bestBid")),
324
+ best_ask=_as_float(raw.get("bestAsk")),
325
+ last_trade_price=_as_float(raw.get("lastTradePrice")),
326
+ spread=_as_float(raw.get("spread")),
327
+ image=_first_str(raw.get("image"), raw.get("icon")),
328
+ url=_market_url(event_slug, market_slug),
329
+ )
330
+
331
+
332
+ def _to_market_detail(raw: Mapping[str, Any]) -> MarketDetail:
333
+ summary = _to_market_summary(raw)
334
+ return MarketDetail(
335
+ **summary.model_dump(),
336
+ description=_as_str(raw.get("description")),
337
+ resolution_source=_as_str(raw.get("resolutionSource")),
338
+ tags=_to_tags(raw),
339
+ )
340
+
341
+
342
+ def _to_event_summary(raw: Mapping[str, Any]) -> EventSummary:
343
+ slug = _as_str(raw.get("slug"))
344
+ markets = [
345
+ _to_market_summary(market, event=raw)
346
+ for market in _as_list(raw.get("markets"))
347
+ if isinstance(market, Mapping)
348
+ ]
349
+
350
+ return EventSummary(
351
+ id=_as_str(raw.get("id")) or "",
352
+ slug=slug,
353
+ title=_as_str(raw.get("title")),
354
+ subtitle=_as_str(raw.get("subtitle")),
355
+ active=_as_bool(raw.get("active")),
356
+ closed=_as_bool(raw.get("closed")),
357
+ volume=_as_float(raw.get("volume")),
358
+ volume_24h=_as_float(raw.get("volume24hr")),
359
+ liquidity=_as_float(raw.get("liquidity")),
360
+ open_interest=_as_float(raw.get("openInterest")),
361
+ start_date=_as_datetime(raw.get("startDate")),
362
+ end_date=_as_datetime(raw.get("endDate")),
363
+ image=_first_str(raw.get("image"), raw.get("icon")),
364
+ url=f"https://polymarket.com/event/{slug}" if slug else None,
365
+ tags=_to_tags(raw),
366
+ markets=markets,
367
+ )
368
+
369
+
370
+ def _to_event_detail(raw: Mapping[str, Any]) -> EventDetail:
371
+ summary = _to_event_summary(raw)
372
+ return EventDetail(
373
+ **summary.model_dump(),
374
+ description=_as_str(raw.get("description")),
375
+ resolution_source=_as_str(raw.get("resolutionSource")),
376
+ )
377
+
378
+
379
+ def _to_search_pagination(raw: Mapping[str, Any] | None) -> SearchPagination | None:
380
+ if not raw:
381
+ return None
382
+ has_more = raw.get("hasMore")
383
+ if has_more is None:
384
+ has_more = raw.get("has_more")
385
+ total_results = raw.get("totalResults")
386
+ if total_results is None:
387
+ total_results = raw.get("total_results")
388
+ return SearchPagination(
389
+ has_more=_as_bool(has_more),
390
+ total_results=_as_int(total_results),
391
+ )
392
+
393
+
394
+ def _to_order_book(raw: Mapping[str, Any], token_id: str, depth: int) -> OrderBook:
395
+ depth = max(1, min(depth, 100))
396
+ bids = [
397
+ OrderBookLevel(
398
+ price=float(level.get("price")),
399
+ size=float(level.get("size")),
400
+ )
401
+ for level in _as_list(raw.get("bids"))
402
+ if isinstance(level, Mapping)
403
+ and _as_float(level.get("price")) is not None
404
+ and _as_float(level.get("size")) is not None
405
+ ]
406
+ asks = [
407
+ OrderBookLevel(
408
+ price=float(level.get("price")),
409
+ size=float(level.get("size")),
410
+ )
411
+ for level in _as_list(raw.get("asks"))
412
+ if isinstance(level, Mapping)
413
+ and _as_float(level.get("price")) is not None
414
+ and _as_float(level.get("size")) is not None
415
+ ]
416
+
417
+ bids.sort(key=lambda level: level.price, reverse=True)
418
+ asks.sort(key=lambda level: level.price)
419
+
420
+ best_bid = bids[0].price if bids else None
421
+ best_ask = asks[0].price if asks else None
422
+ midpoint = (
423
+ round((best_bid + best_ask) / 2, 6)
424
+ if best_bid is not None and best_ask is not None
425
+ else None
426
+ )
427
+ spread = (
428
+ round(best_ask - best_bid, 6)
429
+ if best_bid is not None and best_ask is not None
430
+ else None
431
+ )
432
+
433
+ return OrderBook(
434
+ token_id=token_id,
435
+ condition_id=_as_str(raw.get("market")),
436
+ fetched_at=_as_datetime(raw.get("timestamp")) or datetime.now(timezone.utc),
437
+ bids=bids[:depth],
438
+ asks=asks[:depth],
439
+ best_bid=best_bid,
440
+ best_ask=best_ask,
441
+ midpoint=midpoint,
442
+ spread=spread,
443
+ last_trade_price=_as_float(raw.get("last_trade_price")),
444
+ tick_size=_as_float(raw.get("tick_size")),
445
+ min_order_size=_as_float(raw.get("min_order_size")),
446
+ neg_risk=_as_bool(raw.get("neg_risk")),
447
+ )
448
+
449
+
450
+ class PolymarketClient:
451
+ """Async read-only client for Polymarket's public APIs."""
452
+
453
+ def __init__(
454
+ self,
455
+ *,
456
+ gamma_url: str = GAMMA_BASE_URL,
457
+ clob_url: str = CLOB_BASE_URL,
458
+ timeout: float = DEFAULT_TIMEOUT,
459
+ transport: httpx.AsyncBaseTransport | None = None,
460
+ ) -> None:
461
+ self._gamma_url = gamma_url.rstrip("/")
462
+ self._clob_url = clob_url.rstrip("/")
463
+ self._timeout = timeout
464
+ self._transport = transport
465
+ self._client: httpx.AsyncClient | None = None
466
+ self._client_lock = asyncio.Lock()
467
+ self._cache: dict[str, tuple[float, Any]] = {}
468
+
469
+ async def _get_client(self) -> httpx.AsyncClient:
470
+ if self._client is None:
471
+ async with self._client_lock:
472
+ if self._client is None:
473
+ self._client = httpx.AsyncClient(
474
+ timeout=httpx.Timeout(self._timeout),
475
+ headers={
476
+ "Accept": "application/json",
477
+ "User-Agent": "stx-polymarket/1.0",
478
+ },
479
+ transport=self._transport,
480
+ )
481
+ return self._client
482
+
483
+ async def aclose(self) -> None:
484
+ if self._client is not None:
485
+ await self._client.aclose()
486
+ self._client = None
487
+ self._cache.clear()
488
+
489
+ async def _request(
490
+ self,
491
+ base_url: str,
492
+ path: str,
493
+ params: Mapping[str, Any] | None = None,
494
+ ) -> Any:
495
+ client = await self._get_client()
496
+ url = f"{base_url}{path}"
497
+
498
+ for attempt in range(2):
499
+ try:
500
+ response = await client.get(url, params=params)
501
+ except httpx.TimeoutException as exc:
502
+ if attempt == 0:
503
+ await asyncio.sleep(0.25)
504
+ continue
505
+ raise PolymarketTimeout(
506
+ f"Polymarket request timed out: {path}"
507
+ ) from exc
508
+ except httpx.RequestError as exc:
509
+ if attempt == 0:
510
+ await asyncio.sleep(0.25)
511
+ continue
512
+ raise PolymarketUpstreamError(
513
+ f"Polymarket request failed: {exc}"
514
+ ) from exc
515
+
516
+ if response.status_code == 404:
517
+ raise PolymarketNotFound(f"Polymarket resource not found: {path}")
518
+
519
+ if response.status_code in {429, 500, 502, 503, 504} and attempt == 0:
520
+ await asyncio.sleep(
521
+ _retry_delay(response.headers.get("Retry-After"))
522
+ )
523
+ continue
524
+
525
+ if response.status_code >= 400:
526
+ raise PolymarketUpstreamError(
527
+ f"Polymarket API returned {response.status_code}: "
528
+ f"{_error_message(response)}"
529
+ )
530
+
531
+ content_type = response.headers.get("content-type", "")
532
+ if content_type and "json" not in content_type.lower():
533
+ snippet = response.text.strip().replace("\n", " ")[:200]
534
+ raise PolymarketUpstreamError(
535
+ "Polymarket returned a non-JSON response "
536
+ f"({response.status_code}): {snippet}"
537
+ )
538
+
539
+ try:
540
+ return response.json()
541
+ except ValueError as exc:
542
+ raise PolymarketUpstreamError(
543
+ "Polymarket returned invalid JSON."
544
+ ) from exc
545
+
546
+ raise PolymarketUpstreamError(f"Polymarket request failed: {path}")
547
+
548
+ async def _get_json(
549
+ self,
550
+ base_url: str,
551
+ path: str,
552
+ params: Mapping[str, Any] | None = None,
553
+ *,
554
+ ttl: float,
555
+ ) -> Any:
556
+ cache_key = json.dumps(
557
+ {"base": base_url, "path": path, "params": dict(params or {})},
558
+ sort_keys=True,
559
+ default=str,
560
+ )
561
+ now = time.monotonic()
562
+ cached = self._cache.get(cache_key)
563
+ if cached is not None and cached[0] > now:
564
+ return cached[1]
565
+
566
+ data = await self._request(base_url, path, params)
567
+ if len(self._cache) >= _CACHE_MAX_ENTRIES:
568
+ oldest_key = min(self._cache, key=lambda key: self._cache[key][0])
569
+ self._cache.pop(oldest_key, None)
570
+ self._cache[cache_key] = (now + ttl, data)
571
+ return data
572
+
573
+ async def list_events(
574
+ self,
575
+ *,
576
+ tag_id: int | None = None,
577
+ tag_slug: str | None = None,
578
+ closed: bool = False,
579
+ sort: PredictionSort = "volume_24h",
580
+ limit: int = 10,
581
+ after_cursor: str | None = None,
582
+ ) -> EventPage:
583
+ order, ascending = _EVENT_SORT.get(
584
+ sort, _EVENT_SORT["volume_24h"]
585
+ )
586
+ params = _params(
587
+ {
588
+ "limit": max(1, min(int(limit), 100)),
589
+ "closed": _bool_param(closed),
590
+ "order": order,
591
+ "ascending": _bool_param(ascending),
592
+ "tag_id": tag_id,
593
+ "tag_slug": tag_slug,
594
+ "after_cursor": after_cursor,
595
+ }
596
+ )
597
+ data = await self._get_json(
598
+ self._gamma_url, "/events/keyset", params, ttl=30.0
599
+ )
600
+ payload = _as_dict(data)
601
+ return EventPage(
602
+ items=[
603
+ _to_event_summary(event)
604
+ for event in _as_list(payload.get("events"))
605
+ if isinstance(event, Mapping)
606
+ ],
607
+ next_cursor=_as_str(payload.get("next_cursor")),
608
+ )
609
+
610
+ async def get_event(self, identifier: str) -> EventDetail:
611
+ identifier = identifier.strip()
612
+ if not identifier:
613
+ raise PolymarketNotFound("An event id or slug is required.")
614
+ safe_identifier = quote(identifier, safe="")
615
+ path = (
616
+ f"/events/{safe_identifier}"
617
+ if identifier.isdigit()
618
+ else f"/events/slug/{safe_identifier}"
619
+ )
620
+ data = await self._get_json(self._gamma_url, path, ttl=30.0)
621
+ payload = _as_dict(data)
622
+ if not payload:
623
+ raise PolymarketUpstreamError("Polymarket returned an empty event.")
624
+ return _to_event_detail(payload)
625
+
626
+ async def list_markets(
627
+ self,
628
+ *,
629
+ tag_id: int | None = None,
630
+ tag_slug: str | None = None,
631
+ closed: bool = False,
632
+ sort: PredictionSort = "volume_24h",
633
+ limit: int = 10,
634
+ after_cursor: str | None = None,
635
+ ) -> MarketPage:
636
+ if tag_id is None and tag_slug:
637
+ tag = await self.get_tag(tag_slug)
638
+ try:
639
+ tag_id = int(tag.id)
640
+ except ValueError as exc:
641
+ raise PolymarketUpstreamError(
642
+ f"Polymarket returned a non-numeric tag id for '{tag_slug}'."
643
+ ) from exc
644
+
645
+ order, ascending = _MARKET_SORT.get(
646
+ sort, _MARKET_SORT["volume_24h"]
647
+ )
648
+ params = _params(
649
+ {
650
+ "limit": max(1, min(int(limit), 100)),
651
+ "closed": _bool_param(closed),
652
+ "order": order,
653
+ "ascending": _bool_param(ascending),
654
+ "tag_id": tag_id,
655
+ "after_cursor": after_cursor,
656
+ }
657
+ )
658
+ data = await self._get_json(
659
+ self._gamma_url, "/markets/keyset", params, ttl=30.0
660
+ )
661
+ payload = _as_dict(data)
662
+ return MarketPage(
663
+ items=[
664
+ _to_market_summary(market)
665
+ for market in _as_list(payload.get("markets"))
666
+ if isinstance(market, Mapping)
667
+ ],
668
+ next_cursor=_as_str(payload.get("next_cursor")),
669
+ )
670
+
671
+ async def get_market(self, identifier: str) -> MarketDetail:
672
+ identifier = identifier.strip()
673
+ if not identifier:
674
+ raise PolymarketNotFound("A market id or slug is required.")
675
+ safe_identifier = quote(identifier, safe="")
676
+ path = (
677
+ f"/markets/{safe_identifier}"
678
+ if identifier.isdigit()
679
+ else f"/markets/slug/{safe_identifier}"
680
+ )
681
+ data = await self._get_json(
682
+ self._gamma_url, path, {"include_tag": "true"}, ttl=30.0
683
+ )
684
+ payload = _as_dict(data)
685
+ if not payload:
686
+ raise PolymarketUpstreamError("Polymarket returned an empty market.")
687
+ return _to_market_detail(payload)
688
+
689
+ async def search(
690
+ self,
691
+ query: str,
692
+ *,
693
+ limit_per_type: int = 5,
694
+ include_tags: bool = True,
695
+ ) -> SearchResult:
696
+ query = query.strip()
697
+ if not query:
698
+ raise PolymarketNotFound("A search query is required.")
699
+ params = _params(
700
+ {
701
+ "q": query,
702
+ "limit_per_type": max(1, min(int(limit_per_type), 50)),
703
+ "search_profiles": "false",
704
+ "search_tags": _bool_param(include_tags),
705
+ }
706
+ )
707
+ data = await self._get_json(
708
+ self._gamma_url, "/public-search", params, ttl=60.0
709
+ )
710
+ payload = _as_dict(data)
711
+ return SearchResult(
712
+ query=query,
713
+ events=[
714
+ _to_event_summary(event)
715
+ for event in _as_list(payload.get("events"))
716
+ if isinstance(event, Mapping)
717
+ ],
718
+ tags=[
719
+ _to_tag(tag)
720
+ for tag in _as_list(payload.get("tags"))
721
+ if isinstance(tag, Mapping)
722
+ ],
723
+ pagination=_to_search_pagination(
724
+ _as_dict(payload.get("pagination")) or None
725
+ ),
726
+ )
727
+
728
+ async def list_tags(self, *, limit: int = 100, offset: int = 0) -> list[TagOut]:
729
+ params = _params(
730
+ {
731
+ "limit": max(1, min(int(limit), 500)),
732
+ "offset": max(0, int(offset)),
733
+ }
734
+ )
735
+ data = await self._get_json(self._gamma_url, "/tags", params, ttl=300.0)
736
+ raw_tags = (
737
+ data
738
+ if isinstance(data, list)
739
+ else _as_list(_as_dict(data).get("tags"))
740
+ )
741
+ return [
742
+ _to_tag(tag)
743
+ for tag in raw_tags
744
+ if isinstance(tag, Mapping)
745
+ ]
746
+
747
+ async def get_tag(self, slug: str) -> TagOut:
748
+ slug = slug.strip()
749
+ if not slug:
750
+ raise PolymarketNotFound("A tag slug is required.")
751
+ data = await self._get_json(
752
+ self._gamma_url, f"/tags/slug/{quote(slug, safe='')}", ttl=300.0
753
+ )
754
+ payload = _as_dict(data)
755
+ if not payload:
756
+ raise PolymarketNotFound(f"Polymarket tag '{slug}' was not found.")
757
+ return _to_tag(payload)
758
+
759
+ async def get_order_book(self, token_id: str, *, depth: int = 10) -> OrderBook:
760
+ token_id = token_id.strip()
761
+ if not token_id:
762
+ raise PolymarketNotFound("A token id is required.")
763
+ data = await self._get_json(
764
+ self._clob_url,
765
+ "/book",
766
+ {"token_id": token_id},
767
+ ttl=5.0,
768
+ )
769
+ payload = _as_dict(data)
770
+ if not payload:
771
+ raise PolymarketUpstreamError("Polymarket returned an empty order book.")
772
+ return _to_order_book(payload, token_id, depth)
773
+
774
+ async def get_token_price(
775
+ self, token_id: str, *, side: OrderSide = "BUY"
776
+ ) -> TokenPrice:
777
+ token_id = token_id.strip()
778
+ if not token_id:
779
+ raise PolymarketNotFound("A token id is required.")
780
+ data = await self._get_json(
781
+ self._clob_url,
782
+ "/price",
783
+ {"token_id": token_id, "side": side},
784
+ ttl=5.0,
785
+ )
786
+ payload = _as_dict(data)
787
+ price = _as_float(payload.get("price"))
788
+ if price is None:
789
+ raise PolymarketUpstreamError(
790
+ "Polymarket returned an invalid token price."
791
+ )
792
+ return TokenPrice(token_id=token_id, side=side, price=price)
793
+
794
+ async def get_price_history(
795
+ self,
796
+ token_id: str,
797
+ *,
798
+ interval: PriceInterval = "1d",
799
+ fidelity: int | None = None,
800
+ ) -> PriceHistory:
801
+ token_id = token_id.strip()
802
+ if not token_id:
803
+ raise PolymarketNotFound("A token id is required.")
804
+ params = _params(
805
+ {
806
+ "market": token_id,
807
+ "interval": interval,
808
+ "fidelity": fidelity,
809
+ }
810
+ )
811
+ data = await self._get_json(
812
+ self._clob_url, "/prices-history", params, ttl=60.0
813
+ )
814
+ payload = _as_dict(data)
815
+
816
+ points: list[PricePoint] = []
817
+ for item in _as_list(payload.get("history")):
818
+ if not isinstance(item, Mapping):
819
+ continue
820
+ timestamp = _as_int(item.get("t"))
821
+ price = _as_float(item.get("p"))
822
+ point_time = _as_datetime(timestamp)
823
+ if timestamp is None or price is None or point_time is None:
824
+ continue
825
+ points.append(
826
+ PricePoint(timestamp=timestamp, time=point_time, price=price)
827
+ )
828
+
829
+ return PriceHistory(token_id=token_id, interval=interval, points=points)
830
+
831
+ async def get_market_books(
832
+ self, identifier: str, *, depth: int = 10
833
+ ) -> MarketBooks:
834
+ market = await self.get_market(identifier)
835
+ token_ids = list(
836
+ dict.fromkeys(
837
+ outcome.token_id
838
+ for outcome in market.outcomes
839
+ if outcome.token_id
840
+ )
841
+ )
842
+ books = await asyncio.gather(
843
+ *(self.get_order_book(token_id, depth=depth) for token_id in token_ids)
844
+ )
845
+ return MarketBooks(
846
+ market_id=market.id,
847
+ question=market.question,
848
+ books=list(books),
849
+ )
850
+
851
+
852
+ _default_client: PolymarketClient | None = None
853
+
854
+
855
+ def get_polymarket_client() -> PolymarketClient:
856
+ """Return the process-wide client, creating it lazily."""
857
+ global _default_client
858
+ if _default_client is None:
859
+ _default_client = PolymarketClient()
860
+ return _default_client
861
+
862
+
863
+ async def close_polymarket_client() -> None:
864
+ """Close the process-wide client. Safe to call more than once."""
865
+ global _default_client
866
+ client = _default_client
867
+ _default_client = None
868
+ if client is not None:
869
+ await client.aclose()
870
+
871
+
872
+ async def list_events(
873
+ *,
874
+ tag_id: int | None = None,
875
+ tag_slug: str | None = None,
876
+ closed: bool = False,
877
+ sort: PredictionSort = "volume_24h",
878
+ limit: int = 10,
879
+ after_cursor: str | None = None,
880
+ ) -> EventPage:
881
+ return await get_polymarket_client().list_events(
882
+ tag_id=tag_id,
883
+ tag_slug=tag_slug,
884
+ closed=closed,
885
+ sort=sort,
886
+ limit=limit,
887
+ after_cursor=after_cursor,
888
+ )
889
+
890
+
891
+ async def get_event(identifier: str) -> EventDetail:
892
+ return await get_polymarket_client().get_event(identifier)
893
+
894
+
895
+ async def list_markets(
896
+ *,
897
+ tag_id: int | None = None,
898
+ tag_slug: str | None = None,
899
+ closed: bool = False,
900
+ sort: PredictionSort = "volume_24h",
901
+ limit: int = 10,
902
+ after_cursor: str | None = None,
903
+ ) -> MarketPage:
904
+ return await get_polymarket_client().list_markets(
905
+ tag_id=tag_id,
906
+ tag_slug=tag_slug,
907
+ closed=closed,
908
+ sort=sort,
909
+ limit=limit,
910
+ after_cursor=after_cursor,
911
+ )
912
+
913
+
914
+ async def get_market(identifier: str) -> MarketDetail:
915
+ return await get_polymarket_client().get_market(identifier)
916
+
917
+
918
+ async def search(
919
+ query: str,
920
+ *,
921
+ limit_per_type: int = 5,
922
+ include_tags: bool = True,
923
+ ) -> SearchResult:
924
+ return await get_polymarket_client().search(
925
+ query,
926
+ limit_per_type=limit_per_type,
927
+ include_tags=include_tags,
928
+ )
929
+
930
+
931
+ async def list_tags(*, limit: int = 100, offset: int = 0) -> list[TagOut]:
932
+ return await get_polymarket_client().list_tags(limit=limit, offset=offset)
933
+
934
+
935
+ async def get_tag(slug: str) -> TagOut:
936
+ return await get_polymarket_client().get_tag(slug)
937
+
938
+
939
+ async def get_order_book(token_id: str, *, depth: int = 10) -> OrderBook:
940
+ return await get_polymarket_client().get_order_book(token_id, depth=depth)
941
+
942
+
943
+ async def get_token_price(
944
+ token_id: str, *, side: OrderSide = "BUY"
945
+ ) -> TokenPrice:
946
+ return await get_polymarket_client().get_token_price(token_id, side=side)
947
+
948
+
949
+ async def get_price_history(
950
+ token_id: str,
951
+ *,
952
+ interval: PriceInterval = "1d",
953
+ fidelity: int | None = None,
954
+ ) -> PriceHistory:
955
+ return await get_polymarket_client().get_price_history(
956
+ token_id,
957
+ interval=interval,
958
+ fidelity=fidelity,
959
+ )
960
+
961
+
962
+ async def get_market_books(identifier: str, *, depth: int = 10) -> MarketBooks:
963
+ return await get_polymarket_client().get_market_books(identifier, depth=depth)