polynode 0.10.4__tar.gz → 0.10.6__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.
- {polynode-0.10.4 → polynode-0.10.6}/PKG-INFO +27 -1
- {polynode-0.10.4 → polynode-0.10.6}/README.md +26 -0
- polynode-0.10.6/polynode/_version.py +1 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/client.py +300 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/engine.py +1 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/orderbook.py +55 -10
- {polynode-0.10.4 → polynode-0.10.6}/polynode/orderbook_state.py +8 -2
- {polynode-0.10.4 → polynode-0.10.6}/polynode/subscription.py +13 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/types/ws.py +1 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/ws.py +47 -2
- {polynode-0.10.4 → polynode-0.10.6}/pyproject.toml +1 -1
- {polynode-0.10.4 → polynode-0.10.6}/tests/conftest.py +9 -5
- polynode-0.10.6/tests/test_client.py +141 -0
- {polynode-0.10.4 → polynode-0.10.6}/tests/test_orderbook.py +15 -1
- polynode-0.10.6/tests/test_orderbook_protocol.py +131 -0
- polynode-0.10.6/tests/test_ws_protocol.py +107 -0
- polynode-0.10.4/polynode/_version.py +0 -1
- polynode-0.10.4/tests/test_client.py +0 -45
- {polynode-0.10.4 → polynode-0.10.6}/.gitignore +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/__init__.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/cache/__init__.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/errors.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/redemption_watcher.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/short_form.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/testing.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/V2_ORDER_FLOW.md +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/__init__.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/clob_api.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/constants.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/cosigner.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/eip712.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/escrow.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/onboarding.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/position_management.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/privy.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/relayer.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/signer.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/sqlite_backend.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/trader.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/types.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/types/__init__.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/types/enums.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/types/events.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/types/orderbook.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/types/rest.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/polynode/types/short_form.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/tests/__init__.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/tests/test_trading.py +0 -0
- {polynode-0.10.4 → polynode-0.10.6}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: polynode
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.6
|
|
4
4
|
Summary: Python SDK for the PolyNode real-time prediction market data platform
|
|
5
5
|
Project-URL: Homepage, https://polynode.dev
|
|
6
6
|
Project-URL: Documentation, https://docs.polynode.dev
|
|
@@ -59,6 +59,32 @@ with PolyNode(api_key="pn_live_...") as pn:
|
|
|
59
59
|
settlements = pn.recent_settlements(count=5)
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
### Sports and Online Context
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from polynode import PolyNode
|
|
66
|
+
|
|
67
|
+
with PolyNode(api_key="pn_live_...") as pn:
|
|
68
|
+
state = pn.sports_game_state(
|
|
69
|
+
"nba-cle-nyk-2026-05-31",
|
|
70
|
+
price_limit_tokens=20,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
context = pn.sports_game_context(
|
|
74
|
+
"nba-cle-nyk-2026-05-31",
|
|
75
|
+
sources=["online"],
|
|
76
|
+
query_set="injuries",
|
|
77
|
+
max_queries=2,
|
|
78
|
+
max_per_query=5,
|
|
79
|
+
include_state=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
web = pn.search_online(
|
|
83
|
+
"Cavaliers Knicks injury news",
|
|
84
|
+
max_results=5,
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
62
88
|
### Async REST
|
|
63
89
|
|
|
64
90
|
```python
|
|
@@ -26,6 +26,32 @@ with PolyNode(api_key="pn_live_...") as pn:
|
|
|
26
26
|
settlements = pn.recent_settlements(count=5)
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
### Sports and Online Context
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from polynode import PolyNode
|
|
33
|
+
|
|
34
|
+
with PolyNode(api_key="pn_live_...") as pn:
|
|
35
|
+
state = pn.sports_game_state(
|
|
36
|
+
"nba-cle-nyk-2026-05-31",
|
|
37
|
+
price_limit_tokens=20,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
context = pn.sports_game_context(
|
|
41
|
+
"nba-cle-nyk-2026-05-31",
|
|
42
|
+
sources=["online"],
|
|
43
|
+
query_set="injuries",
|
|
44
|
+
max_queries=2,
|
|
45
|
+
max_per_query=5,
|
|
46
|
+
include_state=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
web = pn.search_online(
|
|
50
|
+
"Cavaliers Knicks injury news",
|
|
51
|
+
max_results=5,
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
29
55
|
### Async REST
|
|
30
56
|
|
|
31
57
|
```python
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.10.6"
|
|
@@ -32,6 +32,14 @@ from .types.rest import (
|
|
|
32
32
|
from .types.enums import CandleResolution, MarketSortField
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _csv_param(value: Any) -> Any:
|
|
36
|
+
if value is None or isinstance(value, str):
|
|
37
|
+
return value
|
|
38
|
+
if isinstance(value, (list, tuple, set)):
|
|
39
|
+
return ",".join(str(item) for item in value)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
35
43
|
class PolyNode:
|
|
36
44
|
"""Synchronous PolyNode REST client."""
|
|
37
45
|
|
|
@@ -187,6 +195,152 @@ class PolyNode:
|
|
|
187
195
|
self._fetch("/v1/search", query={"q": query, "limit": limit, "include_inactive": include_inactive})
|
|
188
196
|
)
|
|
189
197
|
|
|
198
|
+
def search_online(
|
|
199
|
+
self,
|
|
200
|
+
query: str,
|
|
201
|
+
*,
|
|
202
|
+
max_results: int | None = None,
|
|
203
|
+
engines: str | list[str] | tuple[str, ...] | set[str] | None = None,
|
|
204
|
+
language: str | None = None,
|
|
205
|
+
time_range: str | None = None,
|
|
206
|
+
safe_search: int | None = None,
|
|
207
|
+
cache_ttl_seconds: int | None = None,
|
|
208
|
+
) -> dict:
|
|
209
|
+
return self._fetch(
|
|
210
|
+
"/v2/search/online",
|
|
211
|
+
query={
|
|
212
|
+
"q": query,
|
|
213
|
+
"max_results": max_results,
|
|
214
|
+
"engines": _csv_param(engines),
|
|
215
|
+
"language": language,
|
|
216
|
+
"time_range": time_range,
|
|
217
|
+
"safe_search": safe_search,
|
|
218
|
+
"cache_ttl_seconds": cache_ttl_seconds,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# ── Sports ──
|
|
223
|
+
|
|
224
|
+
def sports_leagues(self) -> dict:
|
|
225
|
+
return self._fetch("/v2/sports/leagues")
|
|
226
|
+
|
|
227
|
+
def sports_teams(self, league: str, *, limit: int | None = None, offset: int | None = None) -> dict:
|
|
228
|
+
return self._fetch(
|
|
229
|
+
f"/v2/sports/leagues/{quote(league, safe='')}/teams",
|
|
230
|
+
query={"limit": limit, "offset": offset},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def sports_games(
|
|
234
|
+
self,
|
|
235
|
+
league: str,
|
|
236
|
+
*,
|
|
237
|
+
status: str | None = None,
|
|
238
|
+
limit: int | None = None,
|
|
239
|
+
offset: int | None = None,
|
|
240
|
+
sort: str | None = None,
|
|
241
|
+
direction: str | None = None,
|
|
242
|
+
) -> dict:
|
|
243
|
+
return self._fetch(
|
|
244
|
+
f"/v2/sports/leagues/{quote(league, safe='')}/games",
|
|
245
|
+
query={"status": status, "limit": limit, "offset": offset, "sort": sort, "direction": direction},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def sports_game(self, slug: str) -> dict:
|
|
249
|
+
return self._fetch(f"/v2/sports/games/{quote(slug, safe='')}")
|
|
250
|
+
|
|
251
|
+
def sports_game_state(
|
|
252
|
+
self,
|
|
253
|
+
slug: str,
|
|
254
|
+
*,
|
|
255
|
+
include_history: bool | None = None,
|
|
256
|
+
price_limit_tokens: int | None = None,
|
|
257
|
+
history_limit_tokens: int | None = None,
|
|
258
|
+
start_ts: int | str | None = None,
|
|
259
|
+
end_ts: int | str | None = None,
|
|
260
|
+
interval: str | None = None,
|
|
261
|
+
fidelity: int | None = None,
|
|
262
|
+
) -> dict:
|
|
263
|
+
return self._fetch(
|
|
264
|
+
f"/v2/sports/games/{quote(slug, safe='')}/state",
|
|
265
|
+
query={
|
|
266
|
+
"include_history": include_history,
|
|
267
|
+
"price_limit_tokens": price_limit_tokens,
|
|
268
|
+
"history_limit_tokens": history_limit_tokens,
|
|
269
|
+
"start_ts": start_ts,
|
|
270
|
+
"end_ts": end_ts,
|
|
271
|
+
"interval": interval,
|
|
272
|
+
"fidelity": fidelity,
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def sports_game_context(
|
|
277
|
+
self,
|
|
278
|
+
slug: str,
|
|
279
|
+
*,
|
|
280
|
+
sources: str | list[str] | tuple[str, ...] | set[str] | None = None,
|
|
281
|
+
query_set: str | None = None,
|
|
282
|
+
max_queries: int | None = None,
|
|
283
|
+
max_per_query: int | None = None,
|
|
284
|
+
include_state: bool | None = None,
|
|
285
|
+
price_limit_tokens: int | None = None,
|
|
286
|
+
) -> dict:
|
|
287
|
+
return self._fetch(
|
|
288
|
+
f"/v2/sports/games/{quote(slug, safe='')}/context",
|
|
289
|
+
query={
|
|
290
|
+
"sources": _csv_param(sources),
|
|
291
|
+
"query_set": query_set,
|
|
292
|
+
"max_queries": max_queries,
|
|
293
|
+
"max_per_query": max_per_query,
|
|
294
|
+
"include_state": include_state,
|
|
295
|
+
"price_limit_tokens": price_limit_tokens,
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def sports_game_prices(self, slug: str, *, limit_tokens: int | None = None) -> dict:
|
|
300
|
+
return self._fetch(
|
|
301
|
+
f"/v2/sports/games/{quote(slug, safe='')}/prices",
|
|
302
|
+
query={"limit_tokens": limit_tokens},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def sports_game_history(
|
|
306
|
+
self,
|
|
307
|
+
slug: str,
|
|
308
|
+
*,
|
|
309
|
+
start_ts: int | str | None = None,
|
|
310
|
+
end_ts: int | str | None = None,
|
|
311
|
+
interval: str | None = None,
|
|
312
|
+
fidelity: int | None = None,
|
|
313
|
+
limit_tokens: int | None = None,
|
|
314
|
+
) -> dict:
|
|
315
|
+
return self._fetch(
|
|
316
|
+
f"/v2/sports/games/{quote(slug, safe='')}/history",
|
|
317
|
+
query={
|
|
318
|
+
"start_ts": start_ts,
|
|
319
|
+
"end_ts": end_ts,
|
|
320
|
+
"interval": interval,
|
|
321
|
+
"fidelity": fidelity,
|
|
322
|
+
"limit_tokens": limit_tokens,
|
|
323
|
+
},
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def sports_market_types(self) -> dict:
|
|
327
|
+
return self._fetch("/v2/sports/market-types")
|
|
328
|
+
|
|
329
|
+
def sports_series(self, *, status: str | None = None, limit: int | None = None) -> dict:
|
|
330
|
+
return self._fetch("/v2/sports/series", query={"status": status, "limit": limit})
|
|
331
|
+
|
|
332
|
+
def sports_tournaments(self) -> dict:
|
|
333
|
+
return self._fetch("/v2/sports/tournaments")
|
|
334
|
+
|
|
335
|
+
def sports_live(self) -> dict:
|
|
336
|
+
return self._fetch("/v2/sports/live")
|
|
337
|
+
|
|
338
|
+
def sports_summary(self) -> dict:
|
|
339
|
+
return self._fetch("/v2/sports/summary")
|
|
340
|
+
|
|
341
|
+
def sports_search(self, query: str, *, limit: int | None = None) -> dict:
|
|
342
|
+
return self._fetch("/v2/sports/search", query={"q": query, "limit": limit})
|
|
343
|
+
|
|
190
344
|
# ── Pricing ──
|
|
191
345
|
|
|
192
346
|
def candles(
|
|
@@ -490,6 +644,152 @@ class AsyncPolyNode:
|
|
|
490
644
|
await self._fetch("/v1/search", query={"q": query, "limit": limit, "include_inactive": include_inactive})
|
|
491
645
|
)
|
|
492
646
|
|
|
647
|
+
async def search_online(
|
|
648
|
+
self,
|
|
649
|
+
query: str,
|
|
650
|
+
*,
|
|
651
|
+
max_results: int | None = None,
|
|
652
|
+
engines: str | list[str] | tuple[str, ...] | set[str] | None = None,
|
|
653
|
+
language: str | None = None,
|
|
654
|
+
time_range: str | None = None,
|
|
655
|
+
safe_search: int | None = None,
|
|
656
|
+
cache_ttl_seconds: int | None = None,
|
|
657
|
+
) -> dict:
|
|
658
|
+
return await self._fetch(
|
|
659
|
+
"/v2/search/online",
|
|
660
|
+
query={
|
|
661
|
+
"q": query,
|
|
662
|
+
"max_results": max_results,
|
|
663
|
+
"engines": _csv_param(engines),
|
|
664
|
+
"language": language,
|
|
665
|
+
"time_range": time_range,
|
|
666
|
+
"safe_search": safe_search,
|
|
667
|
+
"cache_ttl_seconds": cache_ttl_seconds,
|
|
668
|
+
},
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# ── Sports ──
|
|
672
|
+
|
|
673
|
+
async def sports_leagues(self) -> dict:
|
|
674
|
+
return await self._fetch("/v2/sports/leagues")
|
|
675
|
+
|
|
676
|
+
async def sports_teams(self, league: str, *, limit: int | None = None, offset: int | None = None) -> dict:
|
|
677
|
+
return await self._fetch(
|
|
678
|
+
f"/v2/sports/leagues/{quote(league, safe='')}/teams",
|
|
679
|
+
query={"limit": limit, "offset": offset},
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
async def sports_games(
|
|
683
|
+
self,
|
|
684
|
+
league: str,
|
|
685
|
+
*,
|
|
686
|
+
status: str | None = None,
|
|
687
|
+
limit: int | None = None,
|
|
688
|
+
offset: int | None = None,
|
|
689
|
+
sort: str | None = None,
|
|
690
|
+
direction: str | None = None,
|
|
691
|
+
) -> dict:
|
|
692
|
+
return await self._fetch(
|
|
693
|
+
f"/v2/sports/leagues/{quote(league, safe='')}/games",
|
|
694
|
+
query={"status": status, "limit": limit, "offset": offset, "sort": sort, "direction": direction},
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
async def sports_game(self, slug: str) -> dict:
|
|
698
|
+
return await self._fetch(f"/v2/sports/games/{quote(slug, safe='')}")
|
|
699
|
+
|
|
700
|
+
async def sports_game_state(
|
|
701
|
+
self,
|
|
702
|
+
slug: str,
|
|
703
|
+
*,
|
|
704
|
+
include_history: bool | None = None,
|
|
705
|
+
price_limit_tokens: int | None = None,
|
|
706
|
+
history_limit_tokens: int | None = None,
|
|
707
|
+
start_ts: int | str | None = None,
|
|
708
|
+
end_ts: int | str | None = None,
|
|
709
|
+
interval: str | None = None,
|
|
710
|
+
fidelity: int | None = None,
|
|
711
|
+
) -> dict:
|
|
712
|
+
return await self._fetch(
|
|
713
|
+
f"/v2/sports/games/{quote(slug, safe='')}/state",
|
|
714
|
+
query={
|
|
715
|
+
"include_history": include_history,
|
|
716
|
+
"price_limit_tokens": price_limit_tokens,
|
|
717
|
+
"history_limit_tokens": history_limit_tokens,
|
|
718
|
+
"start_ts": start_ts,
|
|
719
|
+
"end_ts": end_ts,
|
|
720
|
+
"interval": interval,
|
|
721
|
+
"fidelity": fidelity,
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
async def sports_game_context(
|
|
726
|
+
self,
|
|
727
|
+
slug: str,
|
|
728
|
+
*,
|
|
729
|
+
sources: str | list[str] | tuple[str, ...] | set[str] | None = None,
|
|
730
|
+
query_set: str | None = None,
|
|
731
|
+
max_queries: int | None = None,
|
|
732
|
+
max_per_query: int | None = None,
|
|
733
|
+
include_state: bool | None = None,
|
|
734
|
+
price_limit_tokens: int | None = None,
|
|
735
|
+
) -> dict:
|
|
736
|
+
return await self._fetch(
|
|
737
|
+
f"/v2/sports/games/{quote(slug, safe='')}/context",
|
|
738
|
+
query={
|
|
739
|
+
"sources": _csv_param(sources),
|
|
740
|
+
"query_set": query_set,
|
|
741
|
+
"max_queries": max_queries,
|
|
742
|
+
"max_per_query": max_per_query,
|
|
743
|
+
"include_state": include_state,
|
|
744
|
+
"price_limit_tokens": price_limit_tokens,
|
|
745
|
+
},
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
async def sports_game_prices(self, slug: str, *, limit_tokens: int | None = None) -> dict:
|
|
749
|
+
return await self._fetch(
|
|
750
|
+
f"/v2/sports/games/{quote(slug, safe='')}/prices",
|
|
751
|
+
query={"limit_tokens": limit_tokens},
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
async def sports_game_history(
|
|
755
|
+
self,
|
|
756
|
+
slug: str,
|
|
757
|
+
*,
|
|
758
|
+
start_ts: int | str | None = None,
|
|
759
|
+
end_ts: int | str | None = None,
|
|
760
|
+
interval: str | None = None,
|
|
761
|
+
fidelity: int | None = None,
|
|
762
|
+
limit_tokens: int | None = None,
|
|
763
|
+
) -> dict:
|
|
764
|
+
return await self._fetch(
|
|
765
|
+
f"/v2/sports/games/{quote(slug, safe='')}/history",
|
|
766
|
+
query={
|
|
767
|
+
"start_ts": start_ts,
|
|
768
|
+
"end_ts": end_ts,
|
|
769
|
+
"interval": interval,
|
|
770
|
+
"fidelity": fidelity,
|
|
771
|
+
"limit_tokens": limit_tokens,
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
async def sports_market_types(self) -> dict:
|
|
776
|
+
return await self._fetch("/v2/sports/market-types")
|
|
777
|
+
|
|
778
|
+
async def sports_series(self, *, status: str | None = None, limit: int | None = None) -> dict:
|
|
779
|
+
return await self._fetch("/v2/sports/series", query={"status": status, "limit": limit})
|
|
780
|
+
|
|
781
|
+
async def sports_tournaments(self) -> dict:
|
|
782
|
+
return await self._fetch("/v2/sports/tournaments")
|
|
783
|
+
|
|
784
|
+
async def sports_live(self) -> dict:
|
|
785
|
+
return await self._fetch("/v2/sports/live")
|
|
786
|
+
|
|
787
|
+
async def sports_summary(self) -> dict:
|
|
788
|
+
return await self._fetch("/v2/sports/summary")
|
|
789
|
+
|
|
790
|
+
async def sports_search(self, query: str, *, limit: int | None = None) -> dict:
|
|
791
|
+
return await self._fetch("/v2/sports/search", query={"q": query, "limit": limit})
|
|
792
|
+
|
|
493
793
|
# ── Pricing ──
|
|
494
794
|
|
|
495
795
|
async def candles(
|
|
@@ -28,7 +28,7 @@ class OrderbookWS:
|
|
|
28
28
|
|
|
29
29
|
def __init__(self, api_key: str, ob_url: str, *, compress: bool = True, auto_reconnect: bool = True,
|
|
30
30
|
max_reconnect_attempts: int = 0, reconnect_base_delay: float = 1.0,
|
|
31
|
-
reconnect_max_delay: float = 30.0) -> None:
|
|
31
|
+
reconnect_max_delay: float = 30.0, subscribe_timeout: float = 30.0) -> None:
|
|
32
32
|
self._api_key = api_key
|
|
33
33
|
self._ob_url = ob_url
|
|
34
34
|
self._compress = compress
|
|
@@ -36,12 +36,14 @@ class OrderbookWS:
|
|
|
36
36
|
self._max_reconnect = max_reconnect_attempts
|
|
37
37
|
self._base_delay = reconnect_base_delay
|
|
38
38
|
self._max_delay = reconnect_max_delay
|
|
39
|
+
self._subscribe_timeout = subscribe_timeout
|
|
39
40
|
|
|
40
41
|
self._socket: Any = None
|
|
41
42
|
self._connected = False
|
|
42
43
|
self._intentional_close = False
|
|
43
44
|
self._reconnect_attempts = 0
|
|
44
45
|
self._token_ids: list[str] = []
|
|
46
|
+
self._pending_subscribe: asyncio.Future | None = None
|
|
45
47
|
self._recv_task: asyncio.Task | None = None
|
|
46
48
|
|
|
47
49
|
self._handlers: dict[str, list[Callable]] = {
|
|
@@ -92,14 +94,20 @@ class OrderbookWS:
|
|
|
92
94
|
return self
|
|
93
95
|
|
|
94
96
|
async def subscribe(self, token_ids: list[str]) -> None:
|
|
95
|
-
self._token_ids = token_ids
|
|
97
|
+
self._token_ids = list(token_ids)
|
|
96
98
|
await self._ensure_connected()
|
|
97
|
-
await self.
|
|
98
|
-
|
|
99
|
-
def unsubscribe(self) -> None:
|
|
100
|
-
|
|
99
|
+
await self._send_subscribe(self._token_ids)
|
|
100
|
+
|
|
101
|
+
def unsubscribe(self, token_ids: list[str] | None = None) -> None:
|
|
102
|
+
if token_ids is None:
|
|
103
|
+
self._token_ids = []
|
|
104
|
+
msg = {"action": "unsubscribe"}
|
|
105
|
+
else:
|
|
106
|
+
remove = set(token_ids)
|
|
107
|
+
self._token_ids = [token_id for token_id in self._token_ids if token_id not in remove]
|
|
108
|
+
msg = {"action": "unsubscribe", "markets": token_ids}
|
|
101
109
|
if self._connected and self._socket:
|
|
102
|
-
asyncio.ensure_future(self._send(json.dumps(
|
|
110
|
+
asyncio.ensure_future(self._send(json.dumps(msg)))
|
|
103
111
|
|
|
104
112
|
async def connect(self) -> None:
|
|
105
113
|
if self._connected and self._socket:
|
|
@@ -157,6 +165,9 @@ class OrderbookWS:
|
|
|
157
165
|
return
|
|
158
166
|
finally:
|
|
159
167
|
self._connected = False
|
|
168
|
+
self._reject_pending_subscribe(
|
|
169
|
+
WsError("WebSocket connection closed before orderbook subscription was acknowledged")
|
|
170
|
+
)
|
|
160
171
|
reason = "closed" if self._intentional_close else "lost"
|
|
161
172
|
for h in self._on_disconnect:
|
|
162
173
|
h(reason)
|
|
@@ -167,11 +178,44 @@ class OrderbookWS:
|
|
|
167
178
|
if self._socket and self._connected:
|
|
168
179
|
await self._socket.send(data)
|
|
169
180
|
|
|
181
|
+
async def _send_subscribe(self, token_ids: list[str]) -> None:
|
|
182
|
+
if self._pending_subscribe and not self._pending_subscribe.done():
|
|
183
|
+
raise WsError("Subscribe already pending")
|
|
184
|
+
|
|
185
|
+
fut: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
186
|
+
self._pending_subscribe = fut
|
|
187
|
+
await self._send(json.dumps({"action": "subscribe", "markets": token_ids}))
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
await asyncio.wait_for(fut, timeout=self._subscribe_timeout)
|
|
191
|
+
except asyncio.TimeoutError:
|
|
192
|
+
if self._pending_subscribe is fut:
|
|
193
|
+
self._pending_subscribe = None
|
|
194
|
+
raise WsError(f"Subscribe timed out after {self._subscribe_timeout:g} seconds")
|
|
195
|
+
|
|
196
|
+
def _resolve_pending_subscribe(self) -> None:
|
|
197
|
+
fut = self._pending_subscribe
|
|
198
|
+
self._pending_subscribe = None
|
|
199
|
+
if fut and not fut.done():
|
|
200
|
+
fut.set_result(None)
|
|
201
|
+
|
|
202
|
+
def _reject_pending_subscribe(self, err: WsError) -> None:
|
|
203
|
+
fut = self._pending_subscribe
|
|
204
|
+
self._pending_subscribe = None
|
|
205
|
+
if fut and not fut.done():
|
|
206
|
+
fut.set_exception(err)
|
|
207
|
+
|
|
170
208
|
def _handle_message(self, text: str) -> None:
|
|
171
209
|
msg = json.loads(text)
|
|
172
210
|
|
|
173
|
-
if msg.get("error"):
|
|
174
|
-
err_msg = ObErrorMessage
|
|
211
|
+
if msg.get("error") or msg.get("type") == "error":
|
|
212
|
+
err_msg = ObErrorMessage(
|
|
213
|
+
error=str(msg.get("error") or msg.get("code") or "error"),
|
|
214
|
+
message=str(msg.get("message") or msg.get("error") or "Unknown orderbook error"),
|
|
215
|
+
max=msg.get("max"),
|
|
216
|
+
requested=msg.get("requested"),
|
|
217
|
+
)
|
|
218
|
+
self._reject_pending_subscribe(WsError(err_msg.message, err_msg.error))
|
|
175
219
|
for h in self._handlers["error"]:
|
|
176
220
|
h(err_msg)
|
|
177
221
|
return
|
|
@@ -180,6 +224,7 @@ class OrderbookWS:
|
|
|
180
224
|
|
|
181
225
|
if msg_type == "subscribed":
|
|
182
226
|
sub_msg = SubscribedMessage(markets=msg.get("markets", 0))
|
|
227
|
+
self._resolve_pending_subscribe()
|
|
183
228
|
for h in self._handlers["subscribed"]:
|
|
184
229
|
h(sub_msg)
|
|
185
230
|
|
|
@@ -238,6 +283,6 @@ class OrderbookWS:
|
|
|
238
283
|
for h in self._on_reconnect:
|
|
239
284
|
h(self._reconnect_attempts)
|
|
240
285
|
if self._token_ids:
|
|
241
|
-
await self.
|
|
286
|
+
await self._send_subscribe(self._token_ids)
|
|
242
287
|
except Exception:
|
|
243
288
|
pass
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from .types.orderbook import BookSnapshot, BookUpdate, OrderbookLevel, PriceChange
|
|
5
|
+
from .types.orderbook import BookSnapshot, BookUpdate, OrderbookLevel, PriceChange, PriceChangeAsset
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class LocalOrderbook:
|
|
@@ -10,6 +10,7 @@ class LocalOrderbook:
|
|
|
10
10
|
|
|
11
11
|
def __init__(self) -> None:
|
|
12
12
|
self._books: dict[str, tuple[list[OrderbookLevel], list[OrderbookLevel]]] = {}
|
|
13
|
+
self._last_prices: dict[str, PriceChangeAsset] = {}
|
|
13
14
|
|
|
14
15
|
def apply_snapshot(self, snap: BookSnapshot) -> None:
|
|
15
16
|
bids = sorted(list(snap.bids), key=lambda l: float(l.price), reverse=True)
|
|
@@ -25,11 +26,15 @@ class LocalOrderbook:
|
|
|
25
26
|
self._books[update.asset_id] = (new_bids, new_asks)
|
|
26
27
|
|
|
27
28
|
def apply_price_change(self, change: PriceChange) -> None:
|
|
28
|
-
|
|
29
|
+
for asset in change.assets:
|
|
30
|
+
self._last_prices[asset.asset_id] = asset
|
|
29
31
|
|
|
30
32
|
def get_book(self, asset_id: str) -> tuple[list[OrderbookLevel], list[OrderbookLevel]] | None:
|
|
31
33
|
return self._books.get(asset_id)
|
|
32
34
|
|
|
35
|
+
def get_last_price(self, asset_id: str) -> PriceChangeAsset | None:
|
|
36
|
+
return self._last_prices.get(asset_id)
|
|
37
|
+
|
|
33
38
|
def get_best_bid(self, asset_id: str) -> OrderbookLevel | None:
|
|
34
39
|
book = self._books.get(asset_id)
|
|
35
40
|
if book and book[0]:
|
|
@@ -55,6 +60,7 @@ class LocalOrderbook:
|
|
|
55
60
|
|
|
56
61
|
def clear(self) -> None:
|
|
57
62
|
self._books.clear()
|
|
63
|
+
self._last_prices.clear()
|
|
58
64
|
|
|
59
65
|
|
|
60
66
|
def _apply_deltas(
|
|
@@ -12,6 +12,8 @@ from .types.ws import SubscriptionFilters
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
13
|
from .ws import PolyNodeWS
|
|
14
14
|
|
|
15
|
+
PRE_ACK_REPLAY_LIMIT = 1000
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class SubscriptionBuilder:
|
|
17
19
|
"""Fluent builder for WebSocket subscriptions."""
|
|
@@ -79,6 +81,7 @@ class Subscription:
|
|
|
79
81
|
self._id: str | None = None
|
|
80
82
|
self._handlers: dict[str, set[Callable]] = {}
|
|
81
83
|
self._queue: asyncio.Queue[PolyNodeEvent | None] = asyncio.Queue()
|
|
84
|
+
self._pre_ack_replay: list[PolyNodeEvent] = []
|
|
82
85
|
self._closed = False
|
|
83
86
|
|
|
84
87
|
@property
|
|
@@ -92,6 +95,10 @@ class Subscription:
|
|
|
92
95
|
if type not in self._handlers:
|
|
93
96
|
self._handlers[type] = set()
|
|
94
97
|
self._handlers[type].add(handler)
|
|
98
|
+
for event in self._pre_ack_replay:
|
|
99
|
+
event_type = event.event_type # type: ignore[union-attr]
|
|
100
|
+
if type == "*" or type == event_type:
|
|
101
|
+
handler(event)
|
|
95
102
|
return self
|
|
96
103
|
|
|
97
104
|
def off(self, type: str, handler: Callable) -> Subscription:
|
|
@@ -114,6 +121,12 @@ class Subscription:
|
|
|
114
121
|
# Async iterator queue
|
|
115
122
|
self._queue.put_nowait(event)
|
|
116
123
|
|
|
124
|
+
def _emit_pre_ack(self, event: PolyNodeEvent) -> None:
|
|
125
|
+
self._pre_ack_replay.append(event)
|
|
126
|
+
if len(self._pre_ack_replay) > PRE_ACK_REPLAY_LIMIT:
|
|
127
|
+
self._pre_ack_replay = self._pre_ack_replay[-PRE_ACK_REPLAY_LIMIT:]
|
|
128
|
+
self._emit(event)
|
|
129
|
+
|
|
117
130
|
def unsubscribe(self) -> None:
|
|
118
131
|
self._closed = True
|
|
119
132
|
self._queue.put_nowait(None)
|
|
@@ -29,6 +29,32 @@ def _parse_event(data: dict) -> PolyNodeEvent | None:
|
|
|
29
29
|
return None
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _subscription_accepts_event(sub_type: SubscriptionType, event_type: str) -> bool:
|
|
33
|
+
if sub_type in ("global", "markets"):
|
|
34
|
+
return True
|
|
35
|
+
if sub_type == "settlements":
|
|
36
|
+
return event_type in {"settlement", "status_update"}
|
|
37
|
+
if sub_type in ("trades", "large_trades"):
|
|
38
|
+
return event_type == "trade"
|
|
39
|
+
if sub_type in ("prices", "chainlink"):
|
|
40
|
+
return event_type == "price_feed"
|
|
41
|
+
if sub_type == "blocks":
|
|
42
|
+
return event_type == "block"
|
|
43
|
+
if sub_type == "wallets":
|
|
44
|
+
return event_type in {
|
|
45
|
+
"settlement",
|
|
46
|
+
"trade",
|
|
47
|
+
"status_update",
|
|
48
|
+
"position_change",
|
|
49
|
+
"deposit",
|
|
50
|
+
"position_split",
|
|
51
|
+
"position_merge",
|
|
52
|
+
}
|
|
53
|
+
if sub_type == "oracle":
|
|
54
|
+
return event_type == "oracle"
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
32
58
|
class PolyNodeWS:
|
|
33
59
|
"""Async WebSocket client with auto-reconnect and subscription management."""
|
|
34
60
|
|
|
@@ -41,6 +67,7 @@ class PolyNodeWS:
|
|
|
41
67
|
self._max_reconnect = opts.max_reconnect_attempts
|
|
42
68
|
self._base_delay = opts.reconnect_base_delay
|
|
43
69
|
self._max_delay = opts.reconnect_max_delay
|
|
70
|
+
self._subscribe_timeout = opts.subscribe_timeout
|
|
44
71
|
|
|
45
72
|
self._socket: Any = None
|
|
46
73
|
self._connected = False
|
|
@@ -105,11 +132,11 @@ class PolyNodeWS:
|
|
|
105
132
|
await self._send(json.dumps(msg))
|
|
106
133
|
|
|
107
134
|
try:
|
|
108
|
-
return await asyncio.wait_for(fut, timeout=
|
|
135
|
+
return await asyncio.wait_for(fut, timeout=self._subscribe_timeout)
|
|
109
136
|
except asyncio.TimeoutError:
|
|
110
137
|
# Remove from pending
|
|
111
138
|
self._pending = [(s, f) for s, f in self._pending if f is not fut]
|
|
112
|
-
raise WsError("Subscribe timed out after
|
|
139
|
+
raise WsError(f"Subscribe timed out after {self._subscribe_timeout:g} seconds")
|
|
113
140
|
|
|
114
141
|
def _unsubscribe(self, subscription_id: str | None = None) -> None:
|
|
115
142
|
if subscription_id:
|
|
@@ -182,6 +209,8 @@ class PolyNodeWS:
|
|
|
182
209
|
return
|
|
183
210
|
finally:
|
|
184
211
|
self._connected = False
|
|
212
|
+
if self._pending:
|
|
213
|
+
self._reject_pending(WsError("WebSocket connection closed before subscription was acknowledged"))
|
|
185
214
|
reason = "closed" if self._intentional_close else "lost"
|
|
186
215
|
for h in self._on_disconnect:
|
|
187
216
|
h(reason)
|
|
@@ -250,6 +279,22 @@ class PolyNodeWS:
|
|
|
250
279
|
def _route_event(self, event: PolyNodeEvent) -> None:
|
|
251
280
|
for sub in self._subscriptions.values():
|
|
252
281
|
sub._emit(event)
|
|
282
|
+
self._route_pending_event(event)
|
|
283
|
+
|
|
284
|
+
def _route_pending_event(self, event: PolyNodeEvent) -> None:
|
|
285
|
+
if not self._pending:
|
|
286
|
+
return
|
|
287
|
+
event_type = event.event_type # type: ignore[union-attr]
|
|
288
|
+
for sub, _ in self._pending:
|
|
289
|
+
if _subscription_accepts_event(sub.type, event_type):
|
|
290
|
+
sub._emit_pre_ack(event)
|
|
291
|
+
|
|
292
|
+
def _reject_pending(self, err: WsError) -> None:
|
|
293
|
+
pending = self._pending
|
|
294
|
+
self._pending = []
|
|
295
|
+
for _, fut in pending:
|
|
296
|
+
if not fut.done():
|
|
297
|
+
fut.set_exception(err)
|
|
253
298
|
|
|
254
299
|
async def _schedule_reconnect(self) -> None:
|
|
255
300
|
if self._max_reconnect and self._reconnect_attempts >= self._max_reconnect:
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Shared test fixtures."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
|
|
3
5
|
import pytest
|
|
4
6
|
|
|
5
|
-
TEST_KEY = "
|
|
7
|
+
TEST_KEY = os.getenv("POLYNODE_API_KEY")
|
|
6
8
|
BASE_URL = "https://api.polynode.dev"
|
|
7
9
|
WS_URL = "wss://ws.polynode.dev/ws"
|
|
8
10
|
OB_URL = "wss://ob.polynode.dev/ws"
|
|
@@ -10,20 +12,22 @@ OB_URL = "wss://ob.polynode.dev/ws"
|
|
|
10
12
|
|
|
11
13
|
@pytest.fixture
|
|
12
14
|
def api_key():
|
|
15
|
+
if not TEST_KEY:
|
|
16
|
+
pytest.skip("POLYNODE_API_KEY is not set")
|
|
13
17
|
return TEST_KEY
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
@pytest.fixture
|
|
17
|
-
def client():
|
|
21
|
+
def client(api_key):
|
|
18
22
|
from polynode import PolyNode
|
|
19
|
-
pn = PolyNode(api_key=
|
|
23
|
+
pn = PolyNode(api_key=api_key)
|
|
20
24
|
yield pn
|
|
21
25
|
pn.close()
|
|
22
26
|
|
|
23
27
|
|
|
24
28
|
@pytest.fixture
|
|
25
|
-
async def async_client():
|
|
29
|
+
async def async_client(api_key):
|
|
26
30
|
from polynode import AsyncPolyNode
|
|
27
|
-
pn = AsyncPolyNode(api_key=
|
|
31
|
+
pn = AsyncPolyNode(api_key=api_key)
|
|
28
32
|
yield pn
|
|
29
33
|
await pn.close()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Tests for the sync REST client."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from polynode import PolyNode
|
|
7
|
+
from polynode.types.rest import StatusResponse, MarketsResponse, SettlementsResponse
|
|
8
|
+
|
|
9
|
+
TEST_KEY = os.getenv("POLYNODE_API_KEY")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def pn():
|
|
14
|
+
if not TEST_KEY:
|
|
15
|
+
pytest.skip("POLYNODE_API_KEY is not set")
|
|
16
|
+
client = PolyNode(api_key=TEST_KEY)
|
|
17
|
+
yield client
|
|
18
|
+
client.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_healthz(pn):
|
|
22
|
+
result = pn.healthz()
|
|
23
|
+
assert result == "ok" or "ok" in str(result).lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_status(pn):
|
|
27
|
+
result = pn.status()
|
|
28
|
+
assert isinstance(result, StatusResponse)
|
|
29
|
+
assert result.uptime_seconds > 0
|
|
30
|
+
assert result.state.market_count > 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_markets(pn):
|
|
34
|
+
result = pn.markets(count=3)
|
|
35
|
+
assert isinstance(result, MarketsResponse)
|
|
36
|
+
assert result.count > 0
|
|
37
|
+
assert len(result.markets) <= 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_recent_settlements(pn):
|
|
41
|
+
result = pn.recent_settlements(count=1)
|
|
42
|
+
assert isinstance(result, SettlementsResponse)
|
|
43
|
+
assert result.count >= 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_search(pn):
|
|
47
|
+
result = pn.search("election", limit=3)
|
|
48
|
+
assert result.query == "election"
|
|
49
|
+
assert result.count >= 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_search_online_builds_expected_path():
|
|
53
|
+
client = PolyNode(api_key=TEST_KEY or "pn_test_local")
|
|
54
|
+
calls = []
|
|
55
|
+
|
|
56
|
+
def fake_fetch(path, **kwargs):
|
|
57
|
+
calls.append((path, kwargs))
|
|
58
|
+
return {"ok": True}
|
|
59
|
+
|
|
60
|
+
client._fetch = fake_fetch # type: ignore[method-assign]
|
|
61
|
+
try:
|
|
62
|
+
assert client.search_online(
|
|
63
|
+
"Cavaliers Knicks injury news",
|
|
64
|
+
max_results=5,
|
|
65
|
+
engines=["brave", "bing"],
|
|
66
|
+
language="en-US",
|
|
67
|
+
time_range="day",
|
|
68
|
+
safe_search=0,
|
|
69
|
+
cache_ttl_seconds=300,
|
|
70
|
+
)["ok"]
|
|
71
|
+
finally:
|
|
72
|
+
client.close()
|
|
73
|
+
|
|
74
|
+
assert calls[0][0] == "/v2/search/online"
|
|
75
|
+
assert calls[0][1]["query"] == {
|
|
76
|
+
"q": "Cavaliers Knicks injury news",
|
|
77
|
+
"max_results": 5,
|
|
78
|
+
"engines": "brave,bing",
|
|
79
|
+
"language": "en-US",
|
|
80
|
+
"time_range": "day",
|
|
81
|
+
"safe_search": 0,
|
|
82
|
+
"cache_ttl_seconds": 300,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_sports_methods_build_expected_paths():
|
|
87
|
+
client = PolyNode(api_key=TEST_KEY or "pn_test_local")
|
|
88
|
+
calls = []
|
|
89
|
+
|
|
90
|
+
def fake_fetch(path, **kwargs):
|
|
91
|
+
calls.append((path, kwargs))
|
|
92
|
+
return {"ok": True}
|
|
93
|
+
|
|
94
|
+
client._fetch = fake_fetch # type: ignore[method-assign]
|
|
95
|
+
try:
|
|
96
|
+
assert client.sports_leagues()["ok"]
|
|
97
|
+
assert client.sports_games("nba", status="active", limit=2)["ok"]
|
|
98
|
+
assert client.sports_game("nba-nyk-cle-2026-05-25")["ok"]
|
|
99
|
+
assert client.sports_game_state(
|
|
100
|
+
"nba-nyk-cle-2026-05-25",
|
|
101
|
+
include_history=True,
|
|
102
|
+
price_limit_tokens=4,
|
|
103
|
+
history_limit_tokens=2,
|
|
104
|
+
interval="1d",
|
|
105
|
+
)["ok"]
|
|
106
|
+
assert client.sports_game_context(
|
|
107
|
+
"nba-nyk-cle-2026-05-25",
|
|
108
|
+
sources=["x", "online"],
|
|
109
|
+
query_set="injuries",
|
|
110
|
+
max_queries=2,
|
|
111
|
+
max_per_query=3,
|
|
112
|
+
include_state=True,
|
|
113
|
+
price_limit_tokens=4,
|
|
114
|
+
)["ok"]
|
|
115
|
+
assert client.sports_game_prices("nba-nyk-cle-2026-05-25", limit_tokens=4)["ok"]
|
|
116
|
+
assert client.sports_game_history("nba-nyk-cle-2026-05-25", interval="1d", limit_tokens=2)["ok"]
|
|
117
|
+
finally:
|
|
118
|
+
client.close()
|
|
119
|
+
|
|
120
|
+
assert calls[0][0] == "/v2/sports/leagues"
|
|
121
|
+
assert calls[1][0] == "/v2/sports/leagues/nba/games"
|
|
122
|
+
assert calls[1][1]["query"] == {"status": "active", "limit": 2, "offset": None, "sort": None, "direction": None}
|
|
123
|
+
assert calls[2][0] == "/v2/sports/games/nba-nyk-cle-2026-05-25"
|
|
124
|
+
assert calls[3][0] == "/v2/sports/games/nba-nyk-cle-2026-05-25/state"
|
|
125
|
+
assert calls[3][1]["query"]["include_history"] is True
|
|
126
|
+
assert calls[3][1]["query"]["price_limit_tokens"] == 4
|
|
127
|
+
assert calls[3][1]["query"]["history_limit_tokens"] == 2
|
|
128
|
+
assert calls[4][0] == "/v2/sports/games/nba-nyk-cle-2026-05-25/context"
|
|
129
|
+
assert calls[4][1]["query"] == {
|
|
130
|
+
"sources": "x,online",
|
|
131
|
+
"query_set": "injuries",
|
|
132
|
+
"max_queries": 2,
|
|
133
|
+
"max_per_query": 3,
|
|
134
|
+
"include_state": True,
|
|
135
|
+
"price_limit_tokens": 4,
|
|
136
|
+
}
|
|
137
|
+
assert calls[5][0] == "/v2/sports/games/nba-nyk-cle-2026-05-25/prices"
|
|
138
|
+
assert calls[5][1]["query"] == {"limit_tokens": 4}
|
|
139
|
+
assert calls[6][0] == "/v2/sports/games/nba-nyk-cle-2026-05-25/history"
|
|
140
|
+
assert calls[6][1]["query"]["interval"] == "1d"
|
|
141
|
+
assert calls[6][1]["query"]["limit_tokens"] == 2
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Tests for LocalOrderbook state management."""
|
|
2
2
|
|
|
3
3
|
from polynode.orderbook_state import LocalOrderbook
|
|
4
|
-
from polynode.types.orderbook import BookSnapshot, BookUpdate, OrderbookLevel
|
|
4
|
+
from polynode.types.orderbook import BookSnapshot, BookUpdate, OrderbookLevel, PriceChange, PriceChangeAsset
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def test_apply_snapshot():
|
|
@@ -57,3 +57,17 @@ def test_spread():
|
|
|
57
57
|
spread = ob.get_spread("t1")
|
|
58
58
|
assert spread is not None
|
|
59
59
|
assert abs(spread - 0.10) < 0.001
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_apply_price_change_tracks_last_price():
|
|
63
|
+
ob = LocalOrderbook()
|
|
64
|
+
change = PriceChange(
|
|
65
|
+
market="m1",
|
|
66
|
+
assets=[PriceChangeAsset(asset_id="t1", price="0.57", outcome="Yes")],
|
|
67
|
+
)
|
|
68
|
+
ob.apply_price_change(change)
|
|
69
|
+
|
|
70
|
+
last = ob.get_last_price("t1")
|
|
71
|
+
assert last is not None
|
|
72
|
+
assert last.price == "0.57"
|
|
73
|
+
assert last.outcome == "Yes"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Protocol-level orderbook WebSocket regression tests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from polynode.errors import WsError
|
|
9
|
+
from polynode.orderbook import OrderbookWS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeSocket:
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.sent: list[dict] = []
|
|
15
|
+
|
|
16
|
+
async def send(self, data: str) -> None:
|
|
17
|
+
self.sent.append(json.loads(data))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def snapshot(asset_id: str = "token-1") -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"type": "book_snapshot",
|
|
23
|
+
"asset_id": asset_id,
|
|
24
|
+
"market": "condition-1",
|
|
25
|
+
"bids": [{"price": "0.50", "size": "100"}],
|
|
26
|
+
"asks": [{"price": "0.55", "size": "200"}],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_ws(timeout: float = 0.2) -> tuple[OrderbookWS, FakeSocket]:
|
|
31
|
+
socket = FakeSocket()
|
|
32
|
+
ws = OrderbookWS("key", "wss://example.test/ws", subscribe_timeout=timeout)
|
|
33
|
+
ws._socket = socket
|
|
34
|
+
ws._connected = True
|
|
35
|
+
return ws, socket
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_orderbook_subscribe_waits_for_ack_without_dropping_snapshots():
|
|
39
|
+
async def scenario() -> None:
|
|
40
|
+
ws, socket = make_ws()
|
|
41
|
+
snapshots = []
|
|
42
|
+
subscribed = []
|
|
43
|
+
ws.on("snapshot", snapshots.append)
|
|
44
|
+
ws.on("subscribed", subscribed.append)
|
|
45
|
+
|
|
46
|
+
task = asyncio.create_task(ws.subscribe(["token-1"]))
|
|
47
|
+
await asyncio.sleep(0)
|
|
48
|
+
|
|
49
|
+
assert socket.sent == [{"action": "subscribe", "markets": ["token-1"]}]
|
|
50
|
+
assert not task.done()
|
|
51
|
+
|
|
52
|
+
ws._handle_message(json.dumps({"type": "snapshot_batch", "snapshots": [snapshot()]}))
|
|
53
|
+
assert len(snapshots) == 1
|
|
54
|
+
assert not task.done()
|
|
55
|
+
|
|
56
|
+
ws._handle_message(json.dumps({"type": "subscribed", "markets": 1}))
|
|
57
|
+
await asyncio.wait_for(task, timeout=0.1)
|
|
58
|
+
|
|
59
|
+
assert subscribed[0].markets == 1
|
|
60
|
+
assert snapshots[0].asset_id == "token-1"
|
|
61
|
+
|
|
62
|
+
asyncio.run(scenario())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_orderbook_subscribe_rejects_server_error_before_ack():
|
|
66
|
+
async def scenario() -> None:
|
|
67
|
+
ws, _ = make_ws()
|
|
68
|
+
errors = []
|
|
69
|
+
ws.on("error", errors.append)
|
|
70
|
+
|
|
71
|
+
task = asyncio.create_task(ws.subscribe(["token-1", "token-2"]))
|
|
72
|
+
await asyncio.sleep(0)
|
|
73
|
+
ws._handle_message(
|
|
74
|
+
json.dumps(
|
|
75
|
+
{
|
|
76
|
+
"error": "too_many_markets",
|
|
77
|
+
"message": "Too many markets",
|
|
78
|
+
"max": 1,
|
|
79
|
+
"requested": 2,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
with pytest.raises(WsError, match="Too many markets"):
|
|
85
|
+
await task
|
|
86
|
+
|
|
87
|
+
assert errors[0].error == "too_many_markets"
|
|
88
|
+
assert errors[0].max == 1
|
|
89
|
+
assert errors[0].requested == 2
|
|
90
|
+
|
|
91
|
+
asyncio.run(scenario())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_orderbook_subscribe_timeout_uses_configured_timeout():
|
|
95
|
+
async def scenario() -> None:
|
|
96
|
+
ws, socket = make_ws(timeout=0.01)
|
|
97
|
+
|
|
98
|
+
with pytest.raises(WsError, match="0.01 seconds"):
|
|
99
|
+
await ws.subscribe(["token-1"])
|
|
100
|
+
|
|
101
|
+
assert socket.sent == [{"action": "subscribe", "markets": ["token-1"]}]
|
|
102
|
+
|
|
103
|
+
asyncio.run(scenario())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_orderbook_partial_unsubscribe_is_additive():
|
|
107
|
+
async def scenario() -> None:
|
|
108
|
+
ws, socket = make_ws()
|
|
109
|
+
ws._token_ids = ["token-1", "token-2", "token-3"]
|
|
110
|
+
|
|
111
|
+
ws.unsubscribe(["token-2"])
|
|
112
|
+
await asyncio.sleep(0)
|
|
113
|
+
|
|
114
|
+
assert ws._token_ids == ["token-1", "token-3"]
|
|
115
|
+
assert socket.sent == [{"action": "unsubscribe", "markets": ["token-2"]}]
|
|
116
|
+
|
|
117
|
+
asyncio.run(scenario())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_orderbook_type_error_rejects_pending_subscribe():
|
|
121
|
+
async def scenario() -> None:
|
|
122
|
+
ws, _ = make_ws()
|
|
123
|
+
task = asyncio.create_task(ws.subscribe(["token-1"]))
|
|
124
|
+
await asyncio.sleep(0)
|
|
125
|
+
|
|
126
|
+
ws._handle_message(json.dumps({"type": "error", "code": "bad_request", "message": "Invalid token"}))
|
|
127
|
+
|
|
128
|
+
with pytest.raises(WsError, match="Invalid token"):
|
|
129
|
+
await task
|
|
130
|
+
|
|
131
|
+
asyncio.run(scenario())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Protocol-level WebSocket regression tests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from polynode.errors import WsError
|
|
9
|
+
from polynode.subscription import Subscription
|
|
10
|
+
from polynode.types.ws import SubscriptionFilters, WsOptions
|
|
11
|
+
from polynode.ws import PolyNodeWS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeSocket:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.sent: list[dict] = []
|
|
17
|
+
|
|
18
|
+
async def send(self, data: str) -> None:
|
|
19
|
+
self.sent.append(json.loads(data))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def settlement_payload(tx_hash: str = "0xabc") -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"event_type": "settlement",
|
|
25
|
+
"tx_hash": tx_hash,
|
|
26
|
+
"status": "pending",
|
|
27
|
+
"detected_at": 1234567890.0,
|
|
28
|
+
"taker_wallet": "0x123",
|
|
29
|
+
"taker_token": "0x456",
|
|
30
|
+
"taker_side": "BUY",
|
|
31
|
+
"taker_price": 0.55,
|
|
32
|
+
"taker_size": 100.0,
|
|
33
|
+
"trades": [],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_snapshot_before_subscribed_is_replayed_to_late_handler():
|
|
38
|
+
async def scenario() -> None:
|
|
39
|
+
ws = PolyNodeWS("key", "wss://example.test/ws")
|
|
40
|
+
sub = Subscription(ws, "settlements", SubscriptionFilters())
|
|
41
|
+
fut = asyncio.get_running_loop().create_future()
|
|
42
|
+
ws._pending.append((sub, fut))
|
|
43
|
+
|
|
44
|
+
ws._handle_message(json.dumps({"type": "snapshot", "events": [{"data": settlement_payload()}]}))
|
|
45
|
+
assert not fut.done()
|
|
46
|
+
|
|
47
|
+
ws._handle_message(json.dumps({"type": "subscribed", "subscription_id": "sub-1"}))
|
|
48
|
+
returned = await asyncio.wait_for(fut, timeout=0.1)
|
|
49
|
+
|
|
50
|
+
replayed = []
|
|
51
|
+
returned.on("settlement", replayed.append)
|
|
52
|
+
|
|
53
|
+
queued = await asyncio.wait_for(returned.__anext__(), timeout=0.1)
|
|
54
|
+
assert returned.id == "sub-1"
|
|
55
|
+
assert queued.tx_hash == "0xabc"
|
|
56
|
+
assert [event.tx_hash for event in replayed] == ["0xabc"]
|
|
57
|
+
|
|
58
|
+
asyncio.run(scenario())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_pre_ack_snapshot_only_routes_to_compatible_pending_subscription():
|
|
62
|
+
async def scenario() -> None:
|
|
63
|
+
ws = PolyNodeWS("key", "wss://example.test/ws")
|
|
64
|
+
sub = Subscription(ws, "trades", SubscriptionFilters())
|
|
65
|
+
fut = asyncio.get_running_loop().create_future()
|
|
66
|
+
ws._pending.append((sub, fut))
|
|
67
|
+
|
|
68
|
+
ws._handle_message(json.dumps({"type": "snapshot", "events": [{"data": settlement_payload()}]}))
|
|
69
|
+
ws._handle_message(json.dumps({"type": "subscribed", "subscription_id": "trade-sub"}))
|
|
70
|
+
await asyncio.wait_for(fut, timeout=0.1)
|
|
71
|
+
|
|
72
|
+
replayed = []
|
|
73
|
+
sub.on("*", replayed.append)
|
|
74
|
+
assert replayed == []
|
|
75
|
+
assert sub._queue.empty()
|
|
76
|
+
|
|
77
|
+
asyncio.run(scenario())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_subscribe_timeout_uses_configured_timeout():
|
|
81
|
+
async def scenario() -> None:
|
|
82
|
+
ws = PolyNodeWS("key", "wss://example.test/ws", WsOptions(subscribe_timeout=0.01))
|
|
83
|
+
ws._socket = FakeSocket()
|
|
84
|
+
ws._connected = True
|
|
85
|
+
|
|
86
|
+
with pytest.raises(WsError, match="0.01 seconds"):
|
|
87
|
+
await ws.subscribe("settlements").send()
|
|
88
|
+
|
|
89
|
+
assert ws._socket.sent == [{"action": "subscribe", "type": "settlements"}]
|
|
90
|
+
|
|
91
|
+
asyncio.run(scenario())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_disconnect_rejects_pending_subscriptions():
|
|
95
|
+
async def scenario() -> None:
|
|
96
|
+
ws = PolyNodeWS("key", "wss://example.test/ws")
|
|
97
|
+
sub = Subscription(ws, "settlements", SubscriptionFilters())
|
|
98
|
+
fut = asyncio.get_running_loop().create_future()
|
|
99
|
+
ws._pending.append((sub, fut))
|
|
100
|
+
|
|
101
|
+
ws._reject_pending(WsError("closed"))
|
|
102
|
+
|
|
103
|
+
with pytest.raises(WsError, match="closed"):
|
|
104
|
+
await fut
|
|
105
|
+
assert ws._pending == []
|
|
106
|
+
|
|
107
|
+
asyncio.run(scenario())
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.1"
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"""Tests for the sync REST client."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
from polynode import PolyNode
|
|
5
|
-
from polynode.types.rest import StatusResponse, MarketsResponse, SettlementsResponse
|
|
6
|
-
|
|
7
|
-
TEST_KEY = "pn_live_test_session_tracking_51eca107e9b347b589f5b0a04f98eb1d"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@pytest.fixture
|
|
11
|
-
def pn():
|
|
12
|
-
client = PolyNode(api_key=TEST_KEY)
|
|
13
|
-
yield client
|
|
14
|
-
client.close()
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_healthz(pn):
|
|
18
|
-
result = pn.healthz()
|
|
19
|
-
assert result == "ok" or "ok" in str(result).lower()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_status(pn):
|
|
23
|
-
result = pn.status()
|
|
24
|
-
assert isinstance(result, StatusResponse)
|
|
25
|
-
assert result.uptime_seconds > 0
|
|
26
|
-
assert result.state.market_count > 0
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_markets(pn):
|
|
30
|
-
result = pn.markets(count=3)
|
|
31
|
-
assert isinstance(result, MarketsResponse)
|
|
32
|
-
assert result.count > 0
|
|
33
|
-
assert len(result.markets) <= 3
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_recent_settlements(pn):
|
|
37
|
-
result = pn.recent_settlements(count=1)
|
|
38
|
-
assert isinstance(result, SettlementsResponse)
|
|
39
|
-
assert result.count >= 0
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_search(pn):
|
|
43
|
-
result = pn.search("election", limit=3)
|
|
44
|
-
assert result.query == "election"
|
|
45
|
-
assert result.count >= 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|