polypulse 0.1.0__tar.gz

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.
@@ -0,0 +1,22 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: ruff check polypulse tests
21
+ - run: mypy polypulse
22
+ - run: pytest -q
@@ -0,0 +1,19 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write # trusted publishing (OIDC)
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build
18
+ - run: python -m build
19
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ .DS_Store
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-06-27
4
+ - Initial release: `BookFeed` real-time Polymarket order book over WebSocket.
5
+ - Heartbeat, PONG-aware watchdog, exponential-backoff reconnect, REST fallback.
6
+ - `OrderBook` model with best bid/ask, mid, spread, depth.
7
+ - Market discovery: `list_markets` / `tokens_for_slug`.
8
+ - CLI: `polypulse benchmark`, `polypulse watch`, `polypulse markets`.
@@ -0,0 +1,43 @@
1
+ # Launch checklist — polypulse
2
+
3
+ ## Pre-flight (must all be true before announcing)
4
+ - [ ] `pip install polypulse` works from a clean venv
5
+ - [ ] README hook shows REAL benchmark numbers from `python -m polypulse benchmark`
6
+ - [ ] A short GIF/asciinema of `polypulse watch <token>` is embedded in the README
7
+ - [ ] CI is green on 3.10/3.11/3.12
8
+ - [ ] Published to PyPI (version 0.1.0)
9
+ - [ ] GitHub repo description + topics set:
10
+ polymarket, prediction-markets, orderbook, websocket, clob, low-latency, asyncio, trading, python
11
+
12
+ ## Announce (in order of signal)
13
+ - [ ] Polymarket Discord — builders/dev channel
14
+ - [ ] X/Twitter thread (draft below)
15
+ - [ ] Show HN (draft below) — post a weekday morning US time
16
+ - [ ] Reddit: r/algotrading, r/Polymarket, r/Python (show-and-tell)
17
+ - [ ] PRs to awesome lists: awesome-quant, awesome-asyncio, awesome-polymarket (if it exists)
18
+
19
+ ## After
20
+ - [ ] Respond to every issue/PR within ~24h for the first 2 weeks
21
+ - [ ] Pin 1-2 "good first issue"s
22
+ - [ ] Optional: dev.to/Medium write-up of the latency findings, linked from README
23
+
24
+ ---
25
+
26
+ ## Show HN draft
27
+ **Title:** Show HN: polypulse - a never-freezing real-time order book for Polymarket
28
+
29
+ I built this for a Polymarket trading bot. The trading edge didn't survive contact
30
+ with reality, but the infrastructure did: a low-latency in-memory order book over
31
+ WebSocket that reconnects itself when Polymarket's socket silently freezes (a real,
32
+ documented failure mode). Reads are 0 ms (in-memory) vs ~19-80 ms per REST poll.
33
+ MIT, pip-installable, tested. Happy to answer questions about the latency work.
34
+
35
+ ## X/Twitter thread draft
36
+ 1/ I open-sourced polypulse - a real-time Polymarket order book that never freezes.
37
+ pip install polypulse. Here's why it exists.
38
+ 2/ Reading the book via REST /book costs ~19-80 ms per call and is ~1 s stale.
39
+ Over WebSocket you get pushes ~sub-2 ms from the matching engine.
40
+ 3/ But Polymarket's WS can silently freeze - open connection, no events. polypulse
41
+ ships a PONG-aware watchdog + auto-reconnect + REST fallback so the book stays live.
42
+ 4/ Built it for a trading bot; the edge didn't pan out, the infra did. Take it.
43
+ MIT, typed, tested, CI. Star it: github.com/Gavr625/polypulse
@@ -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.
@@ -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,134 @@
1
+ # polypulse
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/polypulse.svg)](https://pypi.org/project/polypulse/)
4
+ [![CI](https://github.com/Gavr625/polypulse/actions/workflows/ci.yml/badge.svg)](https://github.com/Gavr625/polypulse/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
+
7
+ **Real-time pulse of Polymarket — an order book feed that never freezes.**
8
+
9
+ `polypulse` keeps a live, in-memory Polymarket order book over WebSocket, so reading
10
+ the best bid/ask is instant — with no per-read network round-trip. REST `/book` polling
11
+ pays its latency on **every** read (≈185 ms from a typical host, ≈19 ms warm when
12
+ colocated); `polypulse` pays a one-time subscribe, then updates are pushed.
13
+ It adds the production reliability the official tooling lacks: heartbeat, a
14
+ PONG-aware watchdog that detects the silent WS freeze ([issue #292](https://github.com/Polymarket/py-clob-client/issues/292)),
15
+ exponential-backoff reconnect, and an optional REST fallback.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install polypulse
21
+ ```
22
+
23
+ ## Quickstart
24
+
25
+ ```python
26
+ import asyncio
27
+ from polypulse import BookFeed
28
+
29
+ async def main():
30
+ feed = BookFeed(["<token_id>"])
31
+ asyncio.create_task(feed.run()) # connects, reconnects, self-heals
32
+ await asyncio.sleep(2)
33
+ print(feed.best_bid("<token_id>"), feed.mid("<token_id>")) # 0 ms, in-memory
34
+ feed.stop()
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ ## Discover markets
40
+
41
+ No `token_id` yet? Find active markets and their tokens via the Gamma API:
42
+
43
+ ```python
44
+ from polypulse import list_markets, tokens_for_slug, BookFeed
45
+
46
+ markets = list_markets(tag="weather") # active markets (tag is optional)
47
+ tokens = tokens_for_slug(markets, markets[0].slug) # the token ids for one market
48
+ feed = BookFeed(tokens) # ...now stream them
49
+ ```
50
+
51
+ Or from the terminal:
52
+
53
+ ```bash
54
+ polypulse markets --tag weather
55
+ ```
56
+
57
+ ## Why
58
+
59
+ REST `/book` polling pays per-read latency and serves a book that is ~1 s stale.
60
+ Polymarket's WebSocket can also **silently freeze** — the connection stays open but
61
+ events stop. `polypulse` pushes updates as the book changes (no per-read latency) and
62
+ guarantees liveness with a watchdog that reconnects the moment the socket goes quiet.
63
+
64
+ ## API
65
+
66
+ `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)`
67
+
68
+ - `best_bid / best_ask / mid / spread (token_id)` — sync, no network
69
+ - `book(token_id)` — full-depth `OrderBook` snapshot
70
+ - `staleness(token_id)`, `source(token_id)` — freshness introspection
71
+ - `await run()` / `stop()`
72
+
73
+ ### Behavior notes
74
+
75
+ - **Reads are synchronous and never hit the network** — they return whatever the
76
+ background `run()` task has most recently applied.
77
+ - **`source(token_id)`** is `"ws"` when the latest update came from the live socket,
78
+ or `"rest"` when it came from the REST fallback (used only while the WS is down).
79
+ `staleness(token_id)` is seconds since that token last updated.
80
+ - **`on_update` fires on WebSocket events only.** While the socket is down, the REST
81
+ fallback keeps the book fresh for readers (`best_bid` etc.) but does not invoke the
82
+ callback; on reconnect you get a fresh `book` snapshot (which does fire it).
83
+ - **`stop()`** signals shutdown and closes the active connection so `run()` returns
84
+ promptly.
85
+
86
+ ## Benchmark
87
+
88
+ ```bash
89
+ python -m polypulse benchmark
90
+ ```
91
+
92
+ It picks a live market and compares REST `/book` time-to-first-byte against the
93
+ WebSocket feed. Example run (from a non-colocated host — your absolute numbers depend
94
+ on where you run it):
95
+
96
+ ```
97
+ market: highest-temperature-in-karachi-on-june-29-2026 (11 tokens)
98
+ REST /book TTFB: median 184.5ms min 124.4 max 238.3 (n=8)
99
+ WS subscribe → first book: 220.6ms
100
+ WS updates in 30s: 130 (4.3/s)
101
+
102
+ === verdict ===
103
+ REST pays ~185ms EVERY read; WS pays 221ms ONCE, then updates are PUSHED (no per-read latency).
104
+ ```
105
+
106
+ Absolute latency drops sharply when colocated (a eu-west-2 host measured ~19 ms warm
107
+ REST GETs), but the structural win holds everywhere: REST pays its round-trip on every
108
+ read; the WS feed pays once.
109
+
110
+ ## Watch a live book
111
+
112
+ ```bash
113
+ polypulse watch <token_id>
114
+ ```
115
+
116
+ Prints top-of-book once a second — a quick sanity check that the feed is live:
117
+
118
+ ```
119
+ 3414098972... bid=0.012 ask=0.024 mid=0.018 src=ws
120
+ 3414098972... bid=0.012 ask=0.024 mid=0.018 src=ws
121
+ ```
122
+
123
+ Values tick as the market moves; `src` shows `ws` or `rest` (REST fallback). An
124
+ animated GIF reads even better here — record one to drop in.
125
+
126
+ ## Honest note
127
+
128
+ `polypulse` was extracted from a live Polymarket trading bot. The trading edge didn't
129
+ pan out, but the low-latency feed is solid and battle-tested — so here it is. Out of
130
+ scope (for now): generic multi-CLOB support, book integrity hashing, indicators.
131
+
132
+ ## License
133
+
134
+ MIT.
@@ -0,0 +1,18 @@
1
+ """Print top-of-book for one token once the feed warms up."""
2
+
3
+ import asyncio
4
+
5
+ from polypulse import BookFeed
6
+
7
+ TOKEN = "REPLACE_WITH_A_CLOB_TOKEN_ID"
8
+
9
+
10
+ async def main() -> None:
11
+ feed = BookFeed([TOKEN])
12
+ asyncio.create_task(feed.run())
13
+ await asyncio.sleep(2)
14
+ print("bid", feed.best_bid(TOKEN), "ask", feed.best_ask(TOKEN), "mid", feed.mid(TOKEN))
15
+ feed.stop()
16
+
17
+
18
+ asyncio.run(main())
@@ -0,0 +1,19 @@
1
+ """React to every book update via a callback."""
2
+
3
+ import asyncio
4
+
5
+ from polypulse import BookFeed
6
+
7
+ TOKEN = "REPLACE_WITH_A_CLOB_TOKEN_ID"
8
+
9
+
10
+ def on_update(token_id: str, event: dict) -> None:
11
+ print(event["event_type"], token_id)
12
+
13
+
14
+ async def main() -> None:
15
+ feed = BookFeed([TOKEN], on_update=on_update)
16
+ await asyncio.gather(feed.run())
17
+
18
+
19
+ asyncio.run(main())
@@ -0,0 +1,7 @@
1
+ """Run the latency benchmark (equivalent to `python -m polypulse benchmark`)."""
2
+
3
+ import asyncio
4
+
5
+ from polypulse.benchmark import run_benchmark
6
+
7
+ asyncio.run(run_benchmark())
@@ -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
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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).")
@@ -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