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/engine.py ADDED
@@ -0,0 +1,201 @@
1
+ """High-level orderbook engine with local state and filtered views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ from .orderbook import OrderbookWS
8
+ from .orderbook_state import LocalOrderbook
9
+ from .types.orderbook import (
10
+ BookSnapshot,
11
+ BookUpdate,
12
+ OrderbookLevel,
13
+ PriceChange,
14
+ )
15
+
16
+
17
+ class EngineView:
18
+ """A filtered view over the shared orderbook state for specific tokens."""
19
+
20
+ def __init__(
21
+ self,
22
+ token_ids: list[str],
23
+ state: LocalOrderbook,
24
+ remove_from_engine: Callable,
25
+ ) -> None:
26
+ self._tokens: set[str] = set(token_ids)
27
+ self._state = state
28
+ self._remove_from_engine = remove_from_engine
29
+ self._destroyed = False
30
+ self._handlers: dict[str, list[Callable]] = {"update": [], "price": []}
31
+
32
+ def on(self, event: str, handler: Callable) -> EngineView:
33
+ if not self._destroyed and event in self._handlers:
34
+ self._handlers[event].append(handler)
35
+ return self
36
+
37
+ def off(self, event: str, handler: Callable) -> EngineView:
38
+ if event in self._handlers:
39
+ try:
40
+ self._handlers[event].remove(handler)
41
+ except ValueError:
42
+ pass
43
+ return self
44
+
45
+ def set_tokens(self, token_ids: list[str]) -> None:
46
+ self._tokens = set(token_ids)
47
+
48
+ def book(self, token_id: str) -> tuple[list[OrderbookLevel], list[OrderbookLevel]] | None:
49
+ return self._state.get_book(token_id)
50
+
51
+ def midpoint(self, token_id: str) -> float | None:
52
+ return _compute_midpoint(self._state, token_id)
53
+
54
+ def spread(self, token_id: str) -> float | None:
55
+ return self._state.get_spread(token_id)
56
+
57
+ def best_bid(self, token_id: str) -> OrderbookLevel | None:
58
+ return self._state.get_best_bid(token_id)
59
+
60
+ def best_ask(self, token_id: str) -> OrderbookLevel | None:
61
+ return self._state.get_best_ask(token_id)
62
+
63
+ def destroy(self) -> None:
64
+ self._destroyed = True
65
+ self._handlers = {"update": [], "price": []}
66
+ self._tokens.clear()
67
+ self._remove_from_engine()
68
+
69
+ def _emit_update(self, data: BookSnapshot | BookUpdate) -> None:
70
+ if self._destroyed:
71
+ return
72
+ for h in self._handlers["update"]:
73
+ h(data)
74
+
75
+ def _emit_price(self, data: PriceChange) -> None:
76
+ if self._destroyed:
77
+ return
78
+ for h in self._handlers["price"]:
79
+ h(data)
80
+
81
+
82
+ class OrderbookEngine:
83
+ """Manages one WebSocket connection, local state, and multiple filtered views."""
84
+
85
+ def __init__(
86
+ self,
87
+ api_key: str,
88
+ *,
89
+ ob_url: str = "wss://ob.polynode.dev/ws",
90
+ compress: bool = True,
91
+ auto_reconnect: bool = True,
92
+ max_reconnect_attempts: int = 0,
93
+ reconnect_base_delay: float = 1.0,
94
+ reconnect_max_delay: float = 30.0,
95
+ ) -> None:
96
+ self._ws = OrderbookWS(
97
+ api_key,
98
+ ob_url,
99
+ compress=compress,
100
+ auto_reconnect=auto_reconnect,
101
+ max_reconnect_attempts=max_reconnect_attempts,
102
+ reconnect_base_delay=reconnect_base_delay,
103
+ reconnect_max_delay=reconnect_max_delay,
104
+ )
105
+ self._state = LocalOrderbook()
106
+ self._views: set[EngineView] = set()
107
+ self._handlers: dict[str, list[Callable]] = {"update": [], "price": [], "ready": []}
108
+
109
+ self._ws.on("snapshot", self._on_snapshot)
110
+ self._ws.on("update", self._on_update)
111
+ self._ws.on("price", self._on_price)
112
+ self._ws.on("snapshots_done", self._on_ready)
113
+
114
+ def _on_snapshot(self, snap: BookSnapshot) -> None:
115
+ self._state.apply_snapshot(snap)
116
+ self._route_update(snap)
117
+
118
+ def _on_update(self, update: BookUpdate) -> None:
119
+ self._state.apply_update(update)
120
+ self._route_update(update)
121
+
122
+ def _on_price(self, change: PriceChange) -> None:
123
+ for h in self._handlers["price"]:
124
+ h(change)
125
+ for view in self._views:
126
+ if view._destroyed:
127
+ continue
128
+ if any(a.asset_id in view._tokens for a in change.assets):
129
+ view._emit_price(change)
130
+
131
+ def _on_ready(self, msg: Any) -> None:
132
+ for h in self._handlers["ready"]:
133
+ h()
134
+
135
+ async def subscribe(self, identifiers: list[str]) -> None:
136
+ await self._ws.subscribe(identifiers)
137
+
138
+ def on(self, event: str, handler: Callable) -> OrderbookEngine:
139
+ if event in self._handlers:
140
+ self._handlers[event].append(handler)
141
+ return self
142
+
143
+ def off(self, event: str, handler: Callable) -> OrderbookEngine:
144
+ if event in self._handlers:
145
+ try:
146
+ self._handlers[event].remove(handler)
147
+ except ValueError:
148
+ pass
149
+ return self
150
+
151
+ def view(self, token_ids: list[str]) -> EngineView:
152
+ v = EngineView(token_ids, self._state, lambda: self._views.discard(v))
153
+ self._views.add(v)
154
+ return v
155
+
156
+ def book(self, token_id: str) -> tuple[list[OrderbookLevel], list[OrderbookLevel]] | None:
157
+ return self._state.get_book(token_id)
158
+
159
+ def midpoint(self, token_id: str) -> float | None:
160
+ return _compute_midpoint(self._state, token_id)
161
+
162
+ def spread(self, token_id: str) -> float | None:
163
+ return self._state.get_spread(token_id)
164
+
165
+ def best_bid(self, token_id: str) -> OrderbookLevel | None:
166
+ return self._state.get_best_bid(token_id)
167
+
168
+ def best_ask(self, token_id: str) -> OrderbookLevel | None:
169
+ return self._state.get_best_ask(token_id)
170
+
171
+ @property
172
+ def connection(self) -> OrderbookWS:
173
+ return self._ws
174
+
175
+ @property
176
+ def size(self) -> int:
177
+ return self._state.size
178
+
179
+ def close(self) -> None:
180
+ for view in list(self._views):
181
+ view.destroy()
182
+ self._views.clear()
183
+ self._state.clear()
184
+ self._ws.disconnect()
185
+
186
+ def _route_update(self, update: BookSnapshot | BookUpdate) -> None:
187
+ for h in self._handlers["update"]:
188
+ h(update)
189
+ for view in self._views:
190
+ if view._destroyed:
191
+ continue
192
+ if update.asset_id in view._tokens:
193
+ view._emit_update(update)
194
+
195
+
196
+ def _compute_midpoint(state: LocalOrderbook, token_id: str) -> float | None:
197
+ bid = state.get_best_bid(token_id)
198
+ ask = state.get_best_ask(token_id)
199
+ if not bid or not ask:
200
+ return None
201
+ return (float(bid.price) + float(ask.price)) / 2
polynode/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """PolyNode SDK error hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PolyNodeError(Exception):
7
+ """Base error for all PolyNode SDK errors."""
8
+
9
+ def __init__(self, message: str) -> None:
10
+ self.message = message
11
+ super().__init__(message)
12
+
13
+
14
+ class ApiError(PolyNodeError):
15
+ """HTTP API error with status code."""
16
+
17
+ def __init__(self, message: str, status: int) -> None:
18
+ self.status = status
19
+ super().__init__(message)
20
+
21
+ def __str__(self) -> str:
22
+ return f"ApiError({self.status}): {self.message}"
23
+
24
+
25
+ class WsError(PolyNodeError):
26
+ """WebSocket error with optional error code."""
27
+
28
+ def __init__(self, message: str, code: str | None = None) -> None:
29
+ self.code = code
30
+ super().__init__(message)
31
+
32
+ def __str__(self) -> str:
33
+ if self.code:
34
+ return f"WsError({self.code}): {self.message}"
35
+ return f"WsError: {self.message}"
polynode/orderbook.py ADDED
@@ -0,0 +1,243 @@
1
+ """Async WebSocket client for real-time orderbook updates."""
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 .types.orderbook import (
17
+ BookSnapshot,
18
+ BookUpdate,
19
+ ObErrorMessage,
20
+ PriceChange,
21
+ SnapshotsDoneMessage,
22
+ SubscribedMessage,
23
+ )
24
+
25
+
26
+ class OrderbookWS:
27
+ """Async WebSocket client for orderbook streaming from ob.polynode.dev."""
28
+
29
+ def __init__(self, api_key: str, ob_url: str, *, compress: bool = True, auto_reconnect: bool = True,
30
+ max_reconnect_attempts: int = 0, reconnect_base_delay: float = 1.0,
31
+ reconnect_max_delay: float = 30.0) -> None:
32
+ self._api_key = api_key
33
+ self._ob_url = ob_url
34
+ self._compress = compress
35
+ self._auto_reconnect = auto_reconnect
36
+ self._max_reconnect = max_reconnect_attempts
37
+ self._base_delay = reconnect_base_delay
38
+ self._max_delay = reconnect_max_delay
39
+
40
+ self._socket: Any = None
41
+ self._connected = False
42
+ self._intentional_close = False
43
+ self._reconnect_attempts = 0
44
+ self._token_ids: list[str] = []
45
+ self._recv_task: asyncio.Task | None = None
46
+
47
+ self._handlers: dict[str, list[Callable]] = {
48
+ "snapshot": [],
49
+ "update": [],
50
+ "price": [],
51
+ "snapshots_done": [],
52
+ "subscribed": [],
53
+ "error": [],
54
+ "*": [],
55
+ }
56
+ self._on_connect: list[Callable] = []
57
+ self._on_disconnect: list[Callable] = []
58
+ self._on_reconnect: list[Callable] = []
59
+ self._on_error: list[Callable] = []
60
+
61
+ @property
62
+ def is_connected(self) -> bool:
63
+ return self._connected
64
+
65
+ def on_connect(self, handler: Callable) -> OrderbookWS:
66
+ self._on_connect.append(handler)
67
+ return self
68
+
69
+ def on_disconnect(self, handler: Callable) -> OrderbookWS:
70
+ self._on_disconnect.append(handler)
71
+ return self
72
+
73
+ def on_reconnect(self, handler: Callable) -> OrderbookWS:
74
+ self._on_reconnect.append(handler)
75
+ return self
76
+
77
+ def on_error(self, handler: Callable) -> OrderbookWS:
78
+ self._on_error.append(handler)
79
+ return self
80
+
81
+ def on(self, event: str, handler: Callable) -> OrderbookWS:
82
+ if event in self._handlers:
83
+ self._handlers[event].append(handler)
84
+ return self
85
+
86
+ def off(self, event: str, handler: Callable) -> OrderbookWS:
87
+ if event in self._handlers:
88
+ try:
89
+ self._handlers[event].remove(handler)
90
+ except ValueError:
91
+ pass
92
+ return self
93
+
94
+ async def subscribe(self, token_ids: list[str]) -> None:
95
+ self._token_ids = token_ids
96
+ 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 = []
101
+ if self._connected and self._socket:
102
+ asyncio.ensure_future(self._send(json.dumps({"action": "unsubscribe"})))
103
+
104
+ async def connect(self) -> None:
105
+ if self._connected and self._socket:
106
+ return
107
+ await self._connect()
108
+
109
+ def disconnect(self) -> None:
110
+ self._intentional_close = True
111
+ if self._recv_task and not self._recv_task.done():
112
+ self._recv_task.cancel()
113
+ if self._socket:
114
+ asyncio.ensure_future(self._socket.close())
115
+ self._socket = None
116
+ self._connected = False
117
+
118
+ # ── Private ──
119
+
120
+ async def _ensure_connected(self) -> None:
121
+ if not self._connected or not self._socket:
122
+ await self._connect()
123
+
124
+ async def _connect(self) -> None:
125
+ url = f"{self._ob_url}?key={self._api_key}"
126
+ if self._compress:
127
+ url += "&compress=zlib"
128
+
129
+ self._socket = await websockets.asyncio.client.connect(url, max_size=10 * 1024 * 1024)
130
+ self._connected = True
131
+ self._reconnect_attempts = 0
132
+ self._intentional_close = False
133
+
134
+ for h in self._on_connect:
135
+ h()
136
+
137
+ self._recv_task = asyncio.ensure_future(self._recv_loop())
138
+
139
+ async def _recv_loop(self) -> None:
140
+ try:
141
+ async for raw in self._socket:
142
+ try:
143
+ if isinstance(raw, bytes) and self._compress:
144
+ text = zlib.decompress(raw, -zlib.MAX_WBITS).decode("utf-8")
145
+ elif isinstance(raw, bytes):
146
+ text = raw.decode("utf-8")
147
+ else:
148
+ text = raw
149
+ self._handle_message(text)
150
+ except Exception as e:
151
+ err = WsError(f"Failed to process message: {e}")
152
+ for h in self._on_error:
153
+ h(err)
154
+ except websockets.exceptions.ConnectionClosed:
155
+ pass
156
+ except asyncio.CancelledError:
157
+ return
158
+ finally:
159
+ self._connected = False
160
+ reason = "closed" if self._intentional_close else "lost"
161
+ for h in self._on_disconnect:
162
+ h(reason)
163
+ if not self._intentional_close and self._auto_reconnect:
164
+ asyncio.ensure_future(self._schedule_reconnect())
165
+
166
+ async def _send(self, data: str) -> None:
167
+ if self._socket and self._connected:
168
+ await self._socket.send(data)
169
+
170
+ def _handle_message(self, text: str) -> None:
171
+ msg = json.loads(text)
172
+
173
+ if msg.get("error"):
174
+ err_msg = ObErrorMessage.model_validate(msg)
175
+ for h in self._handlers["error"]:
176
+ h(err_msg)
177
+ return
178
+
179
+ msg_type = msg.get("type")
180
+
181
+ if msg_type == "subscribed":
182
+ sub_msg = SubscribedMessage(markets=msg.get("markets", 0))
183
+ for h in self._handlers["subscribed"]:
184
+ h(sub_msg)
185
+
186
+ elif msg_type == "snapshot_batch":
187
+ snapshots = msg.get("snapshots", [])
188
+ for snap_data in snapshots:
189
+ snap = BookSnapshot.model_validate(snap_data)
190
+ for h in self._handlers["snapshot"]:
191
+ h(snap)
192
+ for h in self._handlers["*"]:
193
+ h(snap)
194
+
195
+ elif msg_type == "snapshots_done":
196
+ done = SnapshotsDoneMessage(total=msg.get("total", 0))
197
+ for h in self._handlers["snapshots_done"]:
198
+ h(done)
199
+
200
+ elif msg_type == "batch":
201
+ updates = msg.get("updates", [])
202
+ for update_data in updates:
203
+ self._dispatch_update(update_data)
204
+
205
+ def _dispatch_update(self, data: dict) -> None:
206
+ update_type = data.get("type")
207
+ if update_type == "book_snapshot":
208
+ snap = BookSnapshot.model_validate(data)
209
+ for h in self._handlers["snapshot"]:
210
+ h(snap)
211
+ for h in self._handlers["*"]:
212
+ h(snap)
213
+ elif update_type == "book_update":
214
+ update = BookUpdate.model_validate(data)
215
+ for h in self._handlers["update"]:
216
+ h(update)
217
+ for h in self._handlers["*"]:
218
+ h(update)
219
+ elif update_type == "price_change":
220
+ change = PriceChange.model_validate(data)
221
+ for h in self._handlers["price"]:
222
+ h(change)
223
+ for h in self._handlers["*"]:
224
+ h(change)
225
+
226
+ async def _schedule_reconnect(self) -> None:
227
+ if self._max_reconnect and self._reconnect_attempts >= self._max_reconnect:
228
+ return
229
+
230
+ self._reconnect_attempts += 1
231
+ delay = min(self._base_delay * math.pow(2, self._reconnect_attempts - 1), self._max_delay)
232
+ jitter = delay * (0.5 + random.random() * 0.5)
233
+
234
+ await asyncio.sleep(jitter)
235
+
236
+ try:
237
+ await self._connect()
238
+ for h in self._on_reconnect:
239
+ h(self._reconnect_attempts)
240
+ if self._token_ids:
241
+ await self._send(json.dumps({"action": "subscribe", "markets": self._token_ids}))
242
+ except Exception:
243
+ pass
@@ -0,0 +1,77 @@
1
+ """Local orderbook state manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .types.orderbook import BookSnapshot, BookUpdate, OrderbookLevel, PriceChange
6
+
7
+
8
+ class LocalOrderbook:
9
+ """Maintains sorted local copies of orderbooks for any number of assets."""
10
+
11
+ def __init__(self) -> None:
12
+ self._books: dict[str, tuple[list[OrderbookLevel], list[OrderbookLevel]]] = {}
13
+
14
+ def apply_snapshot(self, snap: BookSnapshot) -> None:
15
+ bids = sorted(list(snap.bids), key=lambda l: float(l.price), reverse=True)
16
+ asks = sorted(list(snap.asks), key=lambda l: float(l.price))
17
+ self._books[snap.asset_id] = (bids, asks)
18
+
19
+ def apply_update(self, update: BookUpdate) -> None:
20
+ if update.asset_id not in self._books:
21
+ self._books[update.asset_id] = ([], [])
22
+ bids, asks = self._books[update.asset_id]
23
+ new_bids = _apply_deltas(bids, update.bids, reverse=True)
24
+ new_asks = _apply_deltas(asks, update.asks, reverse=False)
25
+ self._books[update.asset_id] = (new_bids, new_asks)
26
+
27
+ def apply_price_change(self, change: PriceChange) -> None:
28
+ pass
29
+
30
+ def get_book(self, asset_id: str) -> tuple[list[OrderbookLevel], list[OrderbookLevel]] | None:
31
+ return self._books.get(asset_id)
32
+
33
+ def get_best_bid(self, asset_id: str) -> OrderbookLevel | None:
34
+ book = self._books.get(asset_id)
35
+ if book and book[0]:
36
+ return book[0][0]
37
+ return None
38
+
39
+ def get_best_ask(self, asset_id: str) -> OrderbookLevel | None:
40
+ book = self._books.get(asset_id)
41
+ if book and book[1]:
42
+ return book[1][0]
43
+ return None
44
+
45
+ def get_spread(self, asset_id: str) -> float | None:
46
+ bid = self.get_best_bid(asset_id)
47
+ ask = self.get_best_ask(asset_id)
48
+ if not bid or not ask:
49
+ return None
50
+ return float(ask.price) - float(bid.price)
51
+
52
+ @property
53
+ def size(self) -> int:
54
+ return len(self._books)
55
+
56
+ def clear(self) -> None:
57
+ self._books.clear()
58
+
59
+
60
+ def _apply_deltas(
61
+ levels: list[OrderbookLevel],
62
+ deltas: list[OrderbookLevel],
63
+ *,
64
+ reverse: bool,
65
+ ) -> list[OrderbookLevel]:
66
+ price_map: dict[str, str] = {}
67
+ for level in levels:
68
+ price_map[level.price] = level.size
69
+ for delta in deltas:
70
+ if delta.size == "0" or delta.size == "0.00":
71
+ price_map.pop(delta.price, None)
72
+ else:
73
+ price_map[delta.price] = delta.size
74
+
75
+ result = [OrderbookLevel(price=p, size=s) for p, s in price_map.items()]
76
+ result.sort(key=lambda l: float(l.price), reverse=reverse)
77
+ return result