oxarchive 0.3.6__py3-none-any.whl → 0.4.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.
@@ -6,7 +6,12 @@ from datetime import datetime
6
6
  from typing import Optional, Union
7
7
 
8
8
  from ..http import HttpClient
9
- from ..types import OrderBook, Timestamp
9
+ from typing import Literal
10
+
11
+ from ..types import CursorResponse, OrderBook, Timestamp
12
+
13
+ # Lighter orderbook granularity levels (Lighter.xyz only)
14
+ LighterGranularity = Literal["checkpoint", "30s", "10s", "1s", "tick"]
10
15
 
11
16
 
12
17
  class OrderBookResource:
@@ -14,18 +19,22 @@ class OrderBookResource:
14
19
  Order book API resource.
15
20
 
16
21
  Example:
17
- >>> # Get current order book
18
- >>> orderbook = client.orderbook.get("BTC")
22
+ >>> # Get current order book (Hyperliquid)
23
+ >>> orderbook = client.hyperliquid.orderbook.get("BTC")
19
24
  >>>
20
25
  >>> # Get order book at specific timestamp
21
- >>> historical = client.orderbook.get("ETH", timestamp=1704067200000)
26
+ >>> historical = client.hyperliquid.orderbook.get("ETH", timestamp=1704067200000)
22
27
  >>>
23
28
  >>> # Get order book history
24
- >>> history = client.orderbook.history("BTC", start="2024-01-01", end="2024-01-02")
29
+ >>> history = client.hyperliquid.orderbook.history("BTC", start="2024-01-01", end="2024-01-02")
30
+ >>>
31
+ >>> # Lighter.xyz order book
32
+ >>> lighter_ob = client.lighter.orderbook.get("BTC")
25
33
  """
26
34
 
27
- def __init__(self, http: HttpClient):
35
+ def __init__(self, http: HttpClient, base_path: str = "/v1"):
28
36
  self._http = http
37
+ self._base_path = base_path
29
38
 
30
39
  def _convert_timestamp(self, ts: Optional[Timestamp]) -> Optional[int]:
31
40
  """Convert timestamp to Unix milliseconds."""
@@ -63,7 +72,7 @@ class OrderBookResource:
63
72
  Order book snapshot
64
73
  """
65
74
  data = self._http.get(
66
- f"/v1/orderbook/{coin.upper()}",
75
+ f"{self._base_path}/orderbook/{coin.upper()}",
67
76
  params={
68
77
  "timestamp": self._convert_timestamp(timestamp),
69
78
  "depth": depth,
@@ -80,7 +89,7 @@ class OrderBookResource:
80
89
  ) -> OrderBook:
81
90
  """Async version of get()."""
82
91
  data = await self._http.aget(
83
- f"/v1/orderbook/{coin.upper()}",
92
+ f"{self._base_path}/orderbook/{coin.upper()}",
84
93
  params={
85
94
  "timestamp": self._convert_timestamp(timestamp),
86
95
  "depth": depth,
@@ -94,35 +103,57 @@ class OrderBookResource:
94
103
  *,
95
104
  start: Timestamp,
96
105
  end: Timestamp,
106
+ cursor: Optional[Timestamp] = None,
97
107
  limit: Optional[int] = None,
98
- offset: Optional[int] = None,
99
108
  depth: Optional[int] = None,
100
- ) -> list[OrderBook]:
109
+ granularity: Optional[LighterGranularity] = None,
110
+ ) -> CursorResponse[list[OrderBook]]:
101
111
  """
102
- Get historical order book snapshots.
112
+ Get historical order book snapshots with cursor-based pagination.
103
113
 
104
114
  Args:
105
115
  coin: The coin symbol (e.g., 'BTC', 'ETH')
106
116
  start: Start timestamp (required)
107
117
  end: End timestamp (required)
108
- limit: Maximum number of results
109
- offset: Number of results to skip
118
+ cursor: Cursor from previous response's next_cursor (timestamp)
119
+ limit: Maximum number of results (default: 100, max: 1000)
110
120
  depth: Number of price levels per side
121
+ granularity: Data resolution for Lighter orderbook (Lighter.xyz only, ignored for Hyperliquid).
122
+ Options: 'checkpoint' (1min, default), '30s', '10s', '1s', 'tick'.
123
+ Tier restrictions apply. Credit multipliers: checkpoint=1x, 30s=2x, 10s=3x, 1s=10x, tick=20x.
111
124
 
112
125
  Returns:
113
- List of order book snapshots
126
+ CursorResponse with order book snapshots and next_cursor for pagination
127
+
128
+ Example:
129
+ >>> result = client.orderbook.history("BTC", start=start, end=end, limit=1000)
130
+ >>> snapshots = result.data
131
+ >>> while result.next_cursor:
132
+ ... result = client.orderbook.history(
133
+ ... "BTC", start=start, end=end, cursor=result.next_cursor, limit=1000
134
+ ... )
135
+ ... snapshots.extend(result.data)
136
+ >>>
137
+ >>> # Lighter.xyz with 10s granularity (Build+ tier)
138
+ >>> result = client.lighter.orderbook.history(
139
+ ... "BTC", start=start, end=end, granularity="10s"
140
+ ... )
114
141
  """
115
142
  data = self._http.get(
116
- f"/v1/orderbook/{coin.upper()}/history",
143
+ f"{self._base_path}/orderbook/{coin.upper()}/history",
117
144
  params={
118
145
  "start": self._convert_timestamp(start),
119
146
  "end": self._convert_timestamp(end),
147
+ "cursor": self._convert_timestamp(cursor),
120
148
  "limit": limit,
121
- "offset": offset,
122
149
  "depth": depth,
150
+ "granularity": granularity,
123
151
  },
124
152
  )
125
- return [OrderBook.model_validate(item) for item in data["data"]]
153
+ return CursorResponse(
154
+ data=[OrderBook.model_validate(item) for item in data["data"]],
155
+ next_cursor=data.get("meta", {}).get("next_cursor"),
156
+ )
126
157
 
127
158
  async def ahistory(
128
159
  self,
@@ -130,19 +161,24 @@ class OrderBookResource:
130
161
  *,
131
162
  start: Timestamp,
132
163
  end: Timestamp,
164
+ cursor: Optional[Timestamp] = None,
133
165
  limit: Optional[int] = None,
134
- offset: Optional[int] = None,
135
166
  depth: Optional[int] = None,
136
- ) -> list[OrderBook]:
137
- """Async version of history(). start and end are required."""
167
+ granularity: Optional[LighterGranularity] = None,
168
+ ) -> CursorResponse[list[OrderBook]]:
169
+ """Async version of history(). start and end are required. See history() for granularity details."""
138
170
  data = await self._http.aget(
139
- f"/v1/orderbook/{coin.upper()}/history",
171
+ f"{self._base_path}/orderbook/{coin.upper()}/history",
140
172
  params={
141
173
  "start": self._convert_timestamp(start),
142
174
  "end": self._convert_timestamp(end),
175
+ "cursor": self._convert_timestamp(cursor),
143
176
  "limit": limit,
144
- "offset": offset,
145
177
  "depth": depth,
178
+ "granularity": granularity,
146
179
  },
147
180
  )
148
- return [OrderBook.model_validate(item) for item in data["data"]]
181
+ return CursorResponse(
182
+ data=[OrderBook.model_validate(item) for item in data["data"]],
183
+ next_cursor=data.get("meta", {}).get("next_cursor"),
184
+ )
@@ -2,20 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import warnings
6
- from dataclasses import dataclass
7
5
  from datetime import datetime
8
6
  from typing import Literal, Optional
9
7
 
10
8
  from ..http import HttpClient
11
- from ..types import Trade, Timestamp
12
-
13
-
14
- @dataclass
15
- class CursorResponse:
16
- """Response with cursor for pagination."""
17
- data: list[Trade]
18
- next_cursor: Optional[str] = None
9
+ from ..types import CursorResponse, Trade, Timestamp
19
10
 
20
11
 
21
12
  class TradesResource:
@@ -36,8 +27,9 @@ class TradesResource:
36
27
  ... trades.extend(result.data)
37
28
  """
38
29
 
39
- def __init__(self, http: HttpClient):
30
+ def __init__(self, http: HttpClient, base_path: str = "/v1"):
40
31
  self._http = http
32
+ self._base_path = base_path
41
33
 
42
34
  def _convert_timestamp(self, ts: Optional[Timestamp]) -> Optional[int]:
43
35
  """Convert timestamp to Unix milliseconds."""
@@ -64,7 +56,7 @@ class TradesResource:
64
56
  cursor: Optional[Timestamp] = None,
65
57
  limit: Optional[int] = None,
66
58
  side: Optional[Literal["buy", "sell"]] = None,
67
- ) -> CursorResponse:
59
+ ) -> CursorResponse[list[Trade]]:
68
60
  """
69
61
  Get trade history for a coin using cursor-based pagination.
70
62
 
@@ -95,7 +87,7 @@ class TradesResource:
95
87
  ... trades.extend(result.data)
96
88
  """
97
89
  data = self._http.get(
98
- f"/v1/trades/{coin.upper()}",
90
+ f"{self._base_path}/trades/{coin.upper()}",
99
91
  params={
100
92
  "start": self._convert_timestamp(start),
101
93
  "end": self._convert_timestamp(end),
@@ -118,14 +110,14 @@ class TradesResource:
118
110
  cursor: Optional[Timestamp] = None,
119
111
  limit: Optional[int] = None,
120
112
  side: Optional[Literal["buy", "sell"]] = None,
121
- ) -> CursorResponse:
113
+ ) -> CursorResponse[list[Trade]]:
122
114
  """
123
115
  Async version of list().
124
116
 
125
117
  Uses cursor-based pagination by default.
126
118
  """
127
119
  data = await self._http.aget(
128
- f"/v1/trades/{coin.upper()}",
120
+ f"{self._base_path}/trades/{coin.upper()}",
129
121
  params={
130
122
  "start": self._convert_timestamp(start),
131
123
  "end": self._convert_timestamp(end),
@@ -139,83 +131,6 @@ class TradesResource:
139
131
  next_cursor=data.get("meta", {}).get("next_cursor"),
140
132
  )
141
133
 
142
- def list_with_offset(
143
- self,
144
- coin: str,
145
- *,
146
- start: Timestamp,
147
- end: Timestamp,
148
- limit: Optional[int] = None,
149
- offset: Optional[int] = None,
150
- side: Optional[Literal["buy", "sell"]] = None,
151
- ) -> list[Trade]:
152
- """
153
- Get trade history using offset-based pagination.
154
-
155
- .. deprecated::
156
- Use list() with cursor-based pagination instead for better performance.
157
-
158
- Args:
159
- coin: The coin symbol (e.g., 'BTC', 'ETH')
160
- start: Start timestamp (required)
161
- end: End timestamp (required)
162
- limit: Maximum number of results
163
- offset: Number of results to skip
164
- side: Filter by trade side
165
-
166
- Returns:
167
- List of trades
168
- """
169
- warnings.warn(
170
- "list_with_offset() is deprecated. Use list() with cursor-based pagination instead.",
171
- DeprecationWarning,
172
- stacklevel=2,
173
- )
174
- data = self._http.get(
175
- f"/v1/trades/{coin.upper()}",
176
- params={
177
- "start": self._convert_timestamp(start),
178
- "end": self._convert_timestamp(end),
179
- "limit": limit,
180
- "offset": offset,
181
- "side": side,
182
- },
183
- )
184
- return [Trade.model_validate(item) for item in data["data"]]
185
-
186
- async def alist_with_offset(
187
- self,
188
- coin: str,
189
- *,
190
- start: Timestamp,
191
- end: Timestamp,
192
- limit: Optional[int] = None,
193
- offset: Optional[int] = None,
194
- side: Optional[Literal["buy", "sell"]] = None,
195
- ) -> list[Trade]:
196
- """
197
- Async version of list_with_offset().
198
-
199
- .. deprecated::
200
- Use alist() with cursor-based pagination instead.
201
- """
202
- warnings.warn(
203
- "alist_with_offset() is deprecated. Use alist() with cursor-based pagination instead.",
204
- DeprecationWarning,
205
- stacklevel=2,
206
- )
207
- data = await self._http.aget(
208
- f"/v1/trades/{coin.upper()}",
209
- params={
210
- "start": self._convert_timestamp(start),
211
- "end": self._convert_timestamp(end),
212
- "limit": limit,
213
- "offset": offset,
214
- "side": side,
215
- },
216
- )
217
- return [Trade.model_validate(item) for item in data["data"]]
218
-
219
134
  def recent(self, coin: str, limit: Optional[int] = None) -> list[Trade]:
220
135
  """
221
136
  Get most recent trades for a coin.
@@ -228,7 +143,7 @@ class TradesResource:
228
143
  List of recent trades
229
144
  """
230
145
  data = self._http.get(
231
- f"/v1/trades/{coin.upper()}/recent",
146
+ f"{self._base_path}/trades/{coin.upper()}/recent",
232
147
  params={"limit": limit},
233
148
  )
234
149
  return [Trade.model_validate(item) for item in data["data"]]
@@ -236,64 +151,7 @@ class TradesResource:
236
151
  async def arecent(self, coin: str, limit: Optional[int] = None) -> list[Trade]:
237
152
  """Async version of recent()."""
238
153
  data = await self._http.aget(
239
- f"/v1/trades/{coin.upper()}/recent",
154
+ f"{self._base_path}/trades/{coin.upper()}/recent",
240
155
  params={"limit": limit},
241
156
  )
242
157
  return [Trade.model_validate(item) for item in data["data"]]
243
-
244
- def list_cursor(
245
- self,
246
- coin: str,
247
- *,
248
- start: Timestamp,
249
- end: Timestamp,
250
- cursor: Optional[Timestamp] = None,
251
- limit: Optional[int] = None,
252
- side: Optional[Literal["buy", "sell"]] = None,
253
- ) -> CursorResponse:
254
- """
255
- Get trade history using cursor-based pagination.
256
-
257
- .. deprecated::
258
- Use list() instead - it now uses cursor-based pagination by default.
259
-
260
- Args:
261
- coin: The coin symbol (e.g., 'BTC', 'ETH')
262
- start: Start timestamp (required)
263
- end: End timestamp (required)
264
- cursor: Cursor from previous response's next_cursor (timestamp)
265
- limit: Maximum number of results
266
- side: Filter by trade side
267
-
268
- Returns:
269
- CursorResponse with trades and next_cursor for pagination
270
- """
271
- warnings.warn(
272
- "list_cursor() is deprecated. Use list() instead - it now uses cursor-based pagination by default.",
273
- DeprecationWarning,
274
- stacklevel=2,
275
- )
276
- return self.list(coin, start=start, end=end, cursor=cursor, limit=limit, side=side)
277
-
278
- async def alist_cursor(
279
- self,
280
- coin: str,
281
- *,
282
- start: Timestamp,
283
- end: Timestamp,
284
- cursor: Optional[Timestamp] = None,
285
- limit: Optional[int] = None,
286
- side: Optional[Literal["buy", "sell"]] = None,
287
- ) -> CursorResponse:
288
- """
289
- Async version of list_cursor().
290
-
291
- .. deprecated::
292
- Use alist() instead - it now uses cursor-based pagination by default.
293
- """
294
- warnings.warn(
295
- "alist_cursor() is deprecated. Use alist() instead - it now uses cursor-based pagination by default.",
296
- DeprecationWarning,
297
- stacklevel=2,
298
- )
299
- return await self.alist(coin, start=start, end=end, cursor=cursor, limit=limit, side=side)
oxarchive/types.py CHANGED
@@ -124,9 +124,6 @@ class Trade(BaseModel):
124
124
  start_position: Optional[str] = None
125
125
  """Position size before this trade."""
126
126
 
127
- source: Optional[Literal["s3", "ws", "api", "live"]] = None
128
- """Data source: 's3' (historical), 'api' (REST backfill), 'ws' (websocket), 'live' (real-time ingestion)."""
129
-
130
127
  user_address: Optional[str] = None
131
128
  """User's wallet address (for fill-level data)."""
132
129
 
@@ -143,7 +140,7 @@ class Trade(BaseModel):
143
140
 
144
141
 
145
142
  class Instrument(BaseModel):
146
- """Trading instrument specification."""
143
+ """Trading instrument specification (Hyperliquid)."""
147
144
 
148
145
  model_config = {"populate_by_name": True}
149
146
 
@@ -166,6 +163,53 @@ class Instrument(BaseModel):
166
163
  """Whether the instrument is currently tradeable."""
167
164
 
168
165
 
166
+ class LighterInstrument(BaseModel):
167
+ """Trading instrument specification (Lighter.xyz).
168
+
169
+ Lighter instruments have a different schema than Hyperliquid with more
170
+ detailed market configuration including fees and minimum amounts.
171
+ """
172
+
173
+ symbol: str
174
+ """Instrument symbol (e.g., BTC, ETH)."""
175
+
176
+ market_id: int
177
+ """Unique market identifier."""
178
+
179
+ market_type: str
180
+ """Market type (e.g., 'perp')."""
181
+
182
+ status: str
183
+ """Market status (e.g., 'active')."""
184
+
185
+ taker_fee: float
186
+ """Taker fee rate (e.g., 0.0005 = 0.05%)."""
187
+
188
+ maker_fee: float
189
+ """Maker fee rate (e.g., 0.0002 = 0.02%)."""
190
+
191
+ liquidation_fee: float
192
+ """Liquidation fee rate."""
193
+
194
+ min_base_amount: float
195
+ """Minimum order size in base currency."""
196
+
197
+ min_quote_amount: float
198
+ """Minimum order size in quote currency."""
199
+
200
+ size_decimals: int
201
+ """Size decimal precision."""
202
+
203
+ price_decimals: int
204
+ """Price decimal precision."""
205
+
206
+ quote_decimals: int
207
+ """Quote currency decimal precision."""
208
+
209
+ is_active: bool
210
+ """Whether the instrument is currently tradeable."""
211
+
212
+
169
213
  # =============================================================================
170
214
  # Funding Types
171
215
  # =============================================================================
@@ -338,6 +382,41 @@ class WsHistoricalData(BaseModel):
338
382
  data: dict[str, Any]
339
383
 
340
384
 
385
+ class OrderbookDelta(BaseModel):
386
+ """Orderbook delta for tick-level data."""
387
+
388
+ timestamp: int
389
+ """Timestamp in milliseconds."""
390
+
391
+ side: Literal["bid", "ask"]
392
+ """Side: 'bid' or 'ask'."""
393
+
394
+ price: float
395
+ """Price level."""
396
+
397
+ size: float
398
+ """New size (0 = level removed)."""
399
+
400
+ sequence: int
401
+ """Sequence number for ordering."""
402
+
403
+
404
+ class WsHistoricalTickData(BaseModel):
405
+ """Historical tick data (granularity='tick' mode) - checkpoint + deltas.
406
+
407
+ This message type is sent when using granularity='tick' for Lighter.xyz
408
+ orderbook data. It provides a full checkpoint followed by incremental deltas.
409
+ """
410
+
411
+ type: Literal["historical_tick_data"]
412
+ channel: WsChannel
413
+ coin: str
414
+ checkpoint: dict[str, Any]
415
+ """Initial checkpoint (full orderbook snapshot)."""
416
+ deltas: list[OrderbookDelta]
417
+ """Incremental deltas to apply after checkpoint."""
418
+
419
+
341
420
  # =============================================================================
342
421
  # WebSocket Bulk Stream Types (Bulk Download Mode)
343
422
  # =============================================================================
@@ -414,6 +493,21 @@ class OxArchiveError(Exception):
414
493
  return f"[{self.code}] {self.message}"
415
494
 
416
495
 
496
+ # =============================================================================
497
+ # Pagination Types
498
+ # =============================================================================
499
+
500
+
501
+ class CursorResponse(BaseModel, Generic[T]):
502
+ """Response with cursor for pagination."""
503
+
504
+ data: T
505
+ """The paginated data."""
506
+
507
+ next_cursor: Optional[str] = None
508
+ """Cursor for the next page (use as cursor parameter)."""
509
+
510
+
417
511
  # Type alias for timestamp parameters
418
512
  Timestamp = Union[int, str, datetime]
419
513
  """Timestamp can be Unix ms (int), ISO string, or datetime object."""
oxarchive/websocket.py CHANGED
@@ -31,17 +31,18 @@ from dataclasses import dataclass, field
31
31
  from typing import Any, Callable, Optional, Set, Union
32
32
 
33
33
  try:
34
- import websockets
35
- from websockets.client import WebSocketClientProtocol
34
+ from websockets.asyncio.client import connect as ws_connect, ClientConnection
35
+ from websockets.exceptions import ConnectionClosed
36
36
  from websockets.protocol import State as WsState
37
37
  except ImportError:
38
38
  raise ImportError(
39
- "WebSocket support requires the 'websockets' package. "
39
+ "WebSocket support requires the 'websockets' package (>=14.0). "
40
40
  "Install with: pip install oxarchive[websocket]"
41
41
  )
42
42
 
43
43
  from .types import (
44
44
  OrderBook,
45
+ OrderbookDelta,
45
46
  PriceLevel,
46
47
  Trade,
47
48
  WsChannel,
@@ -57,6 +58,7 @@ from .types import (
57
58
  WsReplayCompleted,
58
59
  WsReplayStopped,
59
60
  WsHistoricalData,
61
+ WsHistoricalTickData,
60
62
  WsStreamStarted,
61
63
  WsStreamProgress,
62
64
  WsHistoricalBatch,
@@ -110,6 +112,7 @@ ErrorHandler = Callable[[Exception], None]
110
112
 
111
113
  # Replay handlers
112
114
  HistoricalDataHandler = Callable[[str, int, dict], None]
115
+ HistoricalTickDataHandler = Callable[[str, dict, list[OrderbookDelta]], None] # coin, checkpoint, deltas
113
116
  ReplayStartHandler = Callable[[WsChannel, str, int, int, float], None] # channel, coin, start, end, speed
114
117
  ReplayCompleteHandler = Callable[[WsChannel, str, int], None] # channel, coin, snapshots_sent
115
118
 
@@ -147,7 +150,6 @@ def _transform_trade(coin: str, raw: dict) -> Trade:
147
150
  "closed_pnl": raw.get("closed_pnl") or raw.get("closedPnl"),
148
151
  "direction": raw.get("direction"),
149
152
  "start_position": raw.get("start_position") or raw.get("startPosition"),
150
- "source": raw.get("source"),
151
153
  "user_address": raw.get("user_address") or raw.get("userAddress"),
152
154
  "maker_address": raw.get("maker_address"),
153
155
  "taker_address": raw.get("taker_address"),
@@ -258,7 +260,7 @@ class OxArchiveWs:
258
260
  options: WebSocket connection options
259
261
  """
260
262
  self.options = options
261
- self._ws: Optional[WebSocketClientProtocol] = None
263
+ self._ws: Optional[ClientConnection] = None
262
264
  self._state: WsConnectionState = "disconnected"
263
265
  self._subscriptions: Set[str] = set()
264
266
  self._reconnect_attempts = 0
@@ -277,6 +279,7 @@ class OxArchiveWs:
277
279
 
278
280
  # Replay handlers (Option B)
279
281
  self._on_historical_data: Optional[HistoricalDataHandler] = None
282
+ self._on_historical_tick_data: Optional[HistoricalTickDataHandler] = None
280
283
  self._on_replay_start: Optional[ReplayStartHandler] = None
281
284
  self._on_replay_complete: Optional[ReplayCompleteHandler] = None
282
285
 
@@ -308,7 +311,8 @@ class OxArchiveWs:
308
311
  url = f"{self.options.ws_url}?apiKey={self.options.api_key}"
309
312
 
310
313
  try:
311
- self._ws = await websockets.connect(url)
314
+ # Increase max_size from default 1MB to 10MB for large Lighter orderbook data
315
+ self._ws = await ws_connect(url, max_size=10 * 1024 * 1024)
312
316
  self._reconnect_attempts = 0
313
317
  self._set_state("connected")
314
318
 
@@ -428,6 +432,7 @@ class OxArchiveWs:
428
432
  start: int,
429
433
  end: Optional[int] = None,
430
434
  speed: float = 1.0,
435
+ granularity: Optional[str] = None,
431
436
  ) -> None:
432
437
  """Start historical replay with timing preserved.
433
438
 
@@ -437,6 +442,7 @@ class OxArchiveWs:
437
442
  start: Start timestamp (Unix ms)
438
443
  end: End timestamp (Unix ms, defaults to now)
439
444
  speed: Playback speed multiplier (1 = real-time, 10 = 10x faster)
445
+ granularity: Data resolution for Lighter orderbook ('checkpoint', '30s', '10s', '1s', 'tick')
440
446
 
441
447
  Example:
442
448
  >>> await ws.replay("orderbook", "BTC", start=time.time()*1000 - 86400000, speed=10)
@@ -450,6 +456,8 @@ class OxArchiveWs:
450
456
  }
451
457
  if end is not None:
452
458
  msg["end"] = end
459
+ if granularity is not None:
460
+ msg["granularity"] = granularity
453
461
  await self._send(msg)
454
462
 
455
463
  async def replay_pause(self) -> None:
@@ -483,6 +491,7 @@ class OxArchiveWs:
483
491
  start: int,
484
492
  end: int,
485
493
  batch_size: int = 1000,
494
+ granularity: Optional[str] = None,
486
495
  ) -> None:
487
496
  """Start bulk streaming for fast data download.
488
497
 
@@ -492,18 +501,22 @@ class OxArchiveWs:
492
501
  start: Start timestamp (Unix ms)
493
502
  end: End timestamp (Unix ms)
494
503
  batch_size: Records per batch message
504
+ granularity: Data resolution for Lighter orderbook ('checkpoint', '30s', '10s', '1s', 'tick')
495
505
 
496
506
  Example:
497
507
  >>> await ws.stream("orderbook", "ETH", start=..., end=..., batch_size=1000)
498
508
  """
499
- await self._send({
509
+ msg = {
500
510
  "op": "stream",
501
511
  "channel": channel,
502
512
  "coin": coin,
503
513
  "start": start,
504
514
  "end": end,
505
515
  "batch_size": batch_size,
506
- })
516
+ }
517
+ if granularity is not None:
518
+ msg["granularity"] = granularity
519
+ await self._send(msg)
507
520
 
508
521
  async def stream_stop(self) -> None:
509
522
  """Stop the current bulk stream."""
@@ -548,6 +561,16 @@ class OxArchiveWs:
548
561
  """
549
562
  self._on_historical_data = handler
550
563
 
564
+ def on_historical_tick_data(self, handler: HistoricalTickDataHandler) -> None:
565
+ """Set handler for historical tick data (granularity='tick' mode).
566
+
567
+ This is for tick-level granularity on Lighter.xyz orderbook data.
568
+ Receives a checkpoint (full orderbook) followed by incremental deltas.
569
+
570
+ Handler receives: (coin, checkpoint, deltas)
571
+ """
572
+ self._on_historical_tick_data = handler
573
+
551
574
  def on_replay_start(self, handler: ReplayStartHandler) -> None:
552
575
  """Set handler for replay started event.
553
576
 
@@ -652,7 +675,7 @@ class OxArchiveWs:
652
675
  try:
653
676
  message = await self._ws.recv()
654
677
  self._handle_message(message)
655
- except websockets.ConnectionClosed as e:
678
+ except ConnectionClosed as e:
656
679
  logger.info(f"Connection closed: {e.code} {e.reason}")
657
680
  if self._on_close:
658
681
  self._on_close(e.code, e.reason)
@@ -723,6 +746,10 @@ class OxArchiveWs:
723
746
  elif msg_type == "historical_data" and self._on_historical_data:
724
747
  self._on_historical_data(data["coin"], data["timestamp"], data["data"])
725
748
 
749
+ elif msg_type == "historical_tick_data" and self._on_historical_tick_data:
750
+ msg = WsHistoricalTickData(**data)
751
+ self._on_historical_tick_data(msg.coin, msg.checkpoint, msg.deltas)
752
+
726
753
  elif msg_type == "replay_completed" and self._on_replay_complete:
727
754
  self._on_replay_complete(data["channel"], data["coin"], data["snapshots_sent"])
728
755