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.
- polypulse-0.1.0/.github/workflows/ci.yml +22 -0
- polypulse-0.1.0/.github/workflows/publish.yml +19 -0
- polypulse-0.1.0/.gitignore +14 -0
- polypulse-0.1.0/CHANGELOG.md +8 -0
- polypulse-0.1.0/LAUNCH.md +43 -0
- polypulse-0.1.0/LICENSE +21 -0
- polypulse-0.1.0/PKG-INFO +161 -0
- polypulse-0.1.0/README.md +134 -0
- polypulse-0.1.0/examples/print_top_of_book.py +18 -0
- polypulse-0.1.0/examples/react_to_updates.py +19 -0
- polypulse-0.1.0/examples/run_benchmark.py +7 -0
- polypulse-0.1.0/polypulse/__init__.py +15 -0
- polypulse-0.1.0/polypulse/__main__.py +3 -0
- polypulse-0.1.0/polypulse/benchmark.py +107 -0
- polypulse-0.1.0/polypulse/cli.py +59 -0
- polypulse-0.1.0/polypulse/feed.py +284 -0
- polypulse-0.1.0/polypulse/markets.py +97 -0
- polypulse-0.1.0/polypulse/orderbook.py +99 -0
- polypulse-0.1.0/polypulse/py.typed +0 -0
- polypulse-0.1.0/polypulse/rest.py +33 -0
- polypulse-0.1.0/pyproject.toml +53 -0
- polypulse-0.1.0/tests/__init__.py +0 -0
- polypulse-0.1.0/tests/conftest.py +40 -0
- polypulse-0.1.0/tests/test_benchmark.py +34 -0
- polypulse-0.1.0/tests/test_cli.py +38 -0
- polypulse-0.1.0/tests/test_feed.py +319 -0
- polypulse-0.1.0/tests/test_integration.py +9 -0
- polypulse-0.1.0/tests/test_markets.py +109 -0
- polypulse-0.1.0/tests/test_orderbook.py +94 -0
- polypulse-0.1.0/tests/test_rest.py +73 -0
|
@@ -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,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
|
polypulse-0.1.0/LICENSE
ADDED
|
@@ -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.
|
polypulse-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/polypulse/)
|
|
31
|
+
[](https://github.com/Gavr625/polypulse/actions/workflows/ci.yml)
|
|
32
|
+
[](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
|
+
[](https://pypi.org/project/polypulse/)
|
|
4
|
+
[](https://github.com/Gavr625/polypulse/actions/workflows/ci.yml)
|
|
5
|
+
[](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,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,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
|