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