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