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/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