polynode 0.5.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polynode/__init__.py +41 -0
- polynode/_version.py +1 -0
- polynode/cache/__init__.py +11 -0
- polynode/client.py +635 -0
- polynode/engine.py +201 -0
- polynode/errors.py +35 -0
- polynode/orderbook.py +243 -0
- polynode/orderbook_state.py +77 -0
- polynode/redemption_watcher.py +339 -0
- polynode/short_form.py +321 -0
- polynode/subscription.py +137 -0
- polynode/testing.py +83 -0
- polynode/trading/__init__.py +19 -0
- polynode/trading/clob_api.py +158 -0
- polynode/trading/constants.py +31 -0
- polynode/trading/cosigner.py +86 -0
- polynode/trading/eip712.py +163 -0
- polynode/trading/onboarding.py +242 -0
- polynode/trading/signer.py +91 -0
- polynode/trading/sqlite_backend.py +208 -0
- polynode/trading/trader.py +506 -0
- polynode/trading/types.py +191 -0
- polynode/types/__init__.py +8 -0
- polynode/types/enums.py +51 -0
- polynode/types/events.py +270 -0
- polynode/types/orderbook.py +66 -0
- polynode/types/rest.py +376 -0
- polynode/types/short_form.py +35 -0
- polynode/types/ws.py +38 -0
- polynode/ws.py +278 -0
- polynode-0.5.5.dist-info/METADATA +133 -0
- polynode-0.5.5.dist-info/RECORD +33 -0
- polynode-0.5.5.dist-info/WHEEL +4 -0
polynode/types/rest.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""REST API response models for the PolyNode SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from .enums import EventStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── System ──
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StateSummary(BaseModel):
|
|
16
|
+
market_count: int
|
|
17
|
+
wallet_count: int
|
|
18
|
+
metadata_count: int
|
|
19
|
+
events_buffered: int
|
|
20
|
+
events_processed: int
|
|
21
|
+
latest_block: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StatusResponse(BaseModel):
|
|
25
|
+
node_connected: bool
|
|
26
|
+
uptime_seconds: float
|
|
27
|
+
stream_length: int
|
|
28
|
+
events_stream_length: int
|
|
29
|
+
pending_txs: int
|
|
30
|
+
ws_subscribers: int
|
|
31
|
+
firehose_connections: int
|
|
32
|
+
redis_memory: str
|
|
33
|
+
state: StateSummary
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApiKeyResponse(BaseModel):
|
|
37
|
+
api_key: str
|
|
38
|
+
name: str
|
|
39
|
+
rate_limit_per_minute: int
|
|
40
|
+
message: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Markets ──
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MarketSummary(BaseModel):
|
|
47
|
+
token_id: str | None = None
|
|
48
|
+
last_price: float | None = None
|
|
49
|
+
volume_24h: float = 0
|
|
50
|
+
trade_count_24h: int = 0
|
|
51
|
+
last_trade_at: float | None = None
|
|
52
|
+
question: str | None = None
|
|
53
|
+
slug: str | None = None
|
|
54
|
+
outcomes: list[str] | None = None
|
|
55
|
+
condition_id: str | None = None
|
|
56
|
+
end_date: str | None = None
|
|
57
|
+
category: str | None = None
|
|
58
|
+
market_type: str | None = None
|
|
59
|
+
image: str | None = None
|
|
60
|
+
icon: str | None = None
|
|
61
|
+
neg_risk: bool | None = None
|
|
62
|
+
created_at: str | None = None
|
|
63
|
+
active: bool | None = None
|
|
64
|
+
closed: bool | None = None
|
|
65
|
+
last_status: EventStatus | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MarketsResponse(BaseModel):
|
|
69
|
+
count: int
|
|
70
|
+
total: int
|
|
71
|
+
markets: list[MarketSummary]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MarketsListResponse(BaseModel):
|
|
75
|
+
count: int
|
|
76
|
+
total: int
|
|
77
|
+
cursor: int | None = None
|
|
78
|
+
markets: list[MarketSummary]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SearchResponse(BaseModel):
|
|
82
|
+
query: str
|
|
83
|
+
count: int
|
|
84
|
+
results: list[MarketSummary]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Pricing ──
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Candle(BaseModel):
|
|
91
|
+
timestamp: float
|
|
92
|
+
open: float
|
|
93
|
+
high: float
|
|
94
|
+
low: float
|
|
95
|
+
close: float
|
|
96
|
+
volume: float
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CandlesResponse(BaseModel):
|
|
100
|
+
candles: list[Candle]
|
|
101
|
+
count: int
|
|
102
|
+
token_id: str
|
|
103
|
+
question: str | None = None
|
|
104
|
+
slug: str | None = None
|
|
105
|
+
resolution: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── Settlements ──
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Settlement(BaseModel):
|
|
112
|
+
tx_hash: str
|
|
113
|
+
taker_side: str
|
|
114
|
+
taker_size: str
|
|
115
|
+
taker_price: str
|
|
116
|
+
taker_wallet: str
|
|
117
|
+
taker_token: str
|
|
118
|
+
timestamp: str
|
|
119
|
+
detected_at: str
|
|
120
|
+
status: str
|
|
121
|
+
outcome: str
|
|
122
|
+
market_title: str
|
|
123
|
+
market_slug: str
|
|
124
|
+
market_image: str
|
|
125
|
+
event_title: str
|
|
126
|
+
block_number: str
|
|
127
|
+
id: str
|
|
128
|
+
trades_json: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SettlementsResponse(BaseModel):
|
|
132
|
+
count: int
|
|
133
|
+
settlements: list[Settlement]
|
|
134
|
+
wallet: str | None = None
|
|
135
|
+
token_id: str | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Wallets ──
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class WalletActivity(BaseModel):
|
|
142
|
+
last_active: float
|
|
143
|
+
trade_count: int
|
|
144
|
+
trade_volume_usd: float
|
|
145
|
+
transfer_count: int
|
|
146
|
+
deposit_count: int
|
|
147
|
+
deposit_volume_usd: float
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class WalletResponse(BaseModel):
|
|
151
|
+
wallet: str
|
|
152
|
+
activity: WalletActivity
|
|
153
|
+
|
|
154
|
+
model_config = {"extra": "allow"}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Orderbook (REST) ──
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class OrderbookLevel(BaseModel):
|
|
161
|
+
price: str
|
|
162
|
+
size: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class OrderbookResponse(BaseModel):
|
|
166
|
+
bids: list[OrderbookLevel]
|
|
167
|
+
asks: list[OrderbookLevel]
|
|
168
|
+
asset_id: str
|
|
169
|
+
market: str
|
|
170
|
+
hash: str
|
|
171
|
+
last_trade_price: str
|
|
172
|
+
min_order_size: str
|
|
173
|
+
tick_size: str
|
|
174
|
+
neg_risk: bool
|
|
175
|
+
timestamp: str
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class MidpointResponse(BaseModel):
|
|
179
|
+
mid: str
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SpreadResponse(BaseModel):
|
|
183
|
+
spread: str
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Enriched Data ──
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class LeaderboardTrader(BaseModel):
|
|
190
|
+
rank: int
|
|
191
|
+
wallet: str
|
|
192
|
+
name: str
|
|
193
|
+
pnl: float
|
|
194
|
+
volume: float
|
|
195
|
+
profileImage: str | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class LeaderboardResponse(BaseModel):
|
|
199
|
+
traders: list[LeaderboardTrader]
|
|
200
|
+
period: str
|
|
201
|
+
sort: str
|
|
202
|
+
count: int
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TrendingCarouselItem(BaseModel):
|
|
206
|
+
id: str
|
|
207
|
+
slug: str
|
|
208
|
+
title: str
|
|
209
|
+
image: str | None = None
|
|
210
|
+
volume: float
|
|
211
|
+
volume24hr: float | None = None
|
|
212
|
+
liquidity: float | None = None
|
|
213
|
+
startDate: str
|
|
214
|
+
active: bool
|
|
215
|
+
closed: bool
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TrendingBreakingItem(BaseModel):
|
|
219
|
+
id: str
|
|
220
|
+
slug: str
|
|
221
|
+
question: str
|
|
222
|
+
image: str | None = None
|
|
223
|
+
outcomePrices: list[str]
|
|
224
|
+
oneDayPriceChange: float
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TrendingHotTopic(BaseModel):
|
|
228
|
+
title: str
|
|
229
|
+
volume: float
|
|
230
|
+
url: str
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TrendingResponse(BaseModel):
|
|
234
|
+
carousel: list[TrendingCarouselItem]
|
|
235
|
+
breaking: list[TrendingBreakingItem]
|
|
236
|
+
hotTopics: list[TrendingHotTopic]
|
|
237
|
+
featured: list[TrendingCarouselItem]
|
|
238
|
+
movers: list[TrendingBreakingItem]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ActivityTrade(BaseModel):
|
|
242
|
+
wallet: str
|
|
243
|
+
side: str
|
|
244
|
+
tokenId: str
|
|
245
|
+
conditionId: str
|
|
246
|
+
size: float
|
|
247
|
+
price: float
|
|
248
|
+
timestamp: float
|
|
249
|
+
title: str
|
|
250
|
+
slug: str
|
|
251
|
+
eventSlug: str
|
|
252
|
+
outcome: str
|
|
253
|
+
name: str
|
|
254
|
+
txHash: str
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class ActivityResponse(BaseModel):
|
|
258
|
+
trades: list[ActivityTrade]
|
|
259
|
+
count: int
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class MoverMarket(BaseModel):
|
|
263
|
+
id: str
|
|
264
|
+
slug: str
|
|
265
|
+
question: str
|
|
266
|
+
image: str | None = None
|
|
267
|
+
outcomePrices: list[str]
|
|
268
|
+
oneDayPriceChange: float
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class MoversResponse(BaseModel):
|
|
272
|
+
markets: list[MoverMarket]
|
|
273
|
+
count: int
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class TraderProfile(BaseModel):
|
|
277
|
+
wallet: str
|
|
278
|
+
name: str
|
|
279
|
+
pseudonym: str
|
|
280
|
+
profileSlug: str
|
|
281
|
+
joinDate: str
|
|
282
|
+
trades: int
|
|
283
|
+
marketsTraded: int
|
|
284
|
+
largestWin: float
|
|
285
|
+
views: int
|
|
286
|
+
totalVolume: float
|
|
287
|
+
totalPnl: float
|
|
288
|
+
realizedPnl: float
|
|
289
|
+
unrealizedPnl: float
|
|
290
|
+
positionValue: float
|
|
291
|
+
profileImage: str | None = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class PnlPoint(BaseModel):
|
|
295
|
+
timestamp: float
|
|
296
|
+
pnl: float
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class TraderPnlResponse(BaseModel):
|
|
300
|
+
wallet: str
|
|
301
|
+
period: str
|
|
302
|
+
series: list[PnlPoint]
|
|
303
|
+
count: int
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class EventMarket(BaseModel):
|
|
307
|
+
question: str
|
|
308
|
+
conditionId: str
|
|
309
|
+
outcomes: list[str]
|
|
310
|
+
outcomePrices: list[str]
|
|
311
|
+
volume: float
|
|
312
|
+
liquidity: float
|
|
313
|
+
active: bool
|
|
314
|
+
closed: bool
|
|
315
|
+
groupItemTitle: str | None = None
|
|
316
|
+
tokenId: str | None = None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class EventSearchMarket(BaseModel):
|
|
320
|
+
question: str
|
|
321
|
+
groupItemTitle: str
|
|
322
|
+
conditionId: str
|
|
323
|
+
tokenId: str | None = None
|
|
324
|
+
price: float | None = None
|
|
325
|
+
active: bool
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class EventSearchResult(BaseModel):
|
|
329
|
+
id: str
|
|
330
|
+
slug: str
|
|
331
|
+
title: str
|
|
332
|
+
image: str | None = None
|
|
333
|
+
active: bool
|
|
334
|
+
markets: list[EventSearchMarket]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class EventSearchResponse(BaseModel):
|
|
338
|
+
query: str
|
|
339
|
+
events: list[EventSearchResult]
|
|
340
|
+
count: int
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class EventDetailResponse(BaseModel):
|
|
344
|
+
id: int
|
|
345
|
+
slug: str
|
|
346
|
+
title: str
|
|
347
|
+
description: str
|
|
348
|
+
image: str | None = None
|
|
349
|
+
active: bool
|
|
350
|
+
closed: bool
|
|
351
|
+
volume: float
|
|
352
|
+
volume24hr: float
|
|
353
|
+
liquidity: float
|
|
354
|
+
startDate: str
|
|
355
|
+
endDate: str
|
|
356
|
+
markets: list[EventMarket]
|
|
357
|
+
series: dict[str, Any] | None = None
|
|
358
|
+
similarMarkets: int
|
|
359
|
+
annotations: int
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class MarketsByCategoryResponse(BaseModel):
|
|
363
|
+
category: str
|
|
364
|
+
counts: dict[str, int]
|
|
365
|
+
events: list[dict[str, Any]]
|
|
366
|
+
totalResults: int
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ── RPC ──
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class JsonRpcResponse(BaseModel):
|
|
373
|
+
jsonrpc: str
|
|
374
|
+
id: int | str | None = None
|
|
375
|
+
result: Any | None = None
|
|
376
|
+
error: dict[str, Any] | None = None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Short-form market types for the PolyNode SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
ShortFormInterval = Literal["5m", "15m", "1h"]
|
|
10
|
+
ShortFormCoin = Literal["btc", "eth", "sol", "xrp", "doge", "hype", "bnb"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ShortFormMarket(BaseModel):
|
|
14
|
+
coin: ShortFormCoin
|
|
15
|
+
slug: str
|
|
16
|
+
title: str
|
|
17
|
+
condition_id: str
|
|
18
|
+
window_start: int
|
|
19
|
+
window_end: int
|
|
20
|
+
outcomes: list[str]
|
|
21
|
+
outcome_prices: list[float]
|
|
22
|
+
clob_token_ids: list[str]
|
|
23
|
+
up_odds: float
|
|
24
|
+
down_odds: float
|
|
25
|
+
liquidity: float
|
|
26
|
+
volume_24h: float
|
|
27
|
+
price_to_beat: float | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RotationEvent(BaseModel):
|
|
31
|
+
interval: ShortFormInterval
|
|
32
|
+
markets: list[ShortFormMarket]
|
|
33
|
+
window_start: int
|
|
34
|
+
window_end: int
|
|
35
|
+
time_remaining: int
|
polynode/types/ws.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""WebSocket protocol types for the PolyNode SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .enums import EventType, SubscriptionType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SubscriptionFilters:
|
|
12
|
+
wallets: list[str] | None = None
|
|
13
|
+
tokens: list[str] | None = None
|
|
14
|
+
slugs: list[str] | None = None
|
|
15
|
+
condition_ids: list[str] | None = None
|
|
16
|
+
side: str | None = None
|
|
17
|
+
status: str | None = None
|
|
18
|
+
min_size: float | None = None
|
|
19
|
+
max_size: float | None = None
|
|
20
|
+
event_types: list[EventType] | None = None
|
|
21
|
+
snapshot_count: int | None = None
|
|
22
|
+
feeds: list[str] | None = None
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
d = {}
|
|
26
|
+
for k, v in self.__dict__.items():
|
|
27
|
+
if v is not None:
|
|
28
|
+
d[k] = v
|
|
29
|
+
return d
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class WsOptions:
|
|
34
|
+
compress: bool = True
|
|
35
|
+
auto_reconnect: bool = True
|
|
36
|
+
max_reconnect_attempts: int = 0 # 0 = infinite
|
|
37
|
+
reconnect_base_delay: float = 1.0
|
|
38
|
+
reconnect_max_delay: float = 30.0
|
polynode/ws.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Async WebSocket client for PolyNode settlement and market event streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import random
|
|
9
|
+
import zlib
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
import websockets
|
|
13
|
+
import websockets.asyncio.client
|
|
14
|
+
|
|
15
|
+
from .errors import WsError
|
|
16
|
+
from .subscription import Subscription, SubscriptionBuilder
|
|
17
|
+
from .types.enums import SubscriptionType
|
|
18
|
+
from .types.events import PolyNodeEvent
|
|
19
|
+
from .types.ws import SubscriptionFilters, WsOptions
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_event(data: dict) -> PolyNodeEvent | None:
|
|
23
|
+
"""Parse a raw dict into a typed PolyNodeEvent using Pydantic."""
|
|
24
|
+
from pydantic import TypeAdapter
|
|
25
|
+
_adapter = TypeAdapter(PolyNodeEvent)
|
|
26
|
+
try:
|
|
27
|
+
return _adapter.validate_python(data)
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PolyNodeWS:
|
|
33
|
+
"""Async WebSocket client with auto-reconnect and subscription management."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, api_key: str, ws_url: str, options: WsOptions | None = None) -> None:
|
|
36
|
+
self._api_key = api_key
|
|
37
|
+
self._ws_url = ws_url
|
|
38
|
+
opts = options or WsOptions()
|
|
39
|
+
self._compress = opts.compress
|
|
40
|
+
self._auto_reconnect = opts.auto_reconnect
|
|
41
|
+
self._max_reconnect = opts.max_reconnect_attempts
|
|
42
|
+
self._base_delay = opts.reconnect_base_delay
|
|
43
|
+
self._max_delay = opts.reconnect_max_delay
|
|
44
|
+
|
|
45
|
+
self._socket: Any = None
|
|
46
|
+
self._connected = False
|
|
47
|
+
self._intentional_close = False
|
|
48
|
+
self._reconnect_attempts = 0
|
|
49
|
+
self._sub_counter = 0
|
|
50
|
+
self._subscriber_id: str | None = None
|
|
51
|
+
|
|
52
|
+
self._subscriptions: dict[str, Subscription] = {}
|
|
53
|
+
self._pending: list[tuple[Subscription, asyncio.Future]] = []
|
|
54
|
+
|
|
55
|
+
self._recv_task: asyncio.Task | None = None
|
|
56
|
+
|
|
57
|
+
# System handlers
|
|
58
|
+
self._on_connect: list[Callable] = []
|
|
59
|
+
self._on_disconnect: list[Callable] = []
|
|
60
|
+
self._on_reconnect: list[Callable] = []
|
|
61
|
+
self._on_error: list[Callable] = []
|
|
62
|
+
self._on_heartbeat: list[Callable] = []
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_connected(self) -> bool:
|
|
66
|
+
return self._connected
|
|
67
|
+
|
|
68
|
+
def on_connect(self, handler: Callable) -> PolyNodeWS:
|
|
69
|
+
self._on_connect.append(handler)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def on_disconnect(self, handler: Callable) -> PolyNodeWS:
|
|
73
|
+
self._on_disconnect.append(handler)
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def on_reconnect(self, handler: Callable) -> PolyNodeWS:
|
|
77
|
+
self._on_reconnect.append(handler)
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def on_error(self, handler: Callable) -> PolyNodeWS:
|
|
81
|
+
self._on_error.append(handler)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def on_heartbeat(self, handler: Callable) -> PolyNodeWS:
|
|
85
|
+
self._on_heartbeat.append(handler)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def subscribe(self, type: SubscriptionType) -> SubscriptionBuilder:
|
|
89
|
+
return SubscriptionBuilder(self, type)
|
|
90
|
+
|
|
91
|
+
async def _subscribe(self, type: SubscriptionType, filters: SubscriptionFilters) -> Subscription:
|
|
92
|
+
await self._ensure_connected()
|
|
93
|
+
|
|
94
|
+
sub = Subscription(self, type, filters)
|
|
95
|
+
fut: asyncio.Future[Subscription] = asyncio.get_event_loop().create_future()
|
|
96
|
+
self._pending.append((sub, fut))
|
|
97
|
+
|
|
98
|
+
msg: dict[str, Any] = {"action": "subscribe"}
|
|
99
|
+
if type:
|
|
100
|
+
msg["type"] = type
|
|
101
|
+
clean_filters = filters.to_dict()
|
|
102
|
+
if clean_filters:
|
|
103
|
+
msg["filters"] = clean_filters
|
|
104
|
+
|
|
105
|
+
await self._send(json.dumps(msg))
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
return await asyncio.wait_for(fut, timeout=10.0)
|
|
109
|
+
except asyncio.TimeoutError:
|
|
110
|
+
# Remove from pending
|
|
111
|
+
self._pending = [(s, f) for s, f in self._pending if f is not fut]
|
|
112
|
+
raise WsError("Subscribe timed out after 10 seconds")
|
|
113
|
+
|
|
114
|
+
def _unsubscribe(self, subscription_id: str | None = None) -> None:
|
|
115
|
+
if subscription_id:
|
|
116
|
+
self._subscriptions.pop(subscription_id, None)
|
|
117
|
+
if self._connected and self._socket:
|
|
118
|
+
asyncio.ensure_future(
|
|
119
|
+
self._send(json.dumps({"action": "unsubscribe", "subscription_id": subscription_id}))
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
self._subscriptions.clear()
|
|
123
|
+
if self._connected and self._socket:
|
|
124
|
+
asyncio.ensure_future(self._send(json.dumps({"action": "unsubscribe"})))
|
|
125
|
+
|
|
126
|
+
def unsubscribe_all(self) -> None:
|
|
127
|
+
self._unsubscribe()
|
|
128
|
+
|
|
129
|
+
async def connect(self) -> None:
|
|
130
|
+
if self._connected and self._socket:
|
|
131
|
+
return
|
|
132
|
+
await self._connect()
|
|
133
|
+
|
|
134
|
+
def disconnect(self) -> None:
|
|
135
|
+
self._intentional_close = True
|
|
136
|
+
if self._recv_task and not self._recv_task.done():
|
|
137
|
+
self._recv_task.cancel()
|
|
138
|
+
if self._socket:
|
|
139
|
+
asyncio.ensure_future(self._socket.close())
|
|
140
|
+
self._socket = None
|
|
141
|
+
self._connected = False
|
|
142
|
+
|
|
143
|
+
# ── Private ──
|
|
144
|
+
|
|
145
|
+
async def _ensure_connected(self) -> None:
|
|
146
|
+
if not self._connected or not self._socket:
|
|
147
|
+
await self._connect()
|
|
148
|
+
|
|
149
|
+
async def _connect(self) -> None:
|
|
150
|
+
url = f"{self._ws_url}?key={self._api_key}"
|
|
151
|
+
if self._compress:
|
|
152
|
+
url += "&compress=zlib"
|
|
153
|
+
|
|
154
|
+
self._socket = await websockets.asyncio.client.connect(url, max_size=10 * 1024 * 1024)
|
|
155
|
+
self._connected = True
|
|
156
|
+
self._reconnect_attempts = 0
|
|
157
|
+
self._intentional_close = False
|
|
158
|
+
|
|
159
|
+
for h in self._on_connect:
|
|
160
|
+
h()
|
|
161
|
+
|
|
162
|
+
self._recv_task = asyncio.ensure_future(self._recv_loop())
|
|
163
|
+
|
|
164
|
+
async def _recv_loop(self) -> None:
|
|
165
|
+
try:
|
|
166
|
+
async for raw in self._socket:
|
|
167
|
+
try:
|
|
168
|
+
if isinstance(raw, bytes) and self._compress:
|
|
169
|
+
text = zlib.decompress(raw, -zlib.MAX_WBITS).decode("utf-8")
|
|
170
|
+
elif isinstance(raw, bytes):
|
|
171
|
+
text = raw.decode("utf-8")
|
|
172
|
+
else:
|
|
173
|
+
text = raw
|
|
174
|
+
self._handle_message(text)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
err = WsError(f"Failed to process message: {e}")
|
|
177
|
+
for h in self._on_error:
|
|
178
|
+
h(err)
|
|
179
|
+
except websockets.exceptions.ConnectionClosed:
|
|
180
|
+
pass
|
|
181
|
+
except asyncio.CancelledError:
|
|
182
|
+
return
|
|
183
|
+
finally:
|
|
184
|
+
self._connected = False
|
|
185
|
+
reason = "closed" if self._intentional_close else "lost"
|
|
186
|
+
for h in self._on_disconnect:
|
|
187
|
+
h(reason)
|
|
188
|
+
if not self._intentional_close and self._auto_reconnect:
|
|
189
|
+
asyncio.ensure_future(self._schedule_reconnect())
|
|
190
|
+
|
|
191
|
+
async def _send(self, data: str) -> None:
|
|
192
|
+
if self._socket and self._connected:
|
|
193
|
+
await self._socket.send(data)
|
|
194
|
+
|
|
195
|
+
def _handle_message(self, text: str) -> None:
|
|
196
|
+
msg = json.loads(text)
|
|
197
|
+
msg_type = msg.get("type")
|
|
198
|
+
|
|
199
|
+
if msg_type == "subscribed":
|
|
200
|
+
sub_id = msg.get("subscription_id", "")
|
|
201
|
+
self._subscriber_id = msg.get("subscriber_id") or self._subscriber_id
|
|
202
|
+
if self._pending:
|
|
203
|
+
sub, fut = self._pending.pop(0)
|
|
204
|
+
sub._set_id(sub_id)
|
|
205
|
+
self._subscriptions[sub_id] = sub
|
|
206
|
+
if not fut.done():
|
|
207
|
+
fut.set_result(sub)
|
|
208
|
+
|
|
209
|
+
elif msg_type == "heartbeat":
|
|
210
|
+
ts = msg.get("ts", 0)
|
|
211
|
+
for h in self._on_heartbeat:
|
|
212
|
+
h(ts)
|
|
213
|
+
|
|
214
|
+
elif msg_type == "pong":
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
elif msg_type == "error":
|
|
218
|
+
err = WsError(msg.get("message", "Unknown error"), msg.get("code"))
|
|
219
|
+
for h in self._on_error:
|
|
220
|
+
h(err)
|
|
221
|
+
if self._pending:
|
|
222
|
+
sub, fut = self._pending.pop(0)
|
|
223
|
+
if not fut.done():
|
|
224
|
+
fut.set_exception(err)
|
|
225
|
+
|
|
226
|
+
elif msg_type == "snapshot":
|
|
227
|
+
events = msg.get("events", [])
|
|
228
|
+
for wrapper in events:
|
|
229
|
+
data = wrapper.get("data")
|
|
230
|
+
if data:
|
|
231
|
+
event = _parse_event(data)
|
|
232
|
+
if event:
|
|
233
|
+
self._route_event(event)
|
|
234
|
+
|
|
235
|
+
elif msg_type == "unsubscribed":
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
else:
|
|
239
|
+
data = msg.get("data")
|
|
240
|
+
if data and data.get("event_type"):
|
|
241
|
+
event = _parse_event(data)
|
|
242
|
+
if event:
|
|
243
|
+
self._route_event(event)
|
|
244
|
+
|
|
245
|
+
def _route_event(self, event: PolyNodeEvent) -> None:
|
|
246
|
+
for sub in self._subscriptions.values():
|
|
247
|
+
sub._emit(event)
|
|
248
|
+
|
|
249
|
+
async def _schedule_reconnect(self) -> None:
|
|
250
|
+
if self._max_reconnect and self._reconnect_attempts >= self._max_reconnect:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
self._reconnect_attempts += 1
|
|
254
|
+
delay = min(self._base_delay * math.pow(2, self._reconnect_attempts - 1), self._max_delay)
|
|
255
|
+
jitter = delay * (0.5 + random.random() * 0.5)
|
|
256
|
+
|
|
257
|
+
await asyncio.sleep(jitter)
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
await self._connect()
|
|
261
|
+
for h in self._on_reconnect:
|
|
262
|
+
h(self._reconnect_attempts)
|
|
263
|
+
await self._resubscribe_all()
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
async def _resubscribe_all(self) -> None:
|
|
268
|
+
existing = list(self._subscriptions.items())
|
|
269
|
+
self._subscriptions.clear()
|
|
270
|
+
|
|
271
|
+
for _, sub in existing:
|
|
272
|
+
try:
|
|
273
|
+
new_sub = await self._subscribe(sub.type, sub.filters)
|
|
274
|
+
if new_sub.id:
|
|
275
|
+
self._subscriptions[new_sub.id] = sub
|
|
276
|
+
sub._set_id(new_sub.id)
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|