oxarchive 0.1.0__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.
- oxarchive/__init__.py +101 -0
- oxarchive/client.py +114 -0
- oxarchive/http.py +108 -0
- oxarchive/resources/__init__.py +17 -0
- oxarchive/resources/candles.py +100 -0
- oxarchive/resources/funding.py +113 -0
- oxarchive/resources/instruments.py +55 -0
- oxarchive/resources/openinterest.py +113 -0
- oxarchive/resources/orderbook.py +148 -0
- oxarchive/resources/trades.py +125 -0
- oxarchive/types.py +304 -0
- oxarchive/websocket.py +612 -0
- oxarchive-0.1.0.dist-info/METADATA +401 -0
- oxarchive-0.1.0.dist-info/RECORD +15 -0
- oxarchive-0.1.0.dist-info/WHEEL +4 -0
oxarchive/websocket.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket client for 0xarchive real-time streaming, replay, and bulk download.
|
|
3
|
+
|
|
4
|
+
Examples:
|
|
5
|
+
Real-time streaming:
|
|
6
|
+
>>> ws = OxArchiveWs(WsOptions(api_key="ox_..."))
|
|
7
|
+
>>> await ws.connect()
|
|
8
|
+
>>> ws.on_orderbook(lambda coin, ob: print(f"{coin}: {ob.mid_price}"))
|
|
9
|
+
>>> ws.subscribe_orderbook("BTC")
|
|
10
|
+
|
|
11
|
+
Historical replay (like Tardis.dev):
|
|
12
|
+
>>> ws = OxArchiveWs(WsOptions(api_key="ox_..."))
|
|
13
|
+
>>> await ws.connect()
|
|
14
|
+
>>> ws.on_historical_data(lambda coin, ts, data: print(f"{ts}: {data}"))
|
|
15
|
+
>>> await ws.replay("orderbook", "BTC", start=time.time()*1000 - 86400000, speed=10)
|
|
16
|
+
|
|
17
|
+
Bulk streaming (like Databento):
|
|
18
|
+
>>> ws = OxArchiveWs(WsOptions(api_key="ox_..."))
|
|
19
|
+
>>> await ws.connect()
|
|
20
|
+
>>> batches = []
|
|
21
|
+
>>> ws.on_batch(lambda coin, records: batches.extend(records))
|
|
22
|
+
>>> await ws.stream("orderbook", "ETH", start=..., end=..., batch_size=1000)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Any, Callable, Optional, Set, Union
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import websockets
|
|
35
|
+
from websockets.client import WebSocketClientProtocol
|
|
36
|
+
except ImportError:
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"WebSocket support requires the 'websockets' package. "
|
|
39
|
+
"Install with: pip install oxarchive[websocket]"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from .types import (
|
|
43
|
+
OrderBook,
|
|
44
|
+
Trade,
|
|
45
|
+
WsChannel,
|
|
46
|
+
WsConnectionState,
|
|
47
|
+
WsData,
|
|
48
|
+
WsError,
|
|
49
|
+
WsPong,
|
|
50
|
+
WsSubscribed,
|
|
51
|
+
WsUnsubscribed,
|
|
52
|
+
WsReplayStarted,
|
|
53
|
+
WsReplayPaused,
|
|
54
|
+
WsReplayResumed,
|
|
55
|
+
WsReplayCompleted,
|
|
56
|
+
WsReplayStopped,
|
|
57
|
+
WsHistoricalData,
|
|
58
|
+
WsStreamStarted,
|
|
59
|
+
WsStreamProgress,
|
|
60
|
+
WsHistoricalBatch,
|
|
61
|
+
WsStreamCompleted,
|
|
62
|
+
WsStreamStopped,
|
|
63
|
+
TimestampedRecord,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger("oxarchive.websocket")
|
|
67
|
+
|
|
68
|
+
DEFAULT_WS_URL = "wss://ws.0xarchive.io"
|
|
69
|
+
DEFAULT_PING_INTERVAL = 30
|
|
70
|
+
DEFAULT_RECONNECT_DELAY = 1.0
|
|
71
|
+
DEFAULT_MAX_RECONNECT_ATTEMPTS = 10
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class WsOptions:
|
|
76
|
+
"""WebSocket connection options."""
|
|
77
|
+
|
|
78
|
+
api_key: str
|
|
79
|
+
ws_url: str = DEFAULT_WS_URL
|
|
80
|
+
auto_reconnect: bool = True
|
|
81
|
+
reconnect_delay: float = DEFAULT_RECONNECT_DELAY
|
|
82
|
+
max_reconnect_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS
|
|
83
|
+
ping_interval: float = DEFAULT_PING_INTERVAL
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
MessageHandler = Callable[[Union[WsSubscribed, WsUnsubscribed, WsPong, WsError, WsData]], None]
|
|
87
|
+
OrderbookHandler = Callable[[str, OrderBook], None]
|
|
88
|
+
TradesHandler = Callable[[str, list[Trade]], None]
|
|
89
|
+
StateHandler = Callable[[WsConnectionState], None]
|
|
90
|
+
ErrorHandler = Callable[[Exception], None]
|
|
91
|
+
|
|
92
|
+
# Replay handlers
|
|
93
|
+
HistoricalDataHandler = Callable[[str, int, dict], None]
|
|
94
|
+
ReplayStartHandler = Callable[[WsChannel, str, int, float], None]
|
|
95
|
+
ReplayCompleteHandler = Callable[[WsChannel, str, int], None]
|
|
96
|
+
|
|
97
|
+
# Stream handlers
|
|
98
|
+
BatchHandler = Callable[[str, list[TimestampedRecord]], None]
|
|
99
|
+
StreamStartHandler = Callable[[WsChannel, str, int], None]
|
|
100
|
+
StreamProgressHandler = Callable[[int, int, float], None]
|
|
101
|
+
StreamCompleteHandler = Callable[[WsChannel, str, int], None]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OxArchiveWs:
|
|
105
|
+
"""WebSocket client for real-time data streaming."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, options: WsOptions):
|
|
108
|
+
"""Initialize the WebSocket client.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
options: WebSocket connection options
|
|
112
|
+
"""
|
|
113
|
+
self.options = options
|
|
114
|
+
self._ws: Optional[WebSocketClientProtocol] = None
|
|
115
|
+
self._state: WsConnectionState = "disconnected"
|
|
116
|
+
self._subscriptions: Set[str] = set()
|
|
117
|
+
self._reconnect_attempts = 0
|
|
118
|
+
self._running = False
|
|
119
|
+
self._ping_task: Optional[asyncio.Task] = None
|
|
120
|
+
self._receive_task: Optional[asyncio.Task] = None
|
|
121
|
+
|
|
122
|
+
# Event handlers
|
|
123
|
+
self._on_message: Optional[MessageHandler] = None
|
|
124
|
+
self._on_orderbook: Optional[OrderbookHandler] = None
|
|
125
|
+
self._on_trades: Optional[TradesHandler] = None
|
|
126
|
+
self._on_state_change: Optional[StateHandler] = None
|
|
127
|
+
self._on_error: Optional[ErrorHandler] = None
|
|
128
|
+
self._on_open: Optional[Callable[[], None]] = None
|
|
129
|
+
self._on_close: Optional[Callable[[int, str], None]] = None
|
|
130
|
+
|
|
131
|
+
# Replay handlers (Option B)
|
|
132
|
+
self._on_historical_data: Optional[HistoricalDataHandler] = None
|
|
133
|
+
self._on_replay_start: Optional[ReplayStartHandler] = None
|
|
134
|
+
self._on_replay_complete: Optional[ReplayCompleteHandler] = None
|
|
135
|
+
|
|
136
|
+
# Stream handlers (Option D)
|
|
137
|
+
self._on_batch: Optional[BatchHandler] = None
|
|
138
|
+
self._on_stream_start: Optional[StreamStartHandler] = None
|
|
139
|
+
self._on_stream_progress: Optional[StreamProgressHandler] = None
|
|
140
|
+
self._on_stream_complete: Optional[StreamCompleteHandler] = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def state(self) -> WsConnectionState:
|
|
144
|
+
"""Get current connection state."""
|
|
145
|
+
return self._state
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_connected(self) -> bool:
|
|
149
|
+
"""Check if connected."""
|
|
150
|
+
return self._ws is not None and self._ws.open
|
|
151
|
+
|
|
152
|
+
async def connect(self) -> None:
|
|
153
|
+
"""Connect to the WebSocket server."""
|
|
154
|
+
self._running = True
|
|
155
|
+
await self._connect()
|
|
156
|
+
|
|
157
|
+
async def _connect(self) -> None:
|
|
158
|
+
"""Internal connect method."""
|
|
159
|
+
self._set_state("connecting")
|
|
160
|
+
|
|
161
|
+
url = f"{self.options.ws_url}?apiKey={self.options.api_key}"
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
self._ws = await websockets.connect(url)
|
|
165
|
+
self._reconnect_attempts = 0
|
|
166
|
+
self._set_state("connected")
|
|
167
|
+
|
|
168
|
+
if self._on_open:
|
|
169
|
+
self._on_open()
|
|
170
|
+
|
|
171
|
+
# Resubscribe to all channels
|
|
172
|
+
await self._resubscribe()
|
|
173
|
+
|
|
174
|
+
# Start ping and receive tasks
|
|
175
|
+
self._ping_task = asyncio.create_task(self._ping_loop())
|
|
176
|
+
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"Connection failed: {e}")
|
|
180
|
+
if self._on_error:
|
|
181
|
+
self._on_error(e)
|
|
182
|
+
if self.options.auto_reconnect and self._running:
|
|
183
|
+
await self._schedule_reconnect()
|
|
184
|
+
else:
|
|
185
|
+
self._set_state("disconnected")
|
|
186
|
+
|
|
187
|
+
async def disconnect(self) -> None:
|
|
188
|
+
"""Disconnect from the WebSocket server."""
|
|
189
|
+
self._running = False
|
|
190
|
+
self._set_state("disconnected")
|
|
191
|
+
|
|
192
|
+
if self._ping_task:
|
|
193
|
+
self._ping_task.cancel()
|
|
194
|
+
self._ping_task = None
|
|
195
|
+
|
|
196
|
+
if self._receive_task:
|
|
197
|
+
self._receive_task.cancel()
|
|
198
|
+
self._receive_task = None
|
|
199
|
+
|
|
200
|
+
if self._ws:
|
|
201
|
+
await self._ws.close(1000, "Client disconnect")
|
|
202
|
+
self._ws = None
|
|
203
|
+
|
|
204
|
+
def subscribe(self, channel: WsChannel, coin: Optional[str] = None) -> None:
|
|
205
|
+
"""Subscribe to a channel.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
channel: Channel type
|
|
209
|
+
coin: Coin symbol (required for coin-specific channels)
|
|
210
|
+
"""
|
|
211
|
+
key = self._subscription_key(channel, coin)
|
|
212
|
+
self._subscriptions.add(key)
|
|
213
|
+
|
|
214
|
+
if self.is_connected:
|
|
215
|
+
asyncio.create_task(self._send_subscribe(channel, coin))
|
|
216
|
+
|
|
217
|
+
async def subscribe_async(self, channel: WsChannel, coin: Optional[str] = None) -> None:
|
|
218
|
+
"""Subscribe to a channel (async version)."""
|
|
219
|
+
key = self._subscription_key(channel, coin)
|
|
220
|
+
self._subscriptions.add(key)
|
|
221
|
+
|
|
222
|
+
if self.is_connected:
|
|
223
|
+
await self._send_subscribe(channel, coin)
|
|
224
|
+
|
|
225
|
+
def subscribe_orderbook(self, coin: str) -> None:
|
|
226
|
+
"""Subscribe to order book updates for a coin."""
|
|
227
|
+
self.subscribe("orderbook", coin)
|
|
228
|
+
|
|
229
|
+
def subscribe_trades(self, coin: str) -> None:
|
|
230
|
+
"""Subscribe to trades for a coin."""
|
|
231
|
+
self.subscribe("trades", coin)
|
|
232
|
+
|
|
233
|
+
def subscribe_ticker(self, coin: str) -> None:
|
|
234
|
+
"""Subscribe to ticker updates for a coin."""
|
|
235
|
+
self.subscribe("ticker", coin)
|
|
236
|
+
|
|
237
|
+
def subscribe_all_tickers(self) -> None:
|
|
238
|
+
"""Subscribe to all tickers."""
|
|
239
|
+
self.subscribe("all_tickers")
|
|
240
|
+
|
|
241
|
+
def unsubscribe(self, channel: WsChannel, coin: Optional[str] = None) -> None:
|
|
242
|
+
"""Unsubscribe from a channel."""
|
|
243
|
+
key = self._subscription_key(channel, coin)
|
|
244
|
+
self._subscriptions.discard(key)
|
|
245
|
+
|
|
246
|
+
if self.is_connected:
|
|
247
|
+
asyncio.create_task(self._send_unsubscribe(channel, coin))
|
|
248
|
+
|
|
249
|
+
async def unsubscribe_async(self, channel: WsChannel, coin: Optional[str] = None) -> None:
|
|
250
|
+
"""Unsubscribe from a channel (async version)."""
|
|
251
|
+
key = self._subscription_key(channel, coin)
|
|
252
|
+
self._subscriptions.discard(key)
|
|
253
|
+
|
|
254
|
+
if self.is_connected:
|
|
255
|
+
await self._send_unsubscribe(channel, coin)
|
|
256
|
+
|
|
257
|
+
def unsubscribe_orderbook(self, coin: str) -> None:
|
|
258
|
+
"""Unsubscribe from order book updates for a coin."""
|
|
259
|
+
self.unsubscribe("orderbook", coin)
|
|
260
|
+
|
|
261
|
+
def unsubscribe_trades(self, coin: str) -> None:
|
|
262
|
+
"""Unsubscribe from trades for a coin."""
|
|
263
|
+
self.unsubscribe("trades", coin)
|
|
264
|
+
|
|
265
|
+
def unsubscribe_ticker(self, coin: str) -> None:
|
|
266
|
+
"""Unsubscribe from ticker updates for a coin."""
|
|
267
|
+
self.unsubscribe("ticker", coin)
|
|
268
|
+
|
|
269
|
+
def unsubscribe_all_tickers(self) -> None:
|
|
270
|
+
"""Unsubscribe from all tickers."""
|
|
271
|
+
self.unsubscribe("all_tickers")
|
|
272
|
+
|
|
273
|
+
# =========================================================================
|
|
274
|
+
# Historical Replay (Option B) - Like Tardis.dev
|
|
275
|
+
# =========================================================================
|
|
276
|
+
|
|
277
|
+
async def replay(
|
|
278
|
+
self,
|
|
279
|
+
channel: WsChannel,
|
|
280
|
+
coin: str,
|
|
281
|
+
start: int,
|
|
282
|
+
end: Optional[int] = None,
|
|
283
|
+
speed: float = 1.0,
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Start historical replay with timing preserved.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
channel: Data channel to replay
|
|
289
|
+
coin: Trading pair (e.g., 'BTC', 'ETH')
|
|
290
|
+
start: Start timestamp (Unix ms)
|
|
291
|
+
end: End timestamp (Unix ms, defaults to now)
|
|
292
|
+
speed: Playback speed multiplier (1 = real-time, 10 = 10x faster)
|
|
293
|
+
|
|
294
|
+
Example:
|
|
295
|
+
>>> await ws.replay("orderbook", "BTC", start=time.time()*1000 - 86400000, speed=10)
|
|
296
|
+
"""
|
|
297
|
+
msg = {
|
|
298
|
+
"op": "replay",
|
|
299
|
+
"channel": channel,
|
|
300
|
+
"coin": coin,
|
|
301
|
+
"start": start,
|
|
302
|
+
"speed": speed,
|
|
303
|
+
}
|
|
304
|
+
if end is not None:
|
|
305
|
+
msg["end"] = end
|
|
306
|
+
await self._send(msg)
|
|
307
|
+
|
|
308
|
+
async def replay_pause(self) -> None:
|
|
309
|
+
"""Pause the current replay."""
|
|
310
|
+
await self._send({"op": "replay.pause"})
|
|
311
|
+
|
|
312
|
+
async def replay_resume(self) -> None:
|
|
313
|
+
"""Resume a paused replay."""
|
|
314
|
+
await self._send({"op": "replay.resume"})
|
|
315
|
+
|
|
316
|
+
async def replay_seek(self, timestamp: int) -> None:
|
|
317
|
+
"""Seek to a specific timestamp in the replay.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
timestamp: Unix timestamp in milliseconds
|
|
321
|
+
"""
|
|
322
|
+
await self._send({"op": "replay.seek", "timestamp": timestamp})
|
|
323
|
+
|
|
324
|
+
async def replay_stop(self) -> None:
|
|
325
|
+
"""Stop the current replay."""
|
|
326
|
+
await self._send({"op": "replay.stop"})
|
|
327
|
+
|
|
328
|
+
# =========================================================================
|
|
329
|
+
# Bulk Streaming (Option D) - Like Databento
|
|
330
|
+
# =========================================================================
|
|
331
|
+
|
|
332
|
+
async def stream(
|
|
333
|
+
self,
|
|
334
|
+
channel: WsChannel,
|
|
335
|
+
coin: str,
|
|
336
|
+
start: int,
|
|
337
|
+
end: int,
|
|
338
|
+
batch_size: int = 1000,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Start bulk streaming for fast data download.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
channel: Data channel to stream
|
|
344
|
+
coin: Trading pair (e.g., 'BTC', 'ETH')
|
|
345
|
+
start: Start timestamp (Unix ms)
|
|
346
|
+
end: End timestamp (Unix ms)
|
|
347
|
+
batch_size: Records per batch message
|
|
348
|
+
|
|
349
|
+
Example:
|
|
350
|
+
>>> await ws.stream("orderbook", "ETH", start=..., end=..., batch_size=1000)
|
|
351
|
+
"""
|
|
352
|
+
await self._send({
|
|
353
|
+
"op": "stream",
|
|
354
|
+
"channel": channel,
|
|
355
|
+
"coin": coin,
|
|
356
|
+
"start": start,
|
|
357
|
+
"end": end,
|
|
358
|
+
"batch_size": batch_size,
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
async def stream_stop(self) -> None:
|
|
362
|
+
"""Stop the current bulk stream."""
|
|
363
|
+
await self._send({"op": "stream.stop"})
|
|
364
|
+
|
|
365
|
+
# Event handler setters
|
|
366
|
+
|
|
367
|
+
def on_message(self, handler: MessageHandler) -> None:
|
|
368
|
+
"""Set handler for all messages."""
|
|
369
|
+
self._on_message = handler
|
|
370
|
+
|
|
371
|
+
def on_orderbook(self, handler: OrderbookHandler) -> None:
|
|
372
|
+
"""Set handler for orderbook data."""
|
|
373
|
+
self._on_orderbook = handler
|
|
374
|
+
|
|
375
|
+
def on_trades(self, handler: TradesHandler) -> None:
|
|
376
|
+
"""Set handler for trade data."""
|
|
377
|
+
self._on_trades = handler
|
|
378
|
+
|
|
379
|
+
def on_state_change(self, handler: StateHandler) -> None:
|
|
380
|
+
"""Set handler for state changes."""
|
|
381
|
+
self._on_state_change = handler
|
|
382
|
+
|
|
383
|
+
def on_error(self, handler: ErrorHandler) -> None:
|
|
384
|
+
"""Set handler for errors."""
|
|
385
|
+
self._on_error = handler
|
|
386
|
+
|
|
387
|
+
def on_open(self, handler: Callable[[], None]) -> None:
|
|
388
|
+
"""Set handler for connection open."""
|
|
389
|
+
self._on_open = handler
|
|
390
|
+
|
|
391
|
+
def on_close(self, handler: Callable[[int, str], None]) -> None:
|
|
392
|
+
"""Set handler for connection close."""
|
|
393
|
+
self._on_close = handler
|
|
394
|
+
|
|
395
|
+
# Replay event handlers (Option B)
|
|
396
|
+
|
|
397
|
+
def on_historical_data(self, handler: HistoricalDataHandler) -> None:
|
|
398
|
+
"""Set handler for historical data points (replay mode).
|
|
399
|
+
|
|
400
|
+
Handler receives: (coin, timestamp, data)
|
|
401
|
+
"""
|
|
402
|
+
self._on_historical_data = handler
|
|
403
|
+
|
|
404
|
+
def on_replay_start(self, handler: ReplayStartHandler) -> None:
|
|
405
|
+
"""Set handler for replay started event.
|
|
406
|
+
|
|
407
|
+
Handler receives: (channel, coin, total_records, speed)
|
|
408
|
+
"""
|
|
409
|
+
self._on_replay_start = handler
|
|
410
|
+
|
|
411
|
+
def on_replay_complete(self, handler: ReplayCompleteHandler) -> None:
|
|
412
|
+
"""Set handler for replay completed event.
|
|
413
|
+
|
|
414
|
+
Handler receives: (channel, coin, records_sent)
|
|
415
|
+
"""
|
|
416
|
+
self._on_replay_complete = handler
|
|
417
|
+
|
|
418
|
+
# Stream event handlers (Option D)
|
|
419
|
+
|
|
420
|
+
def on_batch(self, handler: BatchHandler) -> None:
|
|
421
|
+
"""Set handler for batched data (bulk stream mode).
|
|
422
|
+
|
|
423
|
+
Handler receives: (coin, records) where records is list of TimestampedRecord
|
|
424
|
+
"""
|
|
425
|
+
self._on_batch = handler
|
|
426
|
+
|
|
427
|
+
def on_stream_start(self, handler: StreamStartHandler) -> None:
|
|
428
|
+
"""Set handler for stream started event.
|
|
429
|
+
|
|
430
|
+
Handler receives: (channel, coin, total_records)
|
|
431
|
+
"""
|
|
432
|
+
self._on_stream_start = handler
|
|
433
|
+
|
|
434
|
+
def on_stream_progress(self, handler: StreamProgressHandler) -> None:
|
|
435
|
+
"""Set handler for stream progress event.
|
|
436
|
+
|
|
437
|
+
Handler receives: (records_sent, total_records, progress_pct)
|
|
438
|
+
"""
|
|
439
|
+
self._on_stream_progress = handler
|
|
440
|
+
|
|
441
|
+
def on_stream_complete(self, handler: StreamCompleteHandler) -> None:
|
|
442
|
+
"""Set handler for stream completed event.
|
|
443
|
+
|
|
444
|
+
Handler receives: (channel, coin, records_sent)
|
|
445
|
+
"""
|
|
446
|
+
self._on_stream_complete = handler
|
|
447
|
+
|
|
448
|
+
# Private methods
|
|
449
|
+
|
|
450
|
+
async def _send(self, msg: dict) -> None:
|
|
451
|
+
"""Send a message to the server."""
|
|
452
|
+
if self._ws:
|
|
453
|
+
await self._ws.send(json.dumps(msg))
|
|
454
|
+
|
|
455
|
+
def _set_state(self, state: WsConnectionState) -> None:
|
|
456
|
+
"""Set state and notify handler."""
|
|
457
|
+
self._state = state
|
|
458
|
+
if self._on_state_change:
|
|
459
|
+
self._on_state_change(state)
|
|
460
|
+
|
|
461
|
+
def _subscription_key(self, channel: WsChannel, coin: Optional[str]) -> str:
|
|
462
|
+
"""Create subscription key."""
|
|
463
|
+
return f"{channel}:{coin}" if coin else channel
|
|
464
|
+
|
|
465
|
+
async def _send_subscribe(self, channel: WsChannel, coin: Optional[str]) -> None:
|
|
466
|
+
"""Send subscribe message."""
|
|
467
|
+
if self._ws:
|
|
468
|
+
msg = {"op": "subscribe", "channel": channel}
|
|
469
|
+
if coin:
|
|
470
|
+
msg["coin"] = coin
|
|
471
|
+
await self._ws.send(json.dumps(msg))
|
|
472
|
+
|
|
473
|
+
async def _send_unsubscribe(self, channel: WsChannel, coin: Optional[str]) -> None:
|
|
474
|
+
"""Send unsubscribe message."""
|
|
475
|
+
if self._ws:
|
|
476
|
+
msg = {"op": "unsubscribe", "channel": channel}
|
|
477
|
+
if coin:
|
|
478
|
+
msg["coin"] = coin
|
|
479
|
+
await self._ws.send(json.dumps(msg))
|
|
480
|
+
|
|
481
|
+
async def _resubscribe(self) -> None:
|
|
482
|
+
"""Resubscribe to all channels."""
|
|
483
|
+
for key in self._subscriptions:
|
|
484
|
+
parts = key.split(":", 1)
|
|
485
|
+
channel = parts[0]
|
|
486
|
+
coin = parts[1] if len(parts) > 1 else None
|
|
487
|
+
await self._send_subscribe(channel, coin) # type: ignore
|
|
488
|
+
|
|
489
|
+
async def _ping_loop(self) -> None:
|
|
490
|
+
"""Send periodic pings."""
|
|
491
|
+
try:
|
|
492
|
+
while self._running and self.is_connected:
|
|
493
|
+
await asyncio.sleep(self.options.ping_interval)
|
|
494
|
+
if self._ws:
|
|
495
|
+
await self._ws.send(json.dumps({"op": "ping"}))
|
|
496
|
+
except asyncio.CancelledError:
|
|
497
|
+
pass
|
|
498
|
+
except Exception as e:
|
|
499
|
+
logger.error(f"Ping error: {e}")
|
|
500
|
+
|
|
501
|
+
async def _receive_loop(self) -> None:
|
|
502
|
+
"""Receive and process messages."""
|
|
503
|
+
try:
|
|
504
|
+
while self._running and self._ws:
|
|
505
|
+
try:
|
|
506
|
+
message = await self._ws.recv()
|
|
507
|
+
self._handle_message(message)
|
|
508
|
+
except websockets.ConnectionClosed as e:
|
|
509
|
+
logger.info(f"Connection closed: {e.code} {e.reason}")
|
|
510
|
+
if self._on_close:
|
|
511
|
+
self._on_close(e.code, e.reason)
|
|
512
|
+
if self.options.auto_reconnect and self._running:
|
|
513
|
+
await self._schedule_reconnect()
|
|
514
|
+
else:
|
|
515
|
+
self._set_state("disconnected")
|
|
516
|
+
break
|
|
517
|
+
except asyncio.CancelledError:
|
|
518
|
+
pass
|
|
519
|
+
except Exception as e:
|
|
520
|
+
logger.error(f"Receive error: {e}")
|
|
521
|
+
if self._on_error:
|
|
522
|
+
self._on_error(e)
|
|
523
|
+
|
|
524
|
+
def _handle_message(self, raw: str) -> None:
|
|
525
|
+
"""Handle incoming message."""
|
|
526
|
+
try:
|
|
527
|
+
data = json.loads(raw)
|
|
528
|
+
msg_type = data.get("type")
|
|
529
|
+
|
|
530
|
+
if msg_type == "subscribed":
|
|
531
|
+
msg = WsSubscribed(**data)
|
|
532
|
+
if self._on_message:
|
|
533
|
+
self._on_message(msg)
|
|
534
|
+
|
|
535
|
+
elif msg_type == "unsubscribed":
|
|
536
|
+
msg = WsUnsubscribed(**data)
|
|
537
|
+
if self._on_message:
|
|
538
|
+
self._on_message(msg)
|
|
539
|
+
|
|
540
|
+
elif msg_type == "pong":
|
|
541
|
+
msg = WsPong(**data)
|
|
542
|
+
if self._on_message:
|
|
543
|
+
self._on_message(msg)
|
|
544
|
+
|
|
545
|
+
elif msg_type == "error":
|
|
546
|
+
msg = WsError(**data)
|
|
547
|
+
if self._on_message:
|
|
548
|
+
self._on_message(msg)
|
|
549
|
+
|
|
550
|
+
elif msg_type == "data":
|
|
551
|
+
msg = WsData(**data)
|
|
552
|
+
if self._on_message:
|
|
553
|
+
self._on_message(msg)
|
|
554
|
+
|
|
555
|
+
# Call typed handlers
|
|
556
|
+
channel = data.get("channel")
|
|
557
|
+
coin = data.get("coin", "")
|
|
558
|
+
raw_data = data.get("data", {})
|
|
559
|
+
|
|
560
|
+
if channel == "orderbook" and self._on_orderbook:
|
|
561
|
+
orderbook = OrderBook(**raw_data)
|
|
562
|
+
self._on_orderbook(coin, orderbook)
|
|
563
|
+
|
|
564
|
+
elif channel == "trades" and self._on_trades:
|
|
565
|
+
trades = [Trade(**t) for t in raw_data]
|
|
566
|
+
self._on_trades(coin, trades)
|
|
567
|
+
|
|
568
|
+
# Replay messages (Option B)
|
|
569
|
+
elif msg_type == "replay_started" and self._on_replay_start:
|
|
570
|
+
self._on_replay_start(
|
|
571
|
+
data["channel"], data["coin"], data["total_records"], data["speed"]
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
elif msg_type == "historical_data" and self._on_historical_data:
|
|
575
|
+
self._on_historical_data(data["coin"], data["timestamp"], data["data"])
|
|
576
|
+
|
|
577
|
+
elif msg_type == "replay_completed" and self._on_replay_complete:
|
|
578
|
+
self._on_replay_complete(data["channel"], data["coin"], data["records_sent"])
|
|
579
|
+
|
|
580
|
+
# Stream messages (Option D)
|
|
581
|
+
elif msg_type == "stream_started" and self._on_stream_start:
|
|
582
|
+
self._on_stream_start(data["channel"], data["coin"], data["total_records"])
|
|
583
|
+
|
|
584
|
+
elif msg_type == "stream_progress" and self._on_stream_progress:
|
|
585
|
+
self._on_stream_progress(
|
|
586
|
+
data["records_sent"], data["total_records"], data["progress_pct"]
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
elif msg_type == "historical_batch" and self._on_batch:
|
|
590
|
+
records = [TimestampedRecord(**r) for r in data["records"]]
|
|
591
|
+
self._on_batch(data["coin"], records)
|
|
592
|
+
|
|
593
|
+
elif msg_type == "stream_completed" and self._on_stream_complete:
|
|
594
|
+
self._on_stream_complete(data["channel"], data["coin"], data["records_sent"])
|
|
595
|
+
|
|
596
|
+
except Exception as e:
|
|
597
|
+
logger.error(f"Error handling message: {e}")
|
|
598
|
+
|
|
599
|
+
async def _schedule_reconnect(self) -> None:
|
|
600
|
+
"""Schedule a reconnection attempt."""
|
|
601
|
+
if self._reconnect_attempts >= self.options.max_reconnect_attempts:
|
|
602
|
+
self._set_state("disconnected")
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
self._set_state("reconnecting")
|
|
606
|
+
self._reconnect_attempts += 1
|
|
607
|
+
|
|
608
|
+
delay = self.options.reconnect_delay * (2 ** (self._reconnect_attempts - 1))
|
|
609
|
+
logger.info(f"Reconnecting in {delay}s (attempt {self._reconnect_attempts})")
|
|
610
|
+
|
|
611
|
+
await asyncio.sleep(delay)
|
|
612
|
+
await self._connect()
|