oxarchive 0.3.4__py3-none-any.whl → 0.4.3__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
  # =============================================================================
@@ -414,6 +458,21 @@ class OxArchiveError(Exception):
414
458
  return f"[{self.code}] {self.message}"
415
459
 
416
460
 
461
+ # =============================================================================
462
+ # Pagination Types
463
+ # =============================================================================
464
+
465
+
466
+ class CursorResponse(BaseModel, Generic[T]):
467
+ """Response with cursor for pagination."""
468
+
469
+ data: T
470
+ """The paginated data."""
471
+
472
+ next_cursor: Optional[str] = None
473
+ """Cursor for the next page (use as cursor parameter)."""
474
+
475
+
417
476
  # Type alias for timestamp parameters
418
477
  Timestamp = Union[int, str, datetime]
419
478
  """Timestamp can be Unix ms (int), ISO string, or datetime object."""
oxarchive/websocket.py CHANGED
@@ -31,12 +31,12 @@ 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
 
@@ -124,13 +124,36 @@ def _transform_trade(coin: str, raw: dict) -> Trade:
124
124
  """Transform raw Hyperliquid trade format to SDK Trade type.
125
125
 
126
126
  Raw WebSocket format: { coin, side, px, sz, time, hash, tid, users: [maker, taker] }
127
+ Historical replay format: { coin, side, price, size, time, hash, tradeId, userAddress, ... }
127
128
  SDK format: { coin, side, price, size, timestamp, tx_hash, trade_id, maker_address, taker_address }
128
129
  """
129
130
  from datetime import datetime
130
131
 
131
132
  # Check if already in SDK format (from REST API or historical replay)
132
133
  if "price" in raw and "size" in raw:
133
- return Trade(**raw)
134
+ # Map camelCase keys from WebSocket to snake_case for Pydantic
135
+ mapped = {
136
+ "coin": raw.get("coin", coin),
137
+ "side": raw.get("side", "B"),
138
+ "price": raw.get("price"),
139
+ "size": raw.get("size"),
140
+ "timestamp": raw.get("timestamp") or (datetime.utcfromtimestamp(raw.get("time", 0) / 1000).isoformat() + "Z" if raw.get("time") else None),
141
+ "tx_hash": raw.get("tx_hash") or raw.get("hash"),
142
+ "trade_id": raw.get("trade_id") or raw.get("tradeId"),
143
+ "order_id": raw.get("order_id") or raw.get("orderId"),
144
+ "crossed": raw.get("crossed"),
145
+ "fee": raw.get("fee"),
146
+ "fee_token": raw.get("fee_token") or raw.get("feeToken"),
147
+ "closed_pnl": raw.get("closed_pnl") or raw.get("closedPnl"),
148
+ "direction": raw.get("direction"),
149
+ "start_position": raw.get("start_position") or raw.get("startPosition"),
150
+ "user_address": raw.get("user_address") or raw.get("userAddress"),
151
+ "maker_address": raw.get("maker_address"),
152
+ "taker_address": raw.get("taker_address"),
153
+ }
154
+ # Remove None values to let Pydantic use defaults
155
+ mapped = {k: v for k, v in mapped.items() if v is not None}
156
+ return Trade(**mapped)
134
157
 
135
158
  # Transform from Hyperliquid raw format
136
159
  time_ms = raw.get("time")
@@ -234,7 +257,7 @@ class OxArchiveWs:
234
257
  options: WebSocket connection options
235
258
  """
236
259
  self.options = options
237
- self._ws: Optional[WebSocketClientProtocol] = None
260
+ self._ws: Optional[ClientConnection] = None
238
261
  self._state: WsConnectionState = "disconnected"
239
262
  self._subscriptions: Set[str] = set()
240
263
  self._reconnect_attempts = 0
@@ -284,7 +307,7 @@ class OxArchiveWs:
284
307
  url = f"{self.options.ws_url}?apiKey={self.options.api_key}"
285
308
 
286
309
  try:
287
- self._ws = await websockets.connect(url)
310
+ self._ws = await ws_connect(url)
288
311
  self._reconnect_attempts = 0
289
312
  self._set_state("connected")
290
313
 
@@ -628,7 +651,7 @@ class OxArchiveWs:
628
651
  try:
629
652
  message = await self._ws.recv()
630
653
  self._handle_message(message)
631
- except websockets.ConnectionClosed as e:
654
+ except ConnectionClosed as e:
632
655
  logger.info(f"Connection closed: {e.code} {e.reason}")
633
656
  if self._on_close:
634
657
  self._on_close(e.code, e.reason)