polypulse 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.
polypulse/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """polypulse — real-time Polymarket order-book feed."""
2
+
3
+ from .feed import BookFeed
4
+ from .markets import Market, list_markets, tokens_for_slug
5
+ from .orderbook import OrderBook
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = [
9
+ "BookFeed",
10
+ "Market",
11
+ "OrderBook",
12
+ "list_markets",
13
+ "tokens_for_slug",
14
+ "__version__",
15
+ ]
polypulse/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
polypulse/benchmark.py ADDED
@@ -0,0 +1,107 @@
1
+ """Latency benchmark: WebSocket push vs REST /book TTFB on a live market."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import statistics
8
+ import time
9
+ from typing import Any
10
+
11
+ import websockets
12
+
13
+ from .feed import WS_URL
14
+ from .rest import fetch_book, get_json
15
+
16
+ GAMMA = (
17
+ "https://gamma-api.polymarket.com/events"
18
+ "?tag_slug=weather&limit=80&order=createdAt&ascending=false"
19
+ )
20
+ GAMMA_FALLBACK = (
21
+ "https://gamma-api.polymarket.com/events"
22
+ "?limit=80&order=createdAt&ascending=false"
23
+ )
24
+
25
+
26
+ def pick_active_market(
27
+ events: list[dict[str, Any]], min_tokens: int = 4
28
+ ) -> tuple[str, list[str]] | None:
29
+ """From Gamma events, return (slug, [first token per market]) for the first
30
+ event exposing at least ``min_tokens`` open markets."""
31
+ for ev in events:
32
+ slug = ev.get("slug", "")
33
+ tokens: list[str] = []
34
+ for m in ev.get("markets", []):
35
+ if m.get("closed") or m.get("active") is False:
36
+ continue
37
+ cti = m.get("clobTokenIds")
38
+ if isinstance(cti, str):
39
+ try:
40
+ cti = json.loads(cti)
41
+ except Exception:
42
+ continue
43
+ if cti:
44
+ tokens.append(str(cti[0]))
45
+ if len(tokens) >= min_tokens:
46
+ return slug, tokens
47
+ return None
48
+
49
+
50
+ async def run_benchmark() -> None:
51
+ print("=== Polymarket CLOB: WebSocket push vs REST poll latency ===\n")
52
+ picked = pick_active_market(get_json(GAMMA))
53
+ if not picked:
54
+ picked = pick_active_market(get_json(GAMMA_FALLBACK))
55
+ if not picked:
56
+ print("no active market found")
57
+ return
58
+ slug, tokens = picked
59
+ one = tokens[0]
60
+ print(f"market: {slug} ({len(tokens)} tokens)\n")
61
+
62
+ rest_ms: list[float] = []
63
+ for _ in range(8):
64
+ t0 = time.time()
65
+ try:
66
+ fetch_book(one)
67
+ rest_ms.append((time.time() - t0) * 1000)
68
+ except Exception as exc:
69
+ print(f" REST err: {exc}")
70
+ await asyncio.sleep(0.3)
71
+ if rest_ms:
72
+ print(f"REST /book TTFB: median {statistics.median(rest_ms):.1f}ms "
73
+ f"min {min(rest_ms):.1f} max {max(rest_ms):.1f} (n={len(rest_ms)})")
74
+
75
+ first_book_ms: float | None = None
76
+ update_count = 0
77
+ async with websockets.connect(WS_URL, ping_interval=None, open_timeout=10) as ws:
78
+ sub_msg = json.dumps(
79
+ {"assets_ids": tokens, "type": "market", "custom_feature_enabled": True}
80
+ )
81
+ await ws.send(sub_msg)
82
+ t_sub = time.time()
83
+ deadline = t_sub + 30
84
+ while time.time() < deadline:
85
+ try:
86
+ raw = await asyncio.wait_for(ws.recv(), timeout=max(0.1, deadline - time.time()))
87
+ except asyncio.TimeoutError:
88
+ break
89
+ if raw == "PONG":
90
+ continue
91
+ data = json.loads(raw)
92
+ for ev in (data if isinstance(data, list) else [data]):
93
+ et = ev.get("event_type")
94
+ if et == "book" and first_book_ms is None:
95
+ first_book_ms = (time.time() - t_sub) * 1000
96
+ if et in ("book", "price_change"):
97
+ update_count += 1
98
+
99
+ elapsed = time.time() - t_sub
100
+ if first_book_ms is not None:
101
+ print(f"\nWS subscribe → first book: {first_book_ms:.1f}ms")
102
+ rate = update_count / elapsed if elapsed > 0 else 0.0
103
+ print(f"WS updates in {elapsed:.0f}s: {update_count} ({rate:.1f}/s)")
104
+ if rest_ms and first_book_ms is not None:
105
+ print("\n=== verdict ===")
106
+ print(f"REST pays ~{statistics.median(rest_ms):.0f}ms EVERY read; "
107
+ f"WS pays {first_book_ms:.0f}ms ONCE, then updates are PUSHED (no per-read latency).")
polypulse/cli.py ADDED
@@ -0,0 +1,59 @@
1
+ """Command-line interface: `polypulse benchmark`, `polypulse watch`, `polypulse markets`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+
8
+ from .benchmark import run_benchmark
9
+ from .feed import BookFeed
10
+ from .markets import list_markets
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="polypulse", description="Real-time Polymarket order-book feed."
16
+ )
17
+ sub = parser.add_subparsers(dest="command")
18
+ sub.add_parser("benchmark", help="measure WS push vs REST /book latency on a live market")
19
+ watch = sub.add_parser("watch", help="stream and print top-of-book for token ids")
20
+ watch.add_argument("tokens", nargs="+", help="one or more CLOB token ids")
21
+ mk = sub.add_parser("markets", help="list active Polymarket markets and their token ids")
22
+ mk.add_argument("--tag", default=None, help="Gamma tag_slug filter, e.g. 'weather'")
23
+ mk.add_argument("--limit", type=int, default=100, help="max events to fetch")
24
+ return parser
25
+
26
+
27
+ async def _watch(tokens: list[str]) -> None:
28
+ feed = BookFeed(tokens)
29
+ task = asyncio.create_task(feed.run())
30
+ try:
31
+ while True:
32
+ await asyncio.sleep(1.0)
33
+ for t in tokens:
34
+ print(
35
+ f"{t[:10]}… bid={feed.best_bid(t)} ask={feed.best_ask(t)} "
36
+ f"mid={feed.mid(t)} src={feed.source(t)}"
37
+ )
38
+ finally:
39
+ feed.stop()
40
+ task.cancel()
41
+ await asyncio.gather(task, return_exceptions=True)
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ parser = build_parser()
46
+ args = parser.parse_args(argv)
47
+ try:
48
+ if args.command == "benchmark":
49
+ asyncio.run(run_benchmark())
50
+ elif args.command == "watch":
51
+ asyncio.run(_watch(args.tokens))
52
+ elif args.command == "markets":
53
+ for m in list_markets(tag=args.tag, limit=args.limit):
54
+ print(f"{m.slug}\n q: {m.question}\n tokens: {m.token_ids}")
55
+ else:
56
+ parser.print_help()
57
+ except KeyboardInterrupt:
58
+ pass
59
+ return 0
polypulse/feed.py ADDED
@@ -0,0 +1,284 @@
1
+ """Real-time Polymarket order-book feed over WebSocket."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import logging
9
+ import time
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ import websockets
14
+
15
+ from .orderbook import OrderBook
16
+ from .rest import fetch_book
17
+
18
+ WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
19
+
20
+ UpdateCallback = Callable[[str, dict[str, Any]], Any]
21
+
22
+
23
+ class BookFeed:
24
+ """Maintains a live in-memory order book for a fixed set of token ids.
25
+
26
+ Reads (:meth:`best_bid`, :meth:`mid`, …) are synchronous and never touch
27
+ the network. Call :meth:`run` (a coroutine) to keep the book fresh.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ token_ids: list[str],
33
+ on_update: UpdateCallback | None = None,
34
+ *,
35
+ ping_interval: float = 10.0,
36
+ watchdog_timeout: float = 30.0,
37
+ rest_fallback: bool = True,
38
+ max_backoff: float = 30.0,
39
+ rest_poll_interval: float = 1.0,
40
+ logger: logging.Logger | None = None,
41
+ ) -> None:
42
+ self.token_ids = [str(t) for t in token_ids]
43
+ self.on_update = on_update
44
+ self.ping_interval = ping_interval
45
+ self.watchdog_timeout = watchdog_timeout
46
+ self.rest_fallback = rest_fallback
47
+ self.max_backoff = max_backoff
48
+ self.rest_poll_interval = rest_poll_interval
49
+ self.logger = logger or logging.getLogger("polypulse")
50
+
51
+ self.books: dict[str, OrderBook] = {}
52
+ self._stop = False
53
+ self._wake = asyncio.Event()
54
+ self._connected = False
55
+ self._last_frame_ts = 0.0
56
+ self._ws: Any = None
57
+ self._pending: set[asyncio.Task[Any]] = set()
58
+
59
+ # ----- reads (sync, no network) -----
60
+
61
+ def _ob(self, token_id: str) -> OrderBook | None:
62
+ return self.books.get(str(token_id))
63
+
64
+ def best_bid(self, token_id: str) -> float | None:
65
+ ob = self._ob(token_id)
66
+ return ob.best_bid() if ob else None
67
+
68
+ def best_ask(self, token_id: str) -> float | None:
69
+ ob = self._ob(token_id)
70
+ return ob.best_ask() if ob else None
71
+
72
+ def mid(self, token_id: str) -> float | None:
73
+ ob = self._ob(token_id)
74
+ return ob.mid() if ob else None
75
+
76
+ def spread(self, token_id: str) -> float | None:
77
+ ob = self._ob(token_id)
78
+ return ob.spread() if ob else None
79
+
80
+ def book(self, token_id: str) -> OrderBook | None:
81
+ ob = self._ob(token_id)
82
+ return ob.snapshot() if ob else None
83
+
84
+ def staleness(self, token_id: str) -> float | None:
85
+ ob = self._ob(token_id)
86
+ return (time.time() - ob.ts) if ob else None
87
+
88
+ def source(self, token_id: str) -> str | None:
89
+ ob = self._ob(token_id)
90
+ return ob.source if ob else None
91
+
92
+ # ----- event handling -----
93
+
94
+ def _handle(self, raw: str) -> None:
95
+ try:
96
+ data = json.loads(raw)
97
+ except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
98
+ self.logger.debug("polypulse: dropped unparseable frame")
99
+ return
100
+ events = data if isinstance(data, list) else [data]
101
+ now = time.time()
102
+ for ev in events:
103
+ if not isinstance(ev, dict):
104
+ continue
105
+ try:
106
+ et = ev.get("event_type")
107
+ if et == "book":
108
+ aid = ev.get("asset_id")
109
+ if not aid:
110
+ continue
111
+ ob = self.books.get(aid)
112
+ if ob is None:
113
+ ob = OrderBook()
114
+ ob.apply_snapshot(ev.get("bids"), ev.get("asks"), now, "ws")
115
+ self.books[aid] = ob
116
+ self._fire(aid, ev)
117
+ elif et == "price_change":
118
+ affected: set[str] = set()
119
+ for pc in ev.get("price_changes") or []:
120
+ aid = pc.get("asset_id")
121
+ ob = self.books.get(aid) if aid else None
122
+ if ob is None:
123
+ continue
124
+ ob.apply_change(
125
+ pc.get("side", ""), pc.get("price", ""),
126
+ float(pc.get("size", 0)), now, "ws",
127
+ )
128
+ affected.add(aid)
129
+ for aid in affected:
130
+ self._fire(aid, ev)
131
+ else:
132
+ aid = ev.get("asset_id")
133
+ if aid:
134
+ self._fire(aid, ev)
135
+ except (ValueError, TypeError, KeyError):
136
+ self.logger.debug("polypulse: dropped malformed event")
137
+ continue
138
+
139
+ def _fire(self, token_id: str, event: dict[str, Any]) -> None:
140
+ if self.on_update is None:
141
+ return
142
+ try:
143
+ result = self.on_update(token_id, event)
144
+ except Exception:
145
+ self.logger.exception("polypulse: on_update callback raised")
146
+ return
147
+ if inspect.isawaitable(result):
148
+ try:
149
+ loop = asyncio.get_running_loop()
150
+ except RuntimeError:
151
+ # No running loop (sync caller): run to completion in a fresh loop.
152
+ try:
153
+ asyncio.run(self._await_cb(result))
154
+ except Exception:
155
+ self.logger.exception("polypulse: async on_update callback raised")
156
+ return
157
+ task = loop.create_task(self._await_cb(result))
158
+ self._pending.add(task)
159
+ task.add_done_callback(self._pending.discard)
160
+
161
+ async def _await_cb(self, awaitable: Any) -> None:
162
+ try:
163
+ await awaitable
164
+ except Exception:
165
+ self.logger.exception("polypulse: async on_update callback raised")
166
+
167
+ # ----- heartbeat & watchdog -----
168
+
169
+ async def _heartbeat(self, ws: Any) -> None:
170
+ try:
171
+ while True:
172
+ await asyncio.sleep(self.ping_interval)
173
+ await ws.send("PING")
174
+ except asyncio.CancelledError:
175
+ pass
176
+ except Exception:
177
+ return # socket dying during teardown — exit quietly
178
+
179
+ async def _watchdog(self, ws: Any) -> None:
180
+ interval = max(0.005, min(5.0, self.watchdog_timeout / 3))
181
+ try:
182
+ while True:
183
+ await asyncio.sleep(interval)
184
+ if time.time() - self._last_frame_ts > self.watchdog_timeout:
185
+ self.logger.debug("polypulse: watchdog tripped, forcing reconnect")
186
+ try:
187
+ await ws.close()
188
+ except Exception:
189
+ pass
190
+ return
191
+ except asyncio.CancelledError:
192
+ pass
193
+
194
+ # ----- REST fallback -----
195
+
196
+ def _apply_rest(self, token_id: str, data: dict[str, Any]) -> None:
197
+ ob = self.books.get(token_id)
198
+ if ob is None:
199
+ ob = OrderBook()
200
+ ob.apply_snapshot(data.get("bids"), data.get("asks"), time.time(), "rest")
201
+ self.books[token_id] = ob
202
+
203
+ async def _rest_fallback_loop(self) -> None:
204
+ if not self.rest_fallback:
205
+ return
206
+ loop = asyncio.get_running_loop()
207
+ while not self._stop:
208
+ await asyncio.sleep(self.rest_poll_interval)
209
+ if self._connected:
210
+ continue
211
+ for tid in self.token_ids:
212
+ try:
213
+ data = await loop.run_in_executor(None, fetch_book, tid)
214
+ self._apply_rest(tid, data)
215
+ except Exception as exc:
216
+ self.logger.debug("polypulse: rest fallback failed for %s: %s", tid, exc)
217
+
218
+ # ----- connection loop -----
219
+
220
+ def stop(self) -> None:
221
+ self._stop = True
222
+ self._wake.set()
223
+ ws = self._ws
224
+ if ws is not None:
225
+ try:
226
+ task = asyncio.get_running_loop().create_task(ws.close())
227
+ except RuntimeError:
228
+ return # no running loop; loop already stopped, close is moot
229
+ self._pending.add(task)
230
+ task.add_done_callback(self._pending.discard)
231
+
232
+ def _subscribe_msg(self) -> str:
233
+ return json.dumps({
234
+ "assets_ids": self.token_ids,
235
+ "type": "market",
236
+ "custom_feature_enabled": True,
237
+ })
238
+
239
+ async def run(self) -> None:
240
+ """Maintain the connection until :meth:`stop`, reconnecting with
241
+ exponential backoff and resubscribing each time. While the socket is
242
+ down, the optional REST fallback keeps books warm. :meth:`stop` closes
243
+ the active connection for prompt shutdown."""
244
+ rest_task = asyncio.create_task(self._rest_fallback_loop())
245
+ try:
246
+ backoff = 0.5
247
+ while not self._stop:
248
+ try:
249
+ async with websockets.connect(
250
+ WS_URL, ping_interval=None, open_timeout=10, close_timeout=5
251
+ ) as ws:
252
+ await ws.send(self._subscribe_msg())
253
+ self._connected = True
254
+ self._ws = ws
255
+ self._last_frame_ts = time.time()
256
+ backoff = 0.5
257
+ hb = asyncio.create_task(self._heartbeat(ws))
258
+ wd = asyncio.create_task(self._watchdog(ws))
259
+ try:
260
+ async for raw in ws:
261
+ self._last_frame_ts = time.time()
262
+ msg = (
263
+ raw if isinstance(raw, str)
264
+ else raw.decode("utf-8", "replace")
265
+ )
266
+ if msg == "PONG":
267
+ continue
268
+ self._handle(msg)
269
+ finally:
270
+ hb.cancel()
271
+ wd.cancel()
272
+ self._connected = False
273
+ self._ws = None
274
+ except Exception as exc:
275
+ self.logger.debug("polypulse: ws error, will reconnect: %s", exc)
276
+ if self._stop:
277
+ break
278
+ try:
279
+ await asyncio.wait_for(self._wake.wait(), timeout=backoff)
280
+ except asyncio.TimeoutError:
281
+ pass
282
+ backoff = min(backoff * 2, self.max_backoff)
283
+ finally:
284
+ rest_task.cancel()
polypulse/markets.py ADDED
@@ -0,0 +1,97 @@
1
+ """Market discovery via the Polymarket Gamma API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+ from urllib.parse import quote
9
+
10
+ from .rest import get_json
11
+
12
+ GAMMA_EVENTS = "https://gamma-api.polymarket.com/events"
13
+
14
+
15
+ @dataclass
16
+ class Market:
17
+ slug: str
18
+ question: str
19
+ condition_id: str
20
+ token_ids: list[str]
21
+ outcomes: list[str]
22
+ end_date: str | None = None
23
+ volume_24h: float | None = None
24
+
25
+
26
+ def _as_list(value: Any) -> list[Any]:
27
+ """Gamma sometimes returns list-ish fields as JSON-encoded strings."""
28
+ if isinstance(value, str):
29
+ try:
30
+ value = json.loads(value)
31
+ except Exception:
32
+ return []
33
+ return value if isinstance(value, list) else []
34
+
35
+
36
+ def _to_float(value: Any) -> float | None:
37
+ try:
38
+ return float(value)
39
+ except (TypeError, ValueError):
40
+ return None
41
+
42
+
43
+ def list_markets(
44
+ tag: str | None = None,
45
+ *,
46
+ closed: bool = False,
47
+ limit: int = 100,
48
+ timeout: float = 20.0,
49
+ ) -> list[Market]:
50
+ """Fetch markets from the Polymarket Gamma events API.
51
+
52
+ Args:
53
+ tag: optional Gamma ``tag_slug`` filter (e.g. ``"weather"``).
54
+ closed: include closed/inactive markets too (default: only open).
55
+ limit: max events to request.
56
+
57
+ Returns a flat list of :class:`Market`. Markets without parseable token ids are
58
+ skipped. Raises ``urllib.error.URLError`` on network failure.
59
+ """
60
+ url = f"{GAMMA_EVENTS}?limit={int(limit)}&order=createdAt&ascending=false"
61
+ if tag:
62
+ url += f"&tag_slug={quote(tag, safe='')}"
63
+ events = get_json(url, timeout=timeout)
64
+ out: list[Market] = []
65
+ for ev in events if isinstance(events, list) else []:
66
+ if not isinstance(ev, dict):
67
+ continue
68
+ ev_slug = str(ev.get("slug", ""))
69
+ for m in ev.get("markets") or []:
70
+ if not isinstance(m, dict):
71
+ continue
72
+ if not closed and (m.get("closed") or m.get("active") is False):
73
+ continue
74
+ token_ids = [str(t) for t in _as_list(m.get("clobTokenIds"))]
75
+ if not token_ids:
76
+ continue
77
+ ed = m.get("endDate") or ev.get("endDate")
78
+ out.append(
79
+ Market(
80
+ slug=str(m.get("slug") or ev_slug),
81
+ question=str(m.get("question") or ev.get("title") or ""),
82
+ condition_id=str(m.get("conditionId") or ""),
83
+ token_ids=token_ids,
84
+ outcomes=[str(o) for o in _as_list(m.get("outcomes"))],
85
+ end_date=str(ed) if ed is not None else None,
86
+ volume_24h=_to_float(m.get("volume24hr")),
87
+ )
88
+ )
89
+ return out
90
+
91
+
92
+ def tokens_for_slug(markets: list[Market], slug: str) -> list[str]:
93
+ """Return the token ids of the first market whose slug equals ``slug`` (else [])."""
94
+ for m in markets:
95
+ if m.slug == slug:
96
+ return list(m.token_ids)
97
+ return []
polypulse/orderbook.py ADDED
@@ -0,0 +1,99 @@
1
+ """Pure in-memory order-book model. No IO."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class OrderBook:
11
+ """One market's order book. Prices are kept as strings (exchange-native),
12
+ sizes as floats. Best bid/ask are cached on mutation for O(1) reads."""
13
+
14
+ bids: dict[str, float] = field(default_factory=dict)
15
+ asks: dict[str, float] = field(default_factory=dict)
16
+ ts: float = 0.0
17
+ source: str = "ws" # "ws" | "rest"
18
+ _best_bid: float | None = field(default=None, init=False, repr=False)
19
+ _best_ask: float | None = field(default=None, init=False, repr=False)
20
+
21
+ def __post_init__(self) -> None:
22
+ self._recompute()
23
+
24
+ def apply_snapshot(
25
+ self,
26
+ bids: list[dict[str, Any]] | None,
27
+ asks: list[dict[str, Any]] | None,
28
+ ts: float,
29
+ source: str = "ws",
30
+ ) -> None:
31
+ self.bids = self._build_side(bids)
32
+ self.asks = self._build_side(asks)
33
+ self.ts = ts
34
+ self.source = source
35
+ self._recompute()
36
+
37
+ @staticmethod
38
+ def _build_side(levels: list[dict[str, Any]] | None) -> dict[str, float]:
39
+ out: dict[str, float] = {}
40
+ for lvl in levels or []:
41
+ try:
42
+ price = str(lvl["price"])
43
+ size = float(lvl["size"])
44
+ float(price) # ensure the price key is numeric so _recompute can't choke
45
+ except (KeyError, TypeError, ValueError):
46
+ continue
47
+ if size > 0:
48
+ out[price] = size
49
+ return out
50
+
51
+ def apply_change(
52
+ self, side: str, price: str, size: float, ts: float, source: str = "ws"
53
+ ) -> None:
54
+ if side == "BUY":
55
+ book = self.bids
56
+ elif side == "SELL":
57
+ book = self.asks
58
+ else:
59
+ return # unknown side: ignore rather than corrupt a side
60
+ try:
61
+ float(price)
62
+ except (TypeError, ValueError):
63
+ return # unparseable price: skip rather than poison the book
64
+ if size <= 0:
65
+ book.pop(price, None)
66
+ else:
67
+ book[price] = size
68
+ self.ts = ts
69
+ self.source = source
70
+ self._recompute()
71
+
72
+ def _recompute(self) -> None:
73
+ self._best_bid = max((float(p) for p in self.bids), default=None)
74
+ self._best_ask = min((float(p) for p in self.asks), default=None)
75
+
76
+ def best_bid(self) -> float | None:
77
+ return self._best_bid
78
+
79
+ def best_ask(self) -> float | None:
80
+ return self._best_ask
81
+
82
+ def mid(self) -> float | None:
83
+ if self._best_bid is None or self._best_ask is None:
84
+ return None
85
+ return round((self._best_bid + self._best_ask) / 2, 10)
86
+
87
+ def spread(self) -> float | None:
88
+ if self._best_bid is None or self._best_ask is None:
89
+ return None
90
+ return self._best_ask - self._best_bid
91
+
92
+ def bid_levels(self) -> list[tuple[float, float]]:
93
+ return sorted(((float(p), s) for p, s in self.bids.items()), reverse=True)
94
+
95
+ def ask_levels(self) -> list[tuple[float, float]]:
96
+ return sorted((float(p), s) for p, s in self.asks.items())
97
+
98
+ def snapshot(self) -> OrderBook:
99
+ return OrderBook(bids=dict(self.bids), asks=dict(self.asks), ts=self.ts, source=self.source)
polypulse/py.typed ADDED
File without changes
polypulse/rest.py ADDED
@@ -0,0 +1,33 @@
1
+ """Tiny stdlib REST helper for Polymarket CLOB /book (fallback + benchmark)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.request
7
+ from typing import Any
8
+ from urllib.parse import quote
9
+
10
+ REST_BOOK = "https://clob.polymarket.com/book?token_id="
11
+
12
+
13
+ def get_json(url: str, timeout: float = 20.0) -> Any:
14
+ """Blocking GET returning parsed JSON. Raises ``urllib.error.URLError`` (or its
15
+ ``HTTPError`` subclass) on network/HTTP failure."""
16
+ req = urllib.request.Request(url, headers={"User-Agent": "polypulse"})
17
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (trusted host)
18
+ return json.load(resp)
19
+
20
+
21
+ def fetch_book(token_id: str, timeout: float = 10.0) -> dict[str, Any]:
22
+ """Blocking GET of the order book for one token. Returns the parsed JSON
23
+ (keys include ``bids`` and ``asks``, each a list of ``{"price","size"}``).
24
+
25
+ Raises ``urllib.error.URLError`` (or its ``HTTPError`` subclass) on
26
+ network failures or non-success HTTP status codes."""
27
+ req = urllib.request.Request(
28
+ REST_BOOK + quote(token_id, safe=""),
29
+ headers={"User-Agent": "polypulse"},
30
+ )
31
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (trusted host)
32
+ data: dict[str, Any] = json.load(resp)
33
+ return data
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: polypulse
3
+ Version: 0.1.0
4
+ Summary: Real-time pulse of Polymarket — an order book feed that never freezes.
5
+ Project-URL: Homepage, https://github.com/Gavr625/polypulse
6
+ Project-URL: Issues, https://github.com/Gavr625/polypulse/issues
7
+ Author: Gavr625
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: asyncio,clob,low-latency,orderbook,polymarket,prediction-markets,trading,websocket
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Office/Business :: Financial :: Investment
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: websockets>=12.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.10; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.5; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # polypulse
29
+
30
+ [![PyPI](https://img.shields.io/pypi/v/polypulse.svg)](https://pypi.org/project/polypulse/)
31
+ [![CI](https://github.com/Gavr625/polypulse/actions/workflows/ci.yml/badge.svg)](https://github.com/Gavr625/polypulse/actions/workflows/ci.yml)
32
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
33
+
34
+ **Real-time pulse of Polymarket — an order book feed that never freezes.**
35
+
36
+ `polypulse` keeps a live, in-memory Polymarket order book over WebSocket, so reading
37
+ the best bid/ask is instant — with no per-read network round-trip. REST `/book` polling
38
+ pays its latency on **every** read (≈185 ms from a typical host, ≈19 ms warm when
39
+ colocated); `polypulse` pays a one-time subscribe, then updates are pushed.
40
+ It adds the production reliability the official tooling lacks: heartbeat, a
41
+ PONG-aware watchdog that detects the silent WS freeze ([issue #292](https://github.com/Polymarket/py-clob-client/issues/292)),
42
+ exponential-backoff reconnect, and an optional REST fallback.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install polypulse
48
+ ```
49
+
50
+ ## Quickstart
51
+
52
+ ```python
53
+ import asyncio
54
+ from polypulse import BookFeed
55
+
56
+ async def main():
57
+ feed = BookFeed(["<token_id>"])
58
+ asyncio.create_task(feed.run()) # connects, reconnects, self-heals
59
+ await asyncio.sleep(2)
60
+ print(feed.best_bid("<token_id>"), feed.mid("<token_id>")) # 0 ms, in-memory
61
+ feed.stop()
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ## Discover markets
67
+
68
+ No `token_id` yet? Find active markets and their tokens via the Gamma API:
69
+
70
+ ```python
71
+ from polypulse import list_markets, tokens_for_slug, BookFeed
72
+
73
+ markets = list_markets(tag="weather") # active markets (tag is optional)
74
+ tokens = tokens_for_slug(markets, markets[0].slug) # the token ids for one market
75
+ feed = BookFeed(tokens) # ...now stream them
76
+ ```
77
+
78
+ Or from the terminal:
79
+
80
+ ```bash
81
+ polypulse markets --tag weather
82
+ ```
83
+
84
+ ## Why
85
+
86
+ REST `/book` polling pays per-read latency and serves a book that is ~1 s stale.
87
+ Polymarket's WebSocket can also **silently freeze** — the connection stays open but
88
+ events stop. `polypulse` pushes updates as the book changes (no per-read latency) and
89
+ guarantees liveness with a watchdog that reconnects the moment the socket goes quiet.
90
+
91
+ ## API
92
+
93
+ `BookFeed(token_ids, on_update=None, *, ping_interval=10, watchdog_timeout=30, rest_fallback=True, max_backoff=30, rest_poll_interval=1.0, logger=None)`
94
+
95
+ - `best_bid / best_ask / mid / spread (token_id)` — sync, no network
96
+ - `book(token_id)` — full-depth `OrderBook` snapshot
97
+ - `staleness(token_id)`, `source(token_id)` — freshness introspection
98
+ - `await run()` / `stop()`
99
+
100
+ ### Behavior notes
101
+
102
+ - **Reads are synchronous and never hit the network** — they return whatever the
103
+ background `run()` task has most recently applied.
104
+ - **`source(token_id)`** is `"ws"` when the latest update came from the live socket,
105
+ or `"rest"` when it came from the REST fallback (used only while the WS is down).
106
+ `staleness(token_id)` is seconds since that token last updated.
107
+ - **`on_update` fires on WebSocket events only.** While the socket is down, the REST
108
+ fallback keeps the book fresh for readers (`best_bid` etc.) but does not invoke the
109
+ callback; on reconnect you get a fresh `book` snapshot (which does fire it).
110
+ - **`stop()`** signals shutdown and closes the active connection so `run()` returns
111
+ promptly.
112
+
113
+ ## Benchmark
114
+
115
+ ```bash
116
+ python -m polypulse benchmark
117
+ ```
118
+
119
+ It picks a live market and compares REST `/book` time-to-first-byte against the
120
+ WebSocket feed. Example run (from a non-colocated host — your absolute numbers depend
121
+ on where you run it):
122
+
123
+ ```
124
+ market: highest-temperature-in-karachi-on-june-29-2026 (11 tokens)
125
+ REST /book TTFB: median 184.5ms min 124.4 max 238.3 (n=8)
126
+ WS subscribe → first book: 220.6ms
127
+ WS updates in 30s: 130 (4.3/s)
128
+
129
+ === verdict ===
130
+ REST pays ~185ms EVERY read; WS pays 221ms ONCE, then updates are PUSHED (no per-read latency).
131
+ ```
132
+
133
+ Absolute latency drops sharply when colocated (a eu-west-2 host measured ~19 ms warm
134
+ REST GETs), but the structural win holds everywhere: REST pays its round-trip on every
135
+ read; the WS feed pays once.
136
+
137
+ ## Watch a live book
138
+
139
+ ```bash
140
+ polypulse watch <token_id>
141
+ ```
142
+
143
+ Prints top-of-book once a second — a quick sanity check that the feed is live:
144
+
145
+ ```
146
+ 3414098972... bid=0.012 ask=0.024 mid=0.018 src=ws
147
+ 3414098972... bid=0.012 ask=0.024 mid=0.018 src=ws
148
+ ```
149
+
150
+ Values tick as the market moves; `src` shows `ws` or `rest` (REST fallback). An
151
+ animated GIF reads even better here — record one to drop in.
152
+
153
+ ## Honest note
154
+
155
+ `polypulse` was extracted from a live Polymarket trading bot. The trading edge didn't
156
+ pan out, but the low-latency feed is solid and battle-tested — so here it is. Out of
157
+ scope (for now): generic multi-CLOB support, book integrity hashing, indicators.
158
+
159
+ ## License
160
+
161
+ MIT.
@@ -0,0 +1,14 @@
1
+ polypulse/__init__.py,sha256=cY0R0ppHEamU0M9Gy47Pf7a04VVpZ0VMWdZGxWf-b7Y,324
2
+ polypulse/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ polypulse/benchmark.py,sha256=v6tKSVPeNAhSJxBxIb7nxCiDJtBevurOuAdaxB9Pkr8,3733
4
+ polypulse/cli.py,sha256=jOANoCWQwq-t1Sg8Cpar2b5Up4-rHAeoxGxgJiXqlIs,2113
5
+ polypulse/feed.py,sha256=5tH6NMdba-bU4h6vL3-GknATTECvyQzjRYM3GXLikgM,10494
6
+ polypulse/markets.py,sha256=s2ZcMDMNnpIDvJzo71Ta3X1xRK441j__0cupi23EUKs,3033
7
+ polypulse/orderbook.py,sha256=wQkwnfxlBYzNvkvmsjh9gC5IryK3efTbJPDCaHkiHMI,3306
8
+ polypulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ polypulse/rest.py,sha256=pXofDxMUcs6-frkpyt4OSSLkLU5cb9xFOGfEJwc2700,1304
10
+ polypulse-0.1.0.dist-info/METADATA,sha256=AoFIfN4U2lq0nh1tWOT3yOYuJ1cfrP_i6JtSGdWWPT4,5949
11
+ polypulse-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ polypulse-0.1.0.dist-info/entry_points.txt,sha256=CVMptO7VoI5eICkgFyycFTQmw3R9dhxb0V7xS_QqmMw,49
13
+ polypulse-0.1.0.dist-info/licenses/LICENSE,sha256=59_EiXd42QgW3WnfBghxrMuXP7ZYGz8yJz3QRlosey0,1064
14
+ polypulse-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ polypulse = polypulse.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gavr625
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.