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 +15 -0
- polypulse/__main__.py +3 -0
- polypulse/benchmark.py +107 -0
- polypulse/cli.py +59 -0
- polypulse/feed.py +284 -0
- polypulse/markets.py +97 -0
- polypulse/orderbook.py +99 -0
- polypulse/py.typed +0 -0
- polypulse/rest.py +33 -0
- polypulse-0.1.0.dist-info/METADATA +161 -0
- polypulse-0.1.0.dist-info/RECORD +14 -0
- polypulse-0.1.0.dist-info/WHEEL +4 -0
- polypulse-0.1.0.dist-info/entry_points.txt +2 -0
- polypulse-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
+
[](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,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,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.
|