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.
Files changed (49) hide show
  1. {polynode-0.10.4 → polynode-0.10.6}/PKG-INFO +27 -1
  2. {polynode-0.10.4 → polynode-0.10.6}/README.md +26 -0
  3. polynode-0.10.6/polynode/_version.py +1 -0
  4. {polynode-0.10.4 → polynode-0.10.6}/polynode/client.py +300 -0
  5. {polynode-0.10.4 → polynode-0.10.6}/polynode/engine.py +1 -0
  6. {polynode-0.10.4 → polynode-0.10.6}/polynode/orderbook.py +55 -10
  7. {polynode-0.10.4 → polynode-0.10.6}/polynode/orderbook_state.py +8 -2
  8. {polynode-0.10.4 → polynode-0.10.6}/polynode/subscription.py +13 -0
  9. {polynode-0.10.4 → polynode-0.10.6}/polynode/types/ws.py +1 -0
  10. {polynode-0.10.4 → polynode-0.10.6}/polynode/ws.py +47 -2
  11. {polynode-0.10.4 → polynode-0.10.6}/pyproject.toml +1 -1
  12. {polynode-0.10.4 → polynode-0.10.6}/tests/conftest.py +9 -5
  13. polynode-0.10.6/tests/test_client.py +141 -0
  14. {polynode-0.10.4 → polynode-0.10.6}/tests/test_orderbook.py +15 -1
  15. polynode-0.10.6/tests/test_orderbook_protocol.py +131 -0
  16. polynode-0.10.6/tests/test_ws_protocol.py +107 -0
  17. polynode-0.10.4/polynode/_version.py +0 -1
  18. polynode-0.10.4/tests/test_client.py +0 -45
  19. {polynode-0.10.4 → polynode-0.10.6}/.gitignore +0 -0
  20. {polynode-0.10.4 → polynode-0.10.6}/polynode/__init__.py +0 -0
  21. {polynode-0.10.4 → polynode-0.10.6}/polynode/cache/__init__.py +0 -0
  22. {polynode-0.10.4 → polynode-0.10.6}/polynode/errors.py +0 -0
  23. {polynode-0.10.4 → polynode-0.10.6}/polynode/redemption_watcher.py +0 -0
  24. {polynode-0.10.4 → polynode-0.10.6}/polynode/short_form.py +0 -0
  25. {polynode-0.10.4 → polynode-0.10.6}/polynode/testing.py +0 -0
  26. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/V2_ORDER_FLOW.md +0 -0
  27. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/__init__.py +0 -0
  28. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/clob_api.py +0 -0
  29. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/constants.py +0 -0
  30. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/cosigner.py +0 -0
  31. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/eip712.py +0 -0
  32. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/escrow.py +0 -0
  33. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/onboarding.py +0 -0
  34. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/position_management.py +0 -0
  35. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/privy.py +0 -0
  36. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/relayer.py +0 -0
  37. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/signer.py +0 -0
  38. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/sqlite_backend.py +0 -0
  39. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/trader.py +0 -0
  40. {polynode-0.10.4 → polynode-0.10.6}/polynode/trading/types.py +0 -0
  41. {polynode-0.10.4 → polynode-0.10.6}/polynode/types/__init__.py +0 -0
  42. {polynode-0.10.4 → polynode-0.10.6}/polynode/types/enums.py +0 -0
  43. {polynode-0.10.4 → polynode-0.10.6}/polynode/types/events.py +0 -0
  44. {polynode-0.10.4 → polynode-0.10.6}/polynode/types/orderbook.py +0 -0
  45. {polynode-0.10.4 → polynode-0.10.6}/polynode/types/rest.py +0 -0
  46. {polynode-0.10.4 → polynode-0.10.6}/polynode/types/short_form.py +0 -0
  47. {polynode-0.10.4 → polynode-0.10.6}/tests/__init__.py +0 -0
  48. {polynode-0.10.4 → polynode-0.10.6}/tests/test_trading.py +0 -0
  49. {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.4
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(
@@ -120,6 +120,7 @@ class OrderbookEngine:
120
120
  self._route_update(update)
121
121
 
122
122
  def _on_price(self, change: PriceChange) -> None:
123
+ self._state.apply_price_change(change)
123
124
  for h in self._handlers["price"]:
124
125
  h(change)
125
126
  for view in self._views:
@@ -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._send(json.dumps({"action": "subscribe", "markets": token_ids}))
98
-
99
- def unsubscribe(self) -> None:
100
- self._token_ids = []
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({"action": "unsubscribe"})))
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.model_validate(msg)
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._send(json.dumps({"action": "subscribe", "markets": self._token_ids}))
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
- pass
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)
@@ -36,3 +36,4 @@ class WsOptions:
36
36
  max_reconnect_attempts: int = 0 # 0 = infinite
37
37
  reconnect_base_delay: float = 1.0
38
38
  reconnect_max_delay: float = 30.0
39
+ subscribe_timeout: float = 30.0
@@ -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=10.0)
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 10 seconds")
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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polynode"
7
- version = "0.10.4"
7
+ version = "0.10.6"
8
8
  description = "Python SDK for the PolyNode real-time prediction market data platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,8 +1,10 @@
1
1
  """Shared test fixtures."""
2
2
 
3
+ import os
4
+
3
5
  import pytest
4
6
 
5
- TEST_KEY = "pn_live_test_session_tracking_51eca107e9b347b589f5b0a04f98eb1d"
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=TEST_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=TEST_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