From 81fbef7bd9781c4b7e3ddfb206a460cdd72cc81f Mon Sep 17 00:00:00 2001 From: 0xFantomMenace <93462858+0xFantomMenace@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:33:24 +0000 Subject: [PATCH 1/4] API version 2026-10-01: error codes, has_more, capabilities, side and triggered filters, L4 replay on every product Every REST request sends 0xArchive-Version: 2026-10-01 and the WebSocket client connects with version=2026-10-01. The SDK parses the shapes this version selects: the standard envelope on data quality, the public coverage summary and /v1/symbols; RFC 3339 times with integer *_ms companions on CVD, the HIP-3 oracle, Lighter liquidations and volume, and levels snapshots; and the live message shapes on Lighter and Robinhood Chain replay. - OxArchiveError exposes error_code, status, param, valid_values and the whole error body; WsError exposes error_code. ERROR_CODES and ErrorCode export the public set, including slow_consumer and endpoint_unsupported. - CursorResponse.has_more; iterators stop when it is false. Every paged method returns meta, with symbol and venue on per-symbol routes; records returned on their own carry it as response_meta. - client.capabilities() and acapabilities() for GET /v1/capabilities, and data_quality.status_coverage() for /v1/status/coverage. - side on every trades method (all venues), triggered on order history, depth on full-depth L2 history. - WebSocket: WS_CHANNELS mirrors /v1/capabilities. HIP-3, HIP-4 and spot L4 and both full-depth channels replay (bulk, single-channel); channels without replay or live data are refused before sending. on_replay_data() decodes Lighter replay records with the live parsers. - Aliases for consistent verbs: trades.history(), spot.twap.history(), hip4.get_price_history(). Existing names keep working. - The venue is named Lighter throughout. --- .gitignore | 2 + CHANGELOG.md | 144 ++- README.md | 344 ++++--- oxarchive/__init__.py | 43 +- oxarchive/client.py | 43 +- oxarchive/exchanges.py | 70 +- oxarchive/http.py | 40 +- oxarchive/resources/breadth.py | 7 +- oxarchive/resources/candles.py | 8 +- oxarchive/resources/cvd.py | 18 +- oxarchive/resources/data_quality.py | 50 +- oxarchive/resources/funding.py | 10 +- oxarchive/resources/instruments.py | 20 +- oxarchive/resources/l2_orderbook.py | 21 +- oxarchive/resources/l3_orderbook.py | 10 +- oxarchive/resources/l4_orderbook.py | 6 +- oxarchive/resources/liquidations.py | 16 +- oxarchive/resources/openinterest.py | 22 +- oxarchive/resources/oracle.py | 10 +- oxarchive/resources/orderbook.py | 22 +- oxarchive/resources/orders.py | 35 +- oxarchive/resources/outcomes.py | 20 +- oxarchive/resources/positions.py | 4 +- oxarchive/resources/spot.py | 25 +- oxarchive/resources/symbols.py | 6 +- oxarchive/resources/trades.py | 124 ++- oxarchive/resources/wallets.py | 6 +- oxarchive/types.py | 625 +++++++++++-- oxarchive/websocket.py | 372 ++++++-- pyproject.toml | 2 +- tests/test_api_contract.py | 1271 ++++++++++++++++++++++++++ tests/test_api_contract_live.py | 233 +++++ tests/test_coverage_contract.py | 6 +- tests/test_rh_lighter.py | 2 +- tests/test_spec_fixes.py | 19 +- tests/test_surface_parity.py | 6 +- tests/test_websocket_capabilities.py | 22 +- tests/test_websocket_full_depth.py | 16 +- 38 files changed, 3189 insertions(+), 511 deletions(-) create mode 100644 tests/test_api_contract.py create mode 100644 tests/test_api_contract_live.py diff --git a/.gitignore b/.gitignore index 9e55087..9fb810b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ tests/* !tests/test_webhooks.py !tests/test_webhook_signature.py !tests/test_surface_parity.py +!tests/test_api_contract.py +!tests/test_api_contract_live.py # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e46d055..ce66f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,98 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm Builds on 1.11.0. Two venues, Hyperliquid and Lighter; Lighter now has two deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain -(`client.rh_lighter`). +(`client.rh_lighter`). The SDK adopts API version 2026-10-01: stable error +codes, `has_more` on every paged response, the symbol and venue of every +per-symbol response, `client.capabilities()`, and replay for every L4 and +full-depth channel. ### Added +- API version 2026-10-01. Every REST request, sync and async, sends + `0xArchive-Version: 2026-10-01` (`oxarchive.API_VERSION`, + `oxarchive.API_VERSION_HEADER`), and `OxArchiveWs` connects with + `version=2026-10-01`. The SDK parses the shapes this version selects: + - Data quality responses, the public coverage summary and + `/v1/symbols` answer with the standard `{success, data, meta}` envelope; + `client.data_quality.*` and `client.symbols.list()` return the same + models as before. + - Record times that were integer milliseconds are RFC 3339 strings with an + integer companion in a field ending `_ms`. `CvdBucket`, + `Hip3OracleExternalPrice`, `Hip3OracleDiscoveryBounds`, + `LighterLiquidation` and `LighterLiquidationVolume` have `timestamp` as a + UTC `datetime` and `timestamp_ms` as Unix milliseconds. + `LiquidationLevels`, `LiquidationLevelsHistoryItem` and + `TriggerLevelsHistoryItem` add `snapshot_ts_ms` (`snapshot_ts` is RFC + 3339). In an L4 snapshot from `l4_orderbook.get()` (a `dict`), each + resting order's `timestamp` is an RFC 3339 string with `timestamp_ms` + beside it; an unknown queue time is `None` with `timestamp_ms` 0. + - Lighter and Robinhood Chain replay messages have the live shapes: books + `{coin, time, levels}`, a trade message holds a list with one leg, and + open interest and funding carry `{coin, ctx}`. +- Error codes. `OxArchiveError` exposes `error_code`, the stable code from the + error body (for example `invalid_parameter`, `invalid_symbol`, + `range_before_coverage`, `unsupported_for_venue`), with `status` (the HTTP + status, the same value as `code`), `request_id`, `param`, `valid_values` + and `details` (the whole error body, including route-specific fields such + as `available_on`). A body without a code, such as a proxy error page, gets + one derived from the status. The public set is `oxarchive.ERROR_CODES` and + the `ErrorCode` type; `WEBSOCKET_ERROR_CODES` names the two WebSocket-only + codes, `slow_consumer` and `endpoint_unsupported`. `WsError` has + `error_code`. `str()` of an error includes the code. +- Pagination state. `CursorResponse.has_more` comes from `meta.has_more`; on + a route that does not send it, it is true exactly when `next_cursor` is + set. `ResponseMeta.has_more` is typed. The iterators (`cvd.iterate()` and + the positions `iterate_*()` helpers, sync and async) stop when `has_more` + is false. +- The symbol and venue of a response. `ResponseMeta.symbol` (the canonical + public symbol) and `ResponseMeta.venue` (`Venue`: `hyperliquid`, `hip3`, + `hip4`, `spot`, `lighter` or `rh-lighter`; `VENUES` lists them). Every + paged method now returns `meta`. A record a method returns on its own + (order book, funding rate, open interest, instrument, spot pair, + freshness, summary, liquidation and trigger levels, HIP-3 oracle, HIP-4 + outcome and question, breadth snapshot, wallet classification and the + data quality results) carries the response's meta as `response_meta`. +- `client.capabilities()` and `acapabilities()` for `GET /v1/capabilities`: + one `Capability` per venue and datatype, with its REST routes, WebSocket + channels, `live`, `replay`, `available_from`, `cadence`, `page_limit`, + `intervals` and `notes`. +- `client.data_quality.status_coverage()` and `astatus_coverage()` for the + public coverage summary at `/v1/status/coverage`. +- Filters the API now applies: + - `side="buy"` or `side="sell"` (`TradeSide`) on `trades.list()`, + `trades.history()` and `trades.recent()`, and their async versions, on + every venue. The API filters before paging, so a full page holds `limit` + matching trades. + - `triggered` on `orders.history()` for Hyperliquid core, HIP-3 and HIP-4. + - `depth` on `l2_orderbook.history()` (Hyperliquid core and HIP-3). + `depth` on `orderbook.history()` applies on every venue, including HIP-3, + HIP-4 and spot. +- Consistent verbs, as aliases of the existing methods: + `trades.history()` and `ahistory()` (the same methods as `list()` and + `alist()`), `client.spot.twap.history()` and `ahistory()` (`by_symbol()` + and `aby_symbol()`), and `client.hyperliquid.hip4.get_price_history()` and + `aget_price_history()` (`get_prices()` and `aget_prices()`). Every existing + name keeps working. +- WebSocket replay for every L4 channel (`hip3_l4_diffs`, `hip3_l4_orders`, + `hip4_l4_diffs`, `hip4_l4_orders`, `spot_l4_diffs`, `spot_l4_orders`, as + well as `l4_diffs` and `l4_orders`) and for the full-depth L2 channels + `orderbook_full` and `hip3_orderbook_full`. These replays are bulk: one + `l4_snapshot` from the nearest checkpoint at or before `start`, then + ordered `l4_batch` messages; `speed` is ignored, `replay.seek` is refused, + and each replays on its own. +- `oxarchive.websocket.WS_CHANNELS`: one `WsChannelSpec` per channel (venue, + datatype, `live`, `replay`, `bulk_replay`), mirroring `/v1/capabilities`, + with `LIVE_CHANNELS`, `REPLAY_CHANNELS`, `BULK_REPLAY_CHANNELS` and + `L4_REPLAY_CHANNELS`. `subscribe()` refuses a channel without live data + (now also `candles` and `hip3_candles`), `replay()` a channel without + replay (`ticker`, `all_tickers`, `spot_orderbook`, `spot_trades`, + `spot_twap`), and `multi_replay()` also the bulk channels, each with + `ValueError` before anything is sent. +- `OxArchiveWs.on_replay_data()`: every replayed record with its channel, + `(channel, coin, timestamp, record)`. Lighter and Robinhood Chain records + are decoded with the live parsers (`OrderBook`, `list[Trade]`, + `LighterMarketContextUpdate`); `decode_lighter_payload()` applies the same + decoding to any payload. `WsSubscribed.version`, `WsReplayStarted.version` + and `WsReplayStarted.symbol` are typed. - Lighter on Robinhood Chain: `client.rh_lighter` (`RhLighterClient`), served under `/v1/rh-lighter`. It has the same resources as `client.lighter` except the L3 order book and the L1 account resolver: `instruments`, `orderbook`, @@ -75,11 +164,10 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain - `ResponseMeta` and `CursorResponse.meta`: the response's `meta`, typed, with `finalized_through`, `requested_end`, `clamped_to`, `coverage_from`, `notice`, and the new optional fields `as_of`, `snapshot_ts`, `source`, - `quality`, `stale`, `totals` and `built_through`. Set on account positions, - `trades.list()` and Lighter liquidations; `None` elsewhere. Unknown fields - are kept. `trades.recent()` still returns a plain list, so the - `preliminary_row_count` the API sends on the Lighter `/recent` response is - not surfaced in this release. + `quality`, `stale`, `totals` and `built_through`. Set on every paged + method. Unknown fields are kept. `trades.recent()` still returns a plain + list, so the `preliminary_row_count` the API sends on the Lighter + `/recent` response is not surfaced in this release. - Live Lighter on Robinhood Chain WebSocket channels `rh_lighter_orderbook`, `rh_lighter_trades`, `rh_lighter_open_interest` and `rh_lighter_funding`, with the same message shapes as the mainnet `lighter_*` channels, served on @@ -166,18 +254,29 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain `SymbolEntry` per market across every venue family, with data types and coverage dates. - Full-depth L2 WebSocket channels `orderbook_full` (Hyperliquid core) and - `hip3_orderbook_full` (HIP-3) for live subscription, with helpers - `subscribe_orderbook_full()`, `subscribe_hip3_orderbook_full()` and their - `unsubscribe_*` counterparts, and `FULL_DEPTH_L2_CHANNELS` in - `oxarchive.websocket`. A subscription opens with an `l4_snapshot` of every - price level, then `l4_batch` messages of changed levels, delivered to - `on_l4_snapshot()` and `on_l4_batch()`. They are live-only: `replay()` - and `multi_replay()` reject them with `ValueError` - (`FULL_DEPTH_LIVE_ONLY_ERROR`) before anything is sent. Stored full-depth - history is on REST `l2_orderbook.history()` and `diffs()`. + `hip3_orderbook_full` (HIP-3), with helpers `subscribe_orderbook_full()`, + `subscribe_hip3_orderbook_full()` and their `unsubscribe_*` counterparts, + and `FULL_DEPTH_L2_CHANNELS` in `oxarchive.websocket`. A subscription or a + replay opens with an `l4_snapshot` of every price level, then `l4_batch` + messages of changed levels, delivered to `on_l4_snapshot()` and + `on_l4_batch()`. Stored full-depth history is also on REST + `l2_orderbook.history()` and `diffs()`. - `client.lighter.l3_orderbook.get()` and `history()` take `account`. ### Changed +- The venue is named "Lighter" throughout docstrings, messages and the + README (was "Lighter.xyz"). +- `client.hyperliquid.trades.recent()` still refuses before sending (the API + does not serve it for Hyperliquid core), now with + `error_code == "unsupported_for_venue"` and a message that names + `trades.history()` and the venues that serve `recent()`. +- HIP-3, HIP-4 and spot L4 channels are no longer refused by `replay()`. + `L4_LIVE_ONLY_CHANNELS` is empty and `L4_LIVE_ONLY_ERROR` is no longer + raised; both are deprecated and kept so imports keep working. +- Docstrings and the README list live and replay per channel as + `/v1/capabilities` does: `hip3_open_interest`, `hip3_funding`, + `hip4_orderbook` and `hip4_open_interest` stream live as well as replay, + and `hip3_candles` is replay-only. - `WsChannel` includes the five `rh_lighter_*` channels, `orderbook_full` and `hip3_orderbook_full`. Before, the acknowledgement, snapshot and batch messages of the two full-depth channels failed to parse. @@ -192,10 +291,11 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain base class; its paths and behavior are unchanged. - Parameters the API never applied are removed, and passing one now raises `TypeError` before anything is sent instead of returning unfiltered data: - `side` on `trades.list()`; `user`, `status` and `order_type` on + `user`, `status`, `order_type` and `triggered` on `client.spot.orders.history()` (core, HIP-3 and HIP-4 order history keep - them); `depth` on `l2_orderbook.history()`, `l4_orderbook.history()` and - `lighter.l3_orderbook.history()` (it still applies to `get()`). + them); `depth` on `l4_orderbook.history()` and + `lighter.l3_orderbook.history()` (it still applies to `get()`); and + `granularity` on `lighter.l3_orderbook.history()`. - `client.hyperliquid.hip3.breadth.history()` and `ahistory()` accept `interval="1m"`. The API serves 1-minute buckets on breadth, open interest, funding, price and liquidation-volume history for every venue. @@ -255,6 +355,14 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain request. ### Documentation +- README sections on the API version, pagination with `has_more`, response + metadata, capabilities and error codes; the trades, orders, L2, L3, L4 and + WebSocket replay sections describe the filters and replays above. The + channel tables mirror `/v1/capabilities`. +- The README's spot coverage row lists each dataset's first date: L4 and + order history from 2026-03-10 (L4 replay from 2026-03-11 01:03 UTC), order + book from 2026-05-05, TWAP from May 2026. It said all of them started in + May 2026. - The README's HIP-3 coverage row lists each dataset's first date, replacing "February 2026+": trades and oracle prices from 2025-10-13, candles and liquidations from 2025-12-22, order book, funding and open interest from diff --git a/README.md b/README.md index 83a76dd..4811fa0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ client = Client(api_key="0xa_your_api_key") hl_orderbook = client.hyperliquid.orderbook.get("BTC") print(f"Hyperliquid BTC mid price: {hl_orderbook.mid_price}") -# Lighter.xyz uses its own venue client +# Lighter uses its own venue client lighter_orderbook = client.lighter.orderbook.get("BTC") print(f"Lighter BTC mid price: {lighter_orderbook.mid_price}") @@ -83,11 +83,13 @@ history = client.hyperliquid.orderbook.history( | Hyperliquid | April 2023+ | Core perpetuals; coverage varies by schema and route. | | Hyperliquid HIP-3 | Trades and oracle prices from 2025-10-13; candles and liquidations from 2025-12-22; order book, funding, and OI from 2026-02-16; L4 and order history from 2026-03-10 | Builder perps; funding and OI update at roughly 10 seconds. | | Hyperliquid HIP-4 | May 2026+ | Outcome markets. Candles and outcome-side OI are served from 2026-05-02; OI updates at ~10s. No funding or liquidations. | -| Hyperliquid Spot | Trades and candles from 2025-03-22; candle coverage starts exactly 2025-03-22T10:50:22Z; orderbook, L4, TWAP, and orders from 2026-05 | 326 authenticated inventory rows using dashed canonical symbols (`HYPE-USDC`, `PURR-USDC`). Candle intervals are 1m/5m/15m/30m/1h/4h/1d/1w with a 1,000-row page cap and numeric timestamp-string cursors; pass each `next_cursor` back unchanged. No funding/OI/liquidations. | -| Lighter.xyz | Observed global per-fill trade floor January 17, 2025; exact starts vary by market. L3 from March 5, 2026+ | Maker/taker trade context; L3 caps at 250 orders per side; funding/OI update at ~10s. | +| Hyperliquid Spot | Trades and candles from 2025-03-22; candle coverage starts exactly 2025-03-22T10:50:22Z; L4 and order history from 2026-03-10 (L4 replay from 2026-03-11 01:03 UTC); order book from 2026-05-05; TWAP from 2026-05 | Dashed canonical symbols (`HYPE-USDC`, `PURR-USDC`); `client.spot.pairs.list()` lists the pairs. Candle intervals are 1m/5m/15m/30m/1h/4h/1d/1w with a 1,000-row page cap and numeric timestamp-string cursors; pass each `next_cursor` back unchanged. No funding/OI/liquidations. | +| Lighter | Observed global per-fill trade floor January 17, 2025; exact starts vary by market. L3 from March 5, 2026+ | Maker/taker trade context; L3 caps at 250 orders per side; funding/OI update at ~10s. | | Lighter on Robinhood Chain | Trades and liquidations from 2026-06-26 20:10:26 UTC (venue launch); order book, OI, and funding from 2026-08-22 18:43 UTC | The second Lighter deployment. 84 USDG-quoted markets: 57 perps (`BTC`) and 27 spot (`AAPL-USDG`). Candles from 2026-06-26 once enabled. No L3. | | Account positions | Hyperliquid change log from 2025-05-25, HIP-3 from 2025-10-13, hourly history from 2026-06-07; Lighter mainnet from 2025-01-17, Robinhood Chain from 2026-06-26 | Live snapshots every 5 minutes (Hyperliquid, HIP-3) or 2 minutes (Lighter). See [Account Positions](#account-positions). | +`client.capabilities()` returns the first served instant of every datatype on every venue, and `client.symbols.list()` the coverage of each symbol. See [Capabilities](#capabilities). + ## Async Support All methods have async versions prefixed with `a`: @@ -103,7 +105,7 @@ async def main(): orderbook = await client.hyperliquid.orderbook.aget("BTC") print(f"BTC mid price: {orderbook.mid_price}") - # Async get (Lighter.xyz) + # Async get (Lighter) lighter_ob = await client.lighter.orderbook.aget("BTC") # Don't forget to close the client @@ -137,9 +139,61 @@ environment variable. client = Client() ``` +### API Version + +Every request sends `0xArchive-Version: 2026-10-01` (`oxarchive.API_VERSION`), and WebSocket connections select the same version. The version fixes the response shapes the SDK parses: every route answers with the `{success, data, meta}` envelope, record times are RFC 3339 strings with an integer companion in fields ending `_ms`, errors carry a stable `error_code`, and Lighter replay messages have the live shapes. The API echoes the version in the `0xArchive-Version` response header. + +## Pagination + +Paged methods return a `CursorResponse` with `data`, `has_more`, `next_cursor` and `meta`. While `has_more` is `True`, pass `next_cursor` back unchanged as `cursor`, with the other arguments unchanged. Stop when `has_more` is `False`: a page can be short, or even empty, and still not be the last, so stop on `has_more` rather than on the page size. + +```python +page = client.hyperliquid.trades.history("BTC", start="2026-09-01", end="2026-09-02", limit=1000) +trades = list(page.data) +while page.has_more: + page = client.hyperliquid.trades.history( + "BTC", start="2026-09-01", end="2026-09-02", limit=1000, cursor=page.next_cursor + ) + trades.extend(page.data) +``` + +The iterators (`cvd.iterate()`, `positions.iterate_history()` and the other `iterate_*` helpers) follow the cursor for you and stop when `has_more` is `False`. + +## Response Metadata + +`CursorResponse.meta` is the response's `meta`: `request_id`, `count`, `has_more`, and on per-symbol routes `symbol` (the canonical public symbol) and `venue` (`"hyperliquid"`, `"hip3"`, `"hip4"`, `"spot"`, `"lighter"` or `"rh-lighter"`). A record that a method returns on its own, such as an order book, a funding rate, an instrument or a market summary, carries the same meta as `response_meta`. + +```python +book = client.hyperliquid.hip3.orderbook.get("xyz:TSLA") +print(book.response_meta.venue, book.response_meta.symbol) # hip3 xyz:TSLA + +page = client.spot.trades.history("HYPE-USDC", start="2026-09-01", end="2026-09-02") +print(page.meta.venue, page.meta.symbol, page.meta.request_id) +``` + +Methods that return a raw `dict` (the full-depth L2, L3 and L4 snapshots) or a plain list do not carry it. + +## Capabilities + +`client.capabilities()` lists what each venue serves, one `Capability` per venue and datatype: the REST routes and WebSocket channels, whether the datatype streams live (`live`) and replays (`replay`), the first served instant (`available_from`), its cadence, the largest page (`page_limit`) and the accepted intervals. The route needs no API key and costs no credits. + +```python +rows = client.capabilities() +for row in rows: + if row.venue == "spot": + print(row.datatype, row.live, row.replay, row.available_from, row.page_limit) + +replayable = {channel for row in rows if row.replay for channel in row.ws_channels} + +# Async version +rows = await client.acapabilities() +``` + +Per-symbol coverage is on `client.symbols.list()`. The WebSocket client checks subscriptions and replays against the same table (see [Available Channels](#available-channels)). + ## REST API Reference -All examples use `client.hyperliquid.*` but the same methods are available on `client.lighter.*` for Lighter.xyz data. +All examples use `client.hyperliquid.*` but the same methods are available on `client.lighter.*` for Lighter data. ### Order Book @@ -147,7 +201,7 @@ All examples use `client.hyperliquid.*` but the same methods are available on `c # Get current order book (Hyperliquid) orderbook = client.hyperliquid.orderbook.get("BTC") -# Get current order book (Lighter.xyz) +# Get current order book (Lighter) orderbook = client.lighter.orderbook.get("BTC") # Get order book at specific timestamp @@ -177,13 +231,13 @@ hip3_ob = await client.hyperliquid.hip3.orderbook.aget("km:US500") #### Orderbook Depth -The `depth` parameter is route-specific. Hyperliquid-family native L2 is capped at 20 levels per side. Lighter native L2 includes all served levels, and Lighter L3 is capped at 250 orders per side. +The `depth` parameter is route-specific. Hyperliquid-family native L2 is capped at 20 levels per side. Lighter native L2 includes all served levels, and Lighter L3 is capped at 250 orders per side. `depth` applies to `get()` and to `history()` on every venue (Hyperliquid core, HIP-3, HIP-4, spot and Lighter), and to the full-depth `l2_orderbook.get()` and `l2_orderbook.history()`. **Note:** Hyperliquid native L2 source data contains 20 levels per side. Dedicated L2 routes derived from L4 return all served levels where supported. Lighter native L2 also includes all served levels; Lighter L3 returns up to 250 individual resting orders per side. #### Lighter Orderbook Granularity -Lighter.xyz orderbook history supports a `granularity` parameter for different data resolutions. +Lighter order book history (mainnet and Robinhood Chain) supports a `granularity` parameter for different data resolutions. | Granularity | Interval | Credit Multiplier | |-------------|----------|-------------------| @@ -306,16 +360,16 @@ async for snapshot in client.lighter.orderbook.aiterate_tick_history("BTC", star ### Trades -The trades API uses cursor-based pagination for efficient retrieval of large datasets. +`trades.history()` pages the trade tape of a window with a cursor. `trades.list()` is the same method under its earlier name and keeps working. ```python # Get trade history with cursor-based pagination -result = client.hyperliquid.trades.list("ETH", start="2024-01-01", end="2024-01-02", limit=1000) -trades = result.data +result = client.hyperliquid.trades.history("ETH", start="2024-01-01", end="2024-01-02", limit=1000) +trades = list(result.data) # Paginate through all results -while result.next_cursor: - result = client.hyperliquid.trades.list( +while result.has_more: + result = client.hyperliquid.trades.history( "ETH", start="2024-01-01", end="2024-01-02", @@ -324,25 +378,27 @@ while result.next_cursor: ) trades.extend(result.data) -# The API does not filter trades by side; filter the returned rows -buys = [t for t in result.data if t.side == "B"] +# One side of the tape: side="buy" (side == "B") or side="sell" (side == "A"). +# The API filters before paging, so a full page holds `limit` matching trades. +buys = client.hyperliquid.trades.history("ETH", start="2024-01-01", end="2024-01-02", side="buy") -# Get recent trades (Lighter and HIP-3 - have real-time data) +# Get recent trades (HIP-3, HIP-4, spot and Lighter), optionally one side recent = client.lighter.trades.recent("BTC", limit=100) +recent_sells = client.spot.trades.recent("HYPE-USDC", limit=100, side="sell") # HIP-3 recent trades (case-sensitive coins) hip3_recent = client.hyperliquid.hip3.trades.recent("km:US500", limit=100) # HIP-3 trade history -hip3_trades = client.hyperliquid.hip3.trades.list("km:US500", start="2026-09-01", end="2026-09-02") +hip3_trades = client.hyperliquid.hip3.trades.history("km:US500", start="2026-09-01", end="2026-09-02") # Async versions -result = await client.hyperliquid.trades.alist("ETH", start=..., end=...) +result = await client.hyperliquid.trades.ahistory("ETH", start=..., end=...) recent = await client.lighter.trades.arecent("BTC", limit=100) hip3_recent = await client.hyperliquid.hip3.trades.arecent("km:US500", limit=100) ``` -**Note:** The `recent()` method is available for Lighter.xyz and HIP-3 (both have real-time data ingestion). Hyperliquid does not have a recent trades endpoint - use `list()` with a time range instead. +**Note:** `recent()` is available on HIP-3, HIP-4, spot and both Lighter deployments. The API does not serve it for Hyperliquid core, so `client.hyperliquid.trades.recent()` raises `OxArchiveError` with `error_code == "unsupported_for_venue"` before sending; use `history()` with a time range there. ### Instruments @@ -359,7 +415,7 @@ instruments = await client.hyperliquid.instruments.alist() btc = await client.hyperliquid.instruments.aget("BTC") ``` -#### Lighter.xyz Instruments +#### Lighter Instruments Lighter instruments have a different schema with additional fields for fees, market IDs, and minimum order amounts: @@ -423,7 +479,7 @@ history = client.hyperliquid.hip3.breadth.history( interval="5m", # 1m, 5m, 15m, 30m, 1h, 4h, 1d limit=1000, ) -while history.next_cursor: +while history.has_more: history = client.hyperliquid.hip3.breadth.history( start="2026-08-28T00:00:00Z", end="2026-08-29T00:00:00Z", @@ -458,7 +514,7 @@ price = await client.hyperliquid.hip3.oracle.aexternal_price("km:US500") bounds = await client.hyperliquid.hip3.oracle.adiscovery_bounds("km:US500") ``` -`external_price` and `mark_price` are `None` when the market has none. The discovery bounds are `reference_price` times one minus and one plus `bound_fraction`, where the reference is the external price when available and the mark price otherwise (`reference_source`) and the fraction follows from the market's max leverage. The full ratcheted range can be wider when a deployer's reset configuration applies. `timestamp` is Unix milliseconds. +`external_price` and `mark_price` are `None` when the market has none. The discovery bounds are `reference_price` times one minus and one plus `bound_fraction`, where the reference is the external price when available and the mark price otherwise (`reference_source`) and the fraction follows from the market's max leverage. The full ratcheted range can be wider when a deployer's reset configuration applies. `timestamp` is a UTC `datetime`, and `timestamp_ms` the same instant in Unix milliseconds. #### HIP-4 Outcome Markets @@ -511,7 +567,7 @@ summary = client.hyperliquid.hip4.get_summary("0") # mark_price is in Hyperliquid spot pairs live at `/v1/hyperliquid/spot` and are accessible via `client.spot`. Symbols use dashed canonical form (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format (`PURR/USDC` or `@107`) internally. Spot has **no funding, no open interest, or liquidations**. Candle history is served at `/v1/hyperliquid/spot/candles/{symbol}` from exactly `2025-03-22T10:50:22Z`, supports `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, and accepts a maximum of 1,000 rows per page with numeric timestamp-string cursors; pass each `next_cursor` back unchanged. -Trade history goes back to 2025-03-22. Orderbook, L4, TWAP, and order lifecycle are live-only from 2026-05-05. +Trade history goes back to 2025-03-22. L4 and the order lifecycle are served from 2026-03-10, the order book from 2026-05-05 and TWAP statuses from May 2026; `client.symbols.list()` has each pair's exact starts. ```python # Pair discovery @@ -549,8 +605,8 @@ checkpoints = client.spot.l4_orderbook.history("HYPE-USDC", start=..., end=...) # L4 order lifecycle orders = client.spot.orders.history("HYPE-USDC", start=..., end=...) -# TWAP statuses, by symbol or by user wallet -twap_pair = client.spot.twap.by_symbol("HYPE-USDC", start=..., end=...) +# TWAP statuses, by symbol (history() is the same method as by_symbol()) or by user wallet +twap_pair = client.spot.twap.history("HYPE-USDC", start=..., end=...) twap_user = client.spot.twap.by_user("0xabc...", start=..., end=...) # Per-dataset freshness lag: orderbook, trades, l4_diffs, l4_checkpoints, @@ -712,7 +768,7 @@ liquidations = client.hyperliquid.liquidations.history( # Paginate through all results all_liquidations = list(liquidations.data) -while liquidations.next_cursor: +while liquidations.has_more: liquidations = client.hyperliquid.liquidations.history( "BTC", start="2025-06-01", @@ -755,6 +811,7 @@ lighter_liquidations = client.lighter.liquidations.history( limit=1000 ) for liq in lighter_liquidations.data: + # timestamp is a UTC datetime; timestamp_ms is the same instant in Unix milliseconds print(liq.timestamp, liq.price, liq.size, liq.usd_amount, liq.ask_account, liq.bid_account) # Robinhood Chain liquidations start at the venue launch, 2026-06-26 20:10:26 UTC. @@ -817,7 +874,7 @@ print(f"Trades last updated: {freshness.trades.last_updated}, lag: {freshness.tr print(f"Funding last updated: {freshness.funding.last_updated}") print(f"OI last updated: {freshness.open_interest.last_updated}") -# Lighter.xyz +# Lighter lighter_freshness = client.lighter.get_freshness("BTC") # Lighter on Robinhood Chain @@ -848,7 +905,7 @@ print(f"24h liquidation volume: ${summary.liquidation_volume_24h}") print(f" Long: ${summary.long_liquidation_volume_24h}") print(f" Short: ${summary.short_liquidation_volume_24h}") -# Lighter.xyz (price, funding, OI; no volume/liquidation data) +# Lighter (price, funding, OI; no volume/liquidation data) lighter_summary = client.lighter.get_summary("BTC") # Lighter on Robinhood Chain (same shape as mainnet Lighter) @@ -880,7 +937,7 @@ prices = client.hyperliquid.get_price_history( for snapshot in prices.data: print(f"{snapshot.timestamp}: mark={snapshot.mark_price}, oracle={snapshot.oracle_price}, mid={snapshot.mid_price}") -# Lighter.xyz +# Lighter lighter_prices = client.lighter.get_price_history("BTC", start="2026-01-01", end="2026-01-02", interval="1h") # Lighter on Robinhood Chain @@ -891,7 +948,7 @@ hip3_prices = client.hyperliquid.hip3.get_price_history("km:US500", start="2026- # Paginate for larger ranges result = client.hyperliquid.get_price_history("BTC", start=..., end=..., interval="4h", limit=1000) -while result.next_cursor: +while result.has_more: result = client.hyperliquid.get_price_history( "BTC", start=..., end=..., interval="4h", cursor=result.next_cursor, limit=1000 @@ -923,13 +980,13 @@ for candle in candles.data: # Cursor-based pagination for large datasets result = client.hyperliquid.candles.history("BTC", start=..., end=..., interval="1m", limit=1000) -while result.next_cursor: +while result.has_more: result = client.hyperliquid.candles.history( "BTC", start=..., end=..., interval="1m", cursor=result.next_cursor, limit=1000 ) -# Lighter.xyz candles +# Lighter candles lighter_candles = client.lighter.candles.history( "BTC", start="2024-01-01", @@ -996,9 +1053,10 @@ page = client.hyperliquid.cvd.history( for bucket in page.data: print(bucket.timestamp, bucket.buy_volume, bucket.sell_volume, bucket.delta) -# Follow next_cursor with the same start, end and interval. Stop on the -# cursor, not on a short page: below 1h a page can be short and still carry one. -while page.next_cursor: +# Follow next_cursor with the same start, end and interval while has_more is +# True. Stop on has_more, not on a short page: below 1h a page can be short +# and still not be the last. +while page.has_more: page = client.hyperliquid.cvd.history( "BTC", start="2026-09-01T00:00:00Z", @@ -1023,11 +1081,13 @@ async for bucket in client.hyperliquid.hip3.cvd.aiterate("km:US500", start="2026 ... ``` -Buckets are labelled by their open time in UTC (`timestamp`, Unix milliseconds) and omitted when they hold no trades. `4h`, `1d` and `1w` buckets open on UTC epoch boundaries, so `1w` buckets open on Thursdays, and at those widths the first and last bucket of a window can be partial. `cumulative_delta` restarts on every page: to join pages, rebuild the running total from `delta`. A response that is one page of several says so in `meta.notice`. +Buckets are labelled by their open time (`timestamp`, a UTC `datetime`; `timestamp_ms` in Unix milliseconds) and omitted when they hold no trades. `4h`, `1d` and `1w` buckets open on UTC epoch boundaries, so `1w` buckets open on Thursdays, and at those widths the first and last bucket of a window can be partial. `cumulative_delta` restarts on every page: to join pages, rebuild the running total from `delta`. A response that is one page of several says so in `meta.notice`. ### L4 Orderbook (Order-Level) -Get L4 order-level orderbook data with user attribution. Available for Hyperliquid and HIP-3. +Get L4 order-level orderbook data with user attribution. Available for Hyperliquid core, HIP-3, HIP-4 (`client.hyperliquid.hip4.l4_orderbook`) and spot (`client.spot.l4_orderbook`). + +In a snapshot from `get()`, each resting order's `timestamp` is the time it joined the queue, as an RFC 3339 string, with `timestamp_ms` the same instant in Unix milliseconds. When the queue time is unknown, `timestamp` is `None` and `timestamp_ms` is `0`. ```python # Get current L4 orderbook snapshot (Hyperliquid) @@ -1073,7 +1133,9 @@ hip3_snapshot = await client.hyperliquid.hip3.l4_orderbook.aget("km:US500") | `diffs(symbol, *, start, end, cursor, limit)` | Get L4 orderbook diffs (order-level changes) | | `history(symbol, *, start, end, cursor, limit)` | Get L4 orderbook history (full checkpoints; `depth` applies to `get()` only) | -### L3 Orderbook (Lighter.xyz Only) +History replays over the WebSocket too, on every product; see [L4 and Full-Depth Replay](#l4-and-full-depth-replay). + +### L3 Orderbook (Lighter Only) Get Lighter L3 individual order-level snapshots from March 5, 2026, capped at 250 orders per side. @@ -1093,13 +1155,12 @@ history = client.lighter.l3_orderbook.history( "BTC", start="2026-03-05", end="2026-03-06", - granularity="checkpoint", # checkpoint (default), 30s, 10s, 1s, tick account=281474976710654, # optional: one account's orders limit=100 ) # Paginate through results -while history.next_cursor: +while history.has_more: history = client.lighter.l3_orderbook.history( "BTC", start="2026-03-05", @@ -1118,7 +1179,7 @@ history = await client.lighter.l3_orderbook.ahistory("BTC", start=..., end=...) | Method | Description | |--------|-------------| | `get(symbol, *, timestamp, depth, account)` | Get an L3 snapshot, up to 250 orders per side | -| `history(symbol, *, start, end, cursor, limit, account)` | Get L3 history from March 5, 2026, up to 250 orders per side | +| `history(symbol, *, start, end, cursor, limit, account)` | Get L3 history from March 5, 2026, up to 250 orders per side (no `depth` or `granularity`) | ### L2 Orderbook (Full-Depth) @@ -1129,8 +1190,9 @@ Get L2 full-depth orderbook derived from L4 data. Available for Hyperliquid and l2 = client.hyperliquid.l2_orderbook.get("BTC") l2_historical = client.hyperliquid.l2_orderbook.get("BTC", timestamp=1711900800000) -# L2 orderbook history +# L2 orderbook history, optionally limited to the top levels of each snapshot l2_history = client.hyperliquid.l2_orderbook.history("BTC", start=start, end=end) +l2_top = client.hyperliquid.l2_orderbook.history("BTC", start=start, end=end, depth=50) # L2 tick-level diffs l2_diffs = client.hyperliquid.l2_orderbook.diffs("BTC", start=start, end=end) @@ -1149,12 +1211,12 @@ l2_diffs = await client.hyperliquid.l2_orderbook.adiffs("BTC", start=..., end=.. | Method | Description | |--------|-------------| | `get(symbol, *, timestamp, depth)` | Get L2 full-depth orderbook snapshot | -| `history(symbol, *, start, end, cursor, limit)` | Get L2 orderbook history (every level; `depth` applies to `get()` only) | +| `history(symbol, *, start, end, cursor, limit, depth)` | Get L2 orderbook history (every level, or the top `depth` levels per side) | | `diffs(symbol, *, start, end, cursor, limit)` | Get L2 tick-level diffs | ### Orders (L4 Order History) -Get L4 order history, order flow aggregation, and TP/SL data. Available for Hyperliquid and HIP-3. +Get L4 order history, order flow aggregation, and TP/SL data. Available for Hyperliquid core, HIP-3 and HIP-4; spot serves order history only. ```python # Get order history @@ -1175,6 +1237,11 @@ result = client.hyperliquid.orders.history( order_type="limit" ) +# Only orders whose trigger fired (triggered=False excludes them) +fired = client.hyperliquid.orders.history( + "BTC", start="2026-09-01", end="2026-09-02", triggered=True +) + # Get order flow aggregation: one page of time buckets flow = client.hyperliquid.orders.flow( "BTC", @@ -1184,8 +1251,8 @@ flow = client.hyperliquid.orders.flow( ) buckets = list(flow.data) # A page holds up to `limit` buckets (default 1000, max 10000); follow the -# cursor with the same start, end and interval until it is None -while flow.next_cursor: +# cursor with the same start, end and interval while has_more is True +while flow.has_more: flow = client.hyperliquid.orders.flow( "BTC", start="2026-07-13T00:00:00Z", @@ -1220,11 +1287,11 @@ hip3_orders = await client.hyperliquid.hip3.orders.ahistory("km:US500", start=.. | Method | Description | |--------|-------------| -| `history(symbol, *, start, end, user, status, order_type, cursor, limit)` | Get order history | +| `history(symbol, *, start, end, user, status, order_type, triggered, cursor, limit)` | Get order history | | `flow(symbol, *, start, end, interval, cursor, limit)` | Get order flow aggregation, one page of buckets | | `tpsl(symbol, *, start, end, user, triggered, cursor, limit)` | Get TP/SL history | -Hyperliquid core and HIP-3 also have `trigger_levels()` and `trigger_levels_history()`. HIP-4 serves `history()`, `flow()` and `tpsl()`. Spot serves `history(symbol, *, start, end, cursor, limit)` only, without user, status or order-type filters. +Hyperliquid core and HIP-3 also have `trigger_levels()` and `trigger_levels_history()`. HIP-4 serves `history()`, `flow()` and `tpsl()`. Spot serves `history(symbol, *, start, end, cursor, limit)` only, without user, status, order-type or triggered filters. ### Account Positions @@ -1442,6 +1509,7 @@ coverage = await client.data_quality.acoverage() |--------|-------------| | `status()` | Overall system health and per-exchange status | | `coverage()` | Data coverage summary for venue APIs | +| `status_coverage()` | The same summary from the public `/v1/status/coverage` route | | `exchange_coverage(exchange)` | Coverage details for a venue scope (`hyperliquid`, `hip3`, `hip4`, `spot`, `lighter`, `rh-lighter`) | | `symbol_coverage(exchange, symbol, *, from_time, to_time)` | Coverage with gap detection, cadence, and historical coverage. The symbol is sent as given, URL-encoded (`km:US500`, `HYPE-USDC`, `#0`) | | `list_incidents(...)` | List incidents with filtering and pagination | @@ -1888,10 +1956,12 @@ The WebSocket client supports live subscriptions for supported Hyperliquid and L > WebSocket bulk streaming has been discontinued. For large dataset downloads, use the S3 Parquet bulk export in the [Data Catalog](https://www.0xarchive.io/data). The `stream()`, `multi_stream()`, and `stream_stop()` methods remain for compatibility but are deprecated: each call emits a `DeprecationWarning`, and the server answers with an error message (a `WsError` on `on_message`) instead of data. The `on_batch`, `on_stream_start`, `on_stream_progress`, and `on_stream_complete` handler setters are deprecated too: setting one emits a `DeprecationWarning`, and the handler is never called. -> Lighter supports live subscriptions on `lighter_orderbook`, `lighter_trades`, `lighter_open_interest`, and `lighter_funding` at `wss://api.0xarchive.io/ws` (the client default). `lighter_candles` and `lighter_l3_orderbook` remain replay-only. All six Lighter channels support historical replay. +> Lighter supports live subscriptions on `lighter_orderbook`, `lighter_trades`, `lighter_open_interest`, and `lighter_funding` at `wss://api.0xarchive.io/ws` (the client default). `lighter_candles` and `lighter_l3_orderbook` remain replay-only. All six Lighter channels support historical replay, and replay messages have the same shapes as the live ones. > Lighter on Robinhood Chain supports live subscriptions on `rh_lighter_orderbook`, `rh_lighter_trades`, `rh_lighter_open_interest`, and `rh_lighter_funding` at `wss://api.0xarchive.io/ws` only, with the same message shapes as the mainnet Lighter channels. `rh_lighter_candles` is replay-only. All five channels support historical replay. +Every connection selects API version `2026-10-01` (the `version` connection parameter). Server errors arrive on `on_message` as `WsError` messages with a stable `error_code` (see [Error Handling](#error-handling)): for example `unsupported_for_venue` when a channel does not offer the requested mode, `slow_consumer` when the connection fell behind and messages were dropped, and `endpoint_unsupported` when the endpoint does not serve the channel. + ```python import asyncio from oxarchive import OxArchiveWs, WsOptions @@ -1942,7 +2012,7 @@ Hyperliquid `open_interest` and `funding` also stream live, in addition to histo ### Full-Depth L2 Book -`orderbook_full` (Hyperliquid core) and `hip3_orderbook_full` (HIP-3) stream every price level of the book, not just the top 20, aggregated from the order-level book. A subscription opens with an `l4_snapshot` of the whole book, followed by `l4_batch` messages of changed levels. Receive them with `on_l4_snapshot` and `on_l4_batch`; the `channel` argument tells them apart from the L4 channels. These channels are live-only; stored full-depth history is on REST `l2_orderbook.history()` and `l2_orderbook.diffs()`. +`orderbook_full` (Hyperliquid core) and `hip3_orderbook_full` (HIP-3) stream every price level of the book, not just the top 20, aggregated from the order-level book. A subscription opens with an `l4_snapshot` of the whole book, followed by `l4_batch` messages of changed levels. Receive them with `on_l4_snapshot` and `on_l4_batch`; the `channel` argument tells them apart from the L4 channels. Stored history replays over the same messages (see [L4 and Full-Depth Replay](#l4-and-full-depth-replay)) and is also served over REST by `l2_orderbook.history()` and `l2_orderbook.diffs()`. ```python book = {"B": {}, "A": {}} @@ -1969,7 +2039,7 @@ ws.subscribe_hip3_orderbook_full("km:US500") # case-sensitive Each level is `{"px", "sz", "n"}` with numeric values; a changed level also carries `side` (`"B"` or `"A"`) and `bn`, the block it was applied in. The snapshot's `data` also holds `bid_count`, `ask_count`, `total_bid_size`, `total_ask_size`, `mid_price`, `spread`, `spread_bps` and `is_crossed`, and `last_block_number` on the message is the block the snapshot reflects. -### Live Lighter.xyz Data +### Live Lighter Data Live Lighter messages use the same `data` envelope as Hyperliquid live data. Symbols are the ones returned by `client.lighter.instruments.list()`; they are case-insensitive on subscribe and echoed uppercase. Live Lighter data is served at `wss://api.0xarchive.io/ws`, the client default; `wss://stream.0xarchive.io/ws` does not serve Lighter channels and answers a Lighter subscribe with an error pointing to `wss://api.0xarchive.io/ws`. It is available on every plan and metered per message like Hyperliquid live data, with the same per-plan subscription and connection limits and the limit of 10 subscribe operations per second. @@ -1988,8 +2058,8 @@ def on_context(channel, coin, ctx): print(f"Lighter {coin} OI {ctx.open_interest} funding {ctx.funding_rate} mark {ctx.mark_price}") def on_message(msg): - if isinstance(msg, WsError): # includes lag notices - print(f"Server: {msg.message}") + if isinstance(msg, WsError): # lag notices carry error_code "slow_consumer" + print(f"Server: {msg.error_code}: {msg.message}") async def main(): ws = OxArchiveWs(WsOptions(api_key="0xa_your_api_key")) @@ -2034,7 +2104,7 @@ The generic form works too: `ws.subscribe("lighter_orderbook", "BTC", interval_m | `prev_day_price` | `prevDayPx` | Derived from the last trade price and Lighter's 24h percent change | | `impact_prices` | `impactPxs` | Always `None` (Lighter has no impact prices) | -**Falling behind**: if your connection falls behind `lighter_trades`, `lighter_open_interest`, or `lighter_funding`, the server sends an error notice (for example `Dropped ~N live lighter_trades messages for BTC: ...`) and continues. If the lag persists, it stops that subscription with `Stopped the lighter_trades stream for BTC: your connection is too slow to keep up. Re-subscribe to resume.`; call the subscribe method again to resume. `lighter_orderbook` always sends the newest book; a book skipped between intervals loses nothing because each book is a full state. +**Falling behind**: if your connection falls behind `lighter_trades`, `lighter_open_interest`, or `lighter_funding`, the server sends an error notice with `error_code == "slow_consumer"` (for example `Dropped ~N live lighter_trades messages for BTC: ...`) and continues. If the lag persists, it stops that subscription with `Stopped the lighter_trades stream for BTC: your connection is too slow to keep up. Re-subscribe to resume.`; call the subscribe method again to resume. `lighter_orderbook` always sends the newest book; a book skipped between intervals loses nothing because each book is a full state. ### Live Lighter on Robinhood Chain Data @@ -2097,8 +2167,8 @@ async def main(): speed=10 # Optional, defaults to 1x ) - # Lighter.xyz replay with granularity. Replay rows keep their historical - # shapes, which differ from the live Lighter messages. + # Lighter replay with granularity. Replay messages have the same shapes + # as the live Lighter messages. await ws.replay( "lighter_orderbook", "BTC", start=int(time.time() * 1000) - 86400000, @@ -2121,9 +2191,27 @@ async def main(): asyncio.run(main()) ``` -#### Hyperliquid Core L4 Replay +`on_replay_data` receives every replayed record together with its channel: `(channel, coin, timestamp, record)`. Lighter and Robinhood Chain records are decoded with the same parsers as live messages: an `OrderBook` on `lighter_orderbook`, a `list[Trade]` (one leg per message) on `lighter_trades` and a `LighterMarketContextUpdate` on `lighter_open_interest` and `lighter_funding`, and likewise for the `rh_lighter_*` channels. Other channels' records are passed through as sent. `decode_lighter_payload(channel, coin, data)` applies the same decoding to a `replay_snapshot`. -Historical replay for Hyperliquid core `l4_diffs` and `l4_orders` begins with one typed `WsL4Snapshot` message and continues with ordered `WsL4Batch` messages. Apply each batch in the order received; use the snapshot's `last_block_number` with each event's block/sequence fields as the checkpoint boundary. The dedicated `on_l4_snapshot` and `on_l4_batch` callbacks keep their raw payload shapes; `on_message` receives the typed envelopes. +```python +def on_record(channel, coin, timestamp, record): + if channel == "lighter_trades": + for leg in record: # list[Trade] + print(timestamp, leg.side, leg.price, leg.size, leg.account_index) + elif channel == "lighter_orderbook": + print(timestamp, record.mid_price) # OrderBook + +ws.on_replay_data(on_record) +await ws.replay("lighter_trades", "BTC", start=start_ms, end=end_ms, speed=10) +``` + +Replay only runs on channels that replay (see [Available Channels](#available-channels)): `replay()` and `multi_replay()` raise `ValueError` before sending for a live-only channel such as `ticker`, `spot_orderbook` or `spot_trades`. + +#### L4 and Full-Depth Replay + +Every L4 channel replays: `l4_diffs` and `l4_orders` (Hyperliquid core), `hip3_l4_diffs` and `hip3_l4_orders`, `hip4_l4_diffs` and `hip4_l4_orders`, and `spot_l4_diffs` and `spot_l4_orders`. So do the full-depth L2 channels `orderbook_full` and `hip3_orderbook_full`. Replay is available from each product's first L4 checkpoint (`available_from` in `client.capabilities()`; a coin listed later starts at its own first checkpoint). + +These replays are bulk. A replay begins with one typed `WsL4Snapshot` from the nearest checkpoint at or before `start` and continues with ordered `WsL4Batch` messages until `end`, as fast as the connection takes them: `speed` is ignored, `replay_seek()` is refused (restart the replay at the new start instead), and each replays on its own, never inside `multi_replay()`. Apply each batch in the order received; use the snapshot's `last_block_number` with each event's block/sequence fields as the checkpoint boundary. Full-depth batches carry changed price levels `{"side", "px", "sz", "n", "bn"}`, exactly like the live channel. The dedicated `on_l4_snapshot` and `on_l4_batch` callbacks keep their raw payload shapes; `on_message` receives the typed envelopes. ```python from oxarchive import WsL4Batch, WsL4Snapshot @@ -2138,11 +2226,12 @@ def on_message(message): ws.on_message(on_message) -await ws.replay("l4_diffs", "BTC", start=..., end=..., speed=10) +await ws.replay("l4_diffs", "BTC", start=..., end=...) +await ws.replay("hip3_l4_orders", "xyz:TSLA", start=..., end=...) +await ws.replay("spot_l4_diffs", "PURR-USDC", start=..., end=...) +await ws.replay("orderbook_full", "ETH", start=..., end=...) ``` -HIP-3, HIP-4, and Hyperliquid Spot L4 channels remain live-only. They accept live subscriptions and do not accept historical replay. - ### Gap Detection During historical replay, the server automatically detects gaps in the data and notifies the client. This helps identify periods where data may be missing. @@ -2195,6 +2284,15 @@ ws = OxArchiveWs(WsOptions( ### Available Channels +The tables below mirror `client.capabilities()`. The same table ships in the SDK as `oxarchive.websocket.WS_CHANNELS`: `subscribe()` raises `ValueError` before sending for a channel without live data, `replay()` for a channel without replay, and `multi_replay()` also for the bulk L4 and full-depth channels, which replay on their own. + +```python +from oxarchive.websocket import WS_CHANNELS + +spec = WS_CHANNELS["spot_l4_diffs"] +print(spec.venue, spec.live, spec.replay, spec.bulk_replay) # spot True True True +``` + #### Hyperliquid Channels | Channel | Description | Requires Coin | Live Subscription | Historical Replay | @@ -2207,11 +2305,11 @@ ws = OxArchiveWs(WsOptions( | `funding` | Funding rate records | Yes | Yes | Yes | | `ticker` | Price and 24h volume | Yes | Yes | No | | `all_tickers` | All market tickers | No | Yes | No | -| `l4_diffs` | L4 orderbook diffs with user attribution | Yes | Yes | Yes | -| `l4_orders` | Order lifecycle events with user attribution | Yes | Yes | Yes | -| `orderbook_full` | Full-depth L2 order book: every price level, then changed levels | Yes | Yes | No | +| `l4_diffs` | L4 orderbook diffs with user attribution | Yes | Yes | Yes (bulk) | +| `l4_orders` | Order lifecycle events with user attribution | Yes | Yes | Yes (bulk) | +| `orderbook_full` | Full-depth L2 order book: every price level, then changed levels | Yes | Yes | Yes (bulk) | -Only Hyperliquid core `l4_diffs` and `l4_orders` support historical L4 replay. Their sequence is `l4_snapshot` followed by ordered `l4_batch` events. HIP-3, HIP-4, and Hyperliquid Spot L4 remain live-only. +Bulk replays open with an `l4_snapshot` and continue with ordered `l4_batch` messages; see [L4 and Full-Depth Replay](#l4-and-full-depth-replay). > **Note:** ``liquidations`` and ``hip3_liquidations`` now stream live. Each item shares the trades wire shape (a fill row with ``is_liquidation: true``). The SDK exposes a typed ``on_liquidations`` callback that decodes them into :class:`Liquidation` records. @@ -2221,15 +2319,15 @@ Only Hyperliquid core `l4_diffs` and `l4_orders` support historical L4 replay. T |---------|-------------|---------------|-------------------|-------------------| | `hip3_orderbook` | HIP-3 L2 order book snapshots | Yes | Yes | Yes | | `hip3_trades` | HIP-3 trade/fill updates | Yes | Yes | Yes | -| `hip3_candles` | HIP-3 OHLCV candle data | Yes | Yes | Yes | -| `hip3_open_interest` | HIP-3 open interest snapshots | Yes | No | Yes | -| `hip3_funding` | HIP-3 funding rate records | Yes | No | Yes | +| `hip3_candles` | HIP-3 OHLCV candle data | Yes | No | Yes | +| `hip3_open_interest` | HIP-3 open interest snapshots | Yes | Yes | Yes | +| `hip3_funding` | HIP-3 funding rate records | Yes | Yes | Yes | | `hip3_liquidations` | HIP-3 liquidation events (2025-12-22+) | Yes | Yes | Yes | -| `hip3_l4_diffs` | HIP-3 L4 orderbook diffs | Yes | Yes | No | -| `hip3_l4_orders` | HIP-3 order lifecycle events | Yes | Yes | No | -| `hip3_orderbook_full` | HIP-3 full-depth L2 order book: every price level, then changed levels | Yes | Yes | No | +| `hip3_l4_diffs` | HIP-3 L4 orderbook diffs | Yes | Yes | Yes (bulk) | +| `hip3_l4_orders` | HIP-3 order lifecycle events | Yes | Yes | Yes (bulk) | +| `hip3_orderbook_full` | HIP-3 full-depth L2 order book: every price level, then changed levels | Yes | Yes | Yes (bulk) | -> **Note:** `orderbook_full` and `hip3_orderbook_full` are live-only; `replay()` and `multi_replay()` reject them with `ValueError` before anything is sent. Stored full-depth history is served over REST by `l2_orderbook.history()` and `l2_orderbook.diffs()`. +> **Note:** Stored full-depth history is also served over REST by `l2_orderbook.history()` and `l2_orderbook.diffs()`. > **Note:** HIP-3 coins are case-sensitive (e.g., `km:US500`, `xyz:XYZ100`). Do not uppercase them. @@ -2237,13 +2335,13 @@ Only Hyperliquid core `l4_diffs` and `l4_orders` support historical L4 replay. T | Channel | Description | Requires Coin | Live Subscription | Historical Replay | |---------|-------------|---------------|-------------------|-------------------| -| `hip4_orderbook` | HIP-4 L2 order book snapshots | Yes | No | Yes | +| `hip4_orderbook` | HIP-4 L2 order book snapshots | Yes | Yes | Yes | | `hip4_trades` | HIP-4 trade/fill updates | Yes | Yes | Yes | -| `hip4_open_interest` | HIP-4 per-side OI ticks | Yes | No | Yes | -| `hip4_l4_diffs` | HIP-4 L4 orderbook diffs | Yes | Yes | No | -| `hip4_l4_orders` | HIP-4 order lifecycle events | Yes | Yes | No | +| `hip4_open_interest` | HIP-4 per-side OI ticks | Yes | Yes | Yes | +| `hip4_l4_diffs` | HIP-4 L4 orderbook diffs | Yes | Yes | Yes (bulk) | +| `hip4_l4_orders` | HIP-4 order lifecycle events | Yes | Yes | Yes (bulk) | -HIP-4 has no funding or liquidation channels. HIP-4 candles and current outcome-side OI are available over REST; the live HIP-4 order-book and OI bridges are paused, while stored replay remains available. This HIP-4 channel set has no dedicated candle channel. Subscribe with the raw ``#N`` coin form (e.g. ``"#0"``); the SDK passes it through unmodified in the JSON body. When a market settles, the server pushes a single ``outcome_settled`` frame and proactively unsubscribes the client from every ``hip4_*`` channel for that coin. Use :py:meth:`OxArchiveWs.on_outcome_settled` to handle the event: +HIP-4 has no funding or liquidation channels, and no candle channel: HIP-4 candles are served over REST. Subscribe with the raw ``#N`` coin form (e.g. ``"#0"``); the SDK passes it through unmodified in the JSON body. When a market settles, the server pushes a single ``outcome_settled`` frame and proactively unsubscribes the client from every ``hip4_*`` channel for that coin. Use :py:meth:`OxArchiveWs.on_outcome_settled` to handle the event: ```python def on_settled(msg): @@ -2262,8 +2360,8 @@ ws.subscribe_hip4_trades("#0") | `spot_orderbook` | Spot L2 order book snapshots | Yes | Yes | No | | `spot_trades` | Spot trade/fill updates | Yes | Yes | No | | `spot_twap` | Spot TWAP status updates | Yes | Yes | No | -| `spot_l4_diffs` | Spot L4 orderbook diffs | Yes | Yes | No | -| `spot_l4_orders` | Spot L4 order lifecycle events | Yes | Yes | No | +| `spot_l4_diffs` | Spot L4 orderbook diffs | Yes | Yes | Yes (bulk) | +| `spot_l4_orders` | Spot L4 order lifecycle events | Yes | Yes | Yes (bulk) | > **Note:** Spot symbols are dashed canonical (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format internally. The existing `on_orderbook` and `on_trades` typed callbacks fire for `spot_orderbook` and `spot_trades`. @@ -2275,7 +2373,7 @@ ws.subscribe_spot_orderbook("HYPE-USDC") ws.subscribe_spot_trades("HYPE-USDC") ``` -#### Lighter.xyz Channels +#### Lighter Channels | Channel | Description | Requires Coin | Live Subscription | Historical Replay | |---------|-------------|---------------|-------------------|-------------------| @@ -2286,7 +2384,7 @@ ws.subscribe_spot_trades("HYPE-USDC") | `lighter_funding` | Lighter funding rates (live: market context, same message as `lighter_open_interest`) | Yes | Yes | Yes | | `lighter_l3_orderbook` | Lighter L3 order-level orderbook | Yes | No | Yes | -Live Lighter subscriptions are served at `wss://api.0xarchive.io/ws`. Replay of all six channels keeps its historical row shapes, which differ from the live messages described under Live Lighter.xyz Data above. Current Lighter data is also available through the REST resources, and historical Lighter data through REST, WebSocket replay, or exports. +Live Lighter subscriptions are served at `wss://api.0xarchive.io/ws`. Replay messages of `lighter_orderbook`, `lighter_trades`, `lighter_open_interest` and `lighter_funding` have the shapes of the live messages described under Live Lighter Data above (a replayed trade message holds one leg); `on_replay_data` decodes them. Current Lighter data is also available through the REST resources, and historical Lighter data through REST, WebSocket replay, or exports. #### Lighter on Robinhood Chain Channels @@ -2298,7 +2396,7 @@ Live Lighter subscriptions are served at `wss://api.0xarchive.io/ws`. Replay of | `rh_lighter_open_interest` | Open interest (live: market context, same message as `rh_lighter_funding`) | Yes | Yes | Yes (from 2026-08-22) | | `rh_lighter_funding` | Funding rates (live: market context, same message as `rh_lighter_open_interest`) | Yes | Yes | Yes (from 2026-08-22) | -There is no L3 channel for this deployment. Live Robinhood Chain subscriptions are served at `wss://api.0xarchive.io/ws` only. Replay rows keep their historical shapes, like mainnet Lighter replay, and a multi-channel replay must stay within the `rh_lighter_*` family. +There is no L3 channel for this deployment. Live Robinhood Chain subscriptions are served at `wss://api.0xarchive.io/ws` only. Replay messages have the live shapes, like mainnet Lighter replay, and a multi-channel replay must stay within the `rh_lighter_*` family. #### Candle Replay @@ -2312,7 +2410,7 @@ await ws.replay( interval="15m" # 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w ) -# Lighter.xyz candles +# Lighter candles await ws.replay( "lighter_candles", "BTC", start=int(time.time() * 1000) - 86400000, @@ -2345,7 +2443,7 @@ await ws.replay( #### Open Interest / Funding Replay -The Hyperliquid `open_interest` and `funding` channels support both replay and live subscriptions (see Real-time Streaming above). The `hip3_open_interest` and `hip3_funding` channels are **historical only** (replay). They do not support real-time subscriptions. `lighter_open_interest` and `lighter_funding` support both replay and live subscriptions (see Live Lighter.xyz Data above), as do `rh_lighter_open_interest` and `rh_lighter_funding`. +The Hyperliquid `open_interest` and `funding` channels support both replay and live subscriptions (see Real-time Streaming above), as do `hip3_open_interest` and `hip3_funding`. `lighter_open_interest` and `lighter_funding` support both replay and live subscriptions (see Live Lighter Data above), as do `rh_lighter_open_interest` and `rh_lighter_funding`. ```python # Replay open interest at 50x speed @@ -2375,7 +2473,7 @@ await ws.replay( ### Multi-Channel Replay -Replay multiple channels in a single synchronized timeline. All data is interleaved by timestamp, preserving the original timing relationships between orderbook updates, trades, funding rates, and open interest. Before the timeline begins, `replay_snapshot` messages provide the initial state for each channel. +Replay multiple channels in a single synchronized timeline. All data is interleaved by timestamp, preserving the original timing relationships between orderbook updates, trades, funding rates, and open interest. Before the timeline begins, `replay_snapshot` messages provide the initial state for each channel. All channels must belong to one venue, and the bulk L4 and full-depth channels replay on their own, so `multi_replay()` raises `ValueError` for them before sending. ```python import asyncio @@ -2395,21 +2493,12 @@ async def main(): elif channel == "open_interest": print(f" OI: {data.get('open_interest')}") - # Handle interleaved timeline data - def on_data(coin, timestamp, data): - # The 'channel' field on the raw message tells you which channel - # this record belongs to. Use on_message() for full access. - print(f" {timestamp}: {data}") - - # Full message handler to see the channel field - def on_message(msg): - if hasattr(msg, 'type') and msg.type == "historical_data": - channel = msg.channel - print(f"[{channel}] {msg.coin} @ {msg.timestamp}") + # Handle interleaved timeline data, with the channel each record belongs to + def on_record(channel, coin, timestamp, record): + print(f"[{channel}] {coin} @ {timestamp}: {record}") ws.on_replay_snapshot(on_snapshot) - ws.on_historical_data(on_data) - ws.on_message(on_message) + ws.on_replay_data(on_record) ws.on_replay_start(lambda ch, coin, start, end, speed: print(f"Multi-channel replay started at {speed}x") @@ -2447,7 +2536,7 @@ await ws.multi_replay( speed=10, ) -# Lighter.xyz: orderbook + trades + OI + funding +# Lighter: orderbook + trades + OI + funding await ws.multi_replay( ["lighter_orderbook", "lighter_trades", "lighter_open_interest", "lighter_funding"], "BTC", @@ -2498,17 +2587,60 @@ client.hyperliquid.orderbook.history( ## Error Handling +Every error the API returns raises `OxArchiveError`. Branch on `error_code`, a stable machine-readable code, rather than on the message: + ```python from oxarchive import Client, OxArchiveError client = Client(api_key="0xa_your_api_key") try: - orderbook = client.orderbook.get("INVALID") + trades = client.hyperliquid.trades.history("BTC", start="2026-09-01", end="2026-09-02", side="both") except OxArchiveError as e: - print(f"API Error: {e.message}") - print(f"Status Code: {e.code}") - print(f"Request ID: {e.request_id}") + print(e.error_code) # "invalid_parameter" + print(e.status) # 400 (also e.code) + print(e.param) # "side" + print(e.valid_values) # ["buy", "sell"] + print(e.message) # "Invalid side 'both'. Use buy or sell." + print(e.request_id) # quote it when contacting support + print(e.details) # the whole error body, including route-specific fields +``` + +| `error_code` | Meaning | +|--------------|---------| +| `invalid_parameter` | A parameter failed to parse or validate; `param` and `valid_values` say which and what it accepts | +| `invalid_symbol` | The symbol is not listed on this venue | +| `invalid_interval` | The interval is not one the route accepts | +| `invalid_cursor` | The cursor is malformed, stale, or was issued for other filters | +| `invalid_time_range` | `start` is after `end`, or a time could not be parsed | +| `range_before_coverage` | The whole range ends before the dataset's first served instant | +| `historical_range_exceeded` | The span is longer than the plan allows per request | +| `historical_depth_exceeded` | The request reaches further back than the plan's history window | +| `unsupported_for_venue` | The datatype, or the WebSocket mode, is not offered on this venue; `details["available_on"]` lists where it is | +| `route_not_found` | No route matches the path | +| `not_found` | The route exists but the resource id does not | +| `unauthorized`, `forbidden` | Missing or invalid API key; not allowed on this plan | +| `insufficient_credits`, `rate_limited` | Monthly credits spent; too many requests | +| `conflict` | The request conflicts with current state | +| `upstream_unavailable`, `internal_error` | Retry later; quote `request_id` if it persists | +| `endpoint_unsupported`, `slow_consumer` | WebSocket only (see below) | +| `positions_unavailable`, `api_key_limit_reached`, `oauth_not_permitted` | Route-specific | + +The set is exported as `oxarchive.ERROR_CODES` (and the `ErrorCode` type). A server can add a code before an SDK release lists it, so treat an unknown value like a generic error. A request the SDK refuses before sending, such as `client.hyperliquid.trades.recent()`, raises `OxArchiveError` with the code the API would have returned; a network failure raises it with `status` 0 and no `error_code`. + +WebSocket errors arrive on `on_message` as `WsError` messages with the same `error_code` and a `message`. Two codes are WebSocket only: `slow_consumer` (the connection fell behind a stream and messages were dropped; re-subscribe or restart the replay to resync) and `endpoint_unsupported` (the endpoint does not serve the channel or operation; the message names the one that does). + +```python +from oxarchive import WsError + +def on_message(msg): + if isinstance(msg, WsError): + if msg.error_code == "slow_consumer": + ws.subscribe_trades("BTC") # resync + else: + print(msg.error_code, msg.message) + +ws.on_message(on_message) ``` ## Type Hints @@ -2526,6 +2658,7 @@ from oxarchive.types import ( WalletPositions, LighterL1Accounts, CvdBucket, Hip3OracleExternalPrice, Hip4Question, WalletClassification, SymbolEntry, WebhookSubscription, WebhookEstimate, WebhookLimits, + Capability, ErrorCode, ) from oxarchive.resources.trades import CursorResponse @@ -2541,7 +2674,10 @@ from oxarchive import ( client = Client(api_key="0xa_your_api_key") orderbook: OrderBook = client.hyperliquid.orderbook.get("BTC") -result: CursorResponse = client.hyperliquid.trades.list("BTC", start=..., end=...) +result: CursorResponse[list[Trade]] = client.hyperliquid.trades.history("BTC", start=..., end=...) +more: bool = result.has_more +print(result.meta.venue, result.meta.symbol) # hyperliquid BTC +capabilities: list[Capability] = client.capabilities() # Lighter current data is available, so recent() is available recent: list[Trade] = client.lighter.trades.recent("BTC") diff --git a/oxarchive/__init__.py b/oxarchive/__init__.py index bb8610e..1ce97c1 100644 --- a/oxarchive/__init__.py +++ b/oxarchive/__init__.py @@ -7,7 +7,8 @@ - Hyperliquid HIP-3 builder perps under the Hyperliquid namespace at /v1/hyperliquid/hip3 and client.hyperliquid.hip3 - Hyperliquid HIP-4 outcome markets under the Hyperliquid namespace at /v1/hyperliquid/hip4 and client.hyperliquid.hip4 - Hyperliquid spot pairs under /v1/hyperliquid/spot and client.spot (trades and candles - from 2025-03-22, candle floor 2025-03-22T10:50:22Z, rest live from 2026-05-05) + from 2025-03-22, candle floor 2025-03-22T10:50:22Z; L4 and order history from + 2026-03-10; order book from 2026-05-05) - Lighter mainnet at /v1/lighter and client.lighter - Lighter on Robinhood Chain at /v1/rh-lighter and client.rh_lighter (USDG-quoted; trades and liquidations from 2026-06-26; order book, open interest and @@ -15,6 +16,11 @@ - Account positions on client.hyperliquid.positions, client.hyperliquid.hip3.positions, client.lighter.positions and client.rh_lighter.positions - Webhooks on client.webhooks, with WebhookVerifier to verify deliveries +- What each venue serves, per datatype, on client.capabilities() + +Every request selects API version 2026-10-01 (the ``0xArchive-Version`` +header, and ``version`` on WebSocket connections). Errors raise +OxArchiveError, whose ``error_code`` is one of ERROR_CODES. Example: >>> from oxarchive import Client @@ -25,7 +31,7 @@ >>> hl_orderbook = client.hyperliquid.orderbook.get("BTC") >>> print(f"BTC mid price: {hl_orderbook.mid_price}") >>> - >>> # Lighter.xyz data + >>> # Lighter data >>> lighter_orderbook = client.lighter.orderbook.get("BTC") >>> >>> # Lighter on Robinhood Chain @@ -42,6 +48,7 @@ """ from .client import Client +from .http import API_VERSION, API_VERSION_HEADER from .exchanges import ( HyperliquidClient, Hip3Client, @@ -51,6 +58,7 @@ SpotClient, ) from .resources.orderbook import LighterGranularity +from .resources.trades import TradeSide from .orderbook_reconstructor import ( OrderBookReconstructor, OrderbookDelta, @@ -112,6 +120,13 @@ OxArchiveError, CursorResponse, ResponseMeta, + # API contract: error codes, venues and capabilities + ErrorCode, + ERROR_CODES, + WEBSOCKET_ERROR_CODES, + Venue, + VENUES, + Capability, # Cumulative volume delta, HIP-3 oracle, HIP-4 questions CvdBucket, CvdInterval, @@ -210,18 +225,29 @@ # WebSocket client (optional import - requires websockets package) try: - from .websocket import OxArchiveWs, WsOptions + from .websocket import ( + WS_CHANNELS, + OxArchiveWs, + WsChannelSpec, + WsOptions, + decode_lighter_payload, + ) _HAS_WEBSOCKET = True except ImportError: _HAS_WEBSOCKET = False OxArchiveWs = None # type: ignore WsOptions = None # type: ignore + WsChannelSpec = None # type: ignore + WS_CHANNELS = None # type: ignore + decode_lighter_payload = None # type: ignore __version__ = "1.12.0" __all__ = [ # Client "Client", + "API_VERSION", + "API_VERSION_HEADER", # Exchange Clients "HyperliquidClient", "Hip3Client", @@ -232,6 +258,9 @@ # WebSocket Client "OxArchiveWs", "WsOptions", + "WsChannelSpec", + "WS_CHANNELS", + "decode_lighter_payload", # Orderbook Reconstructor (Lighter) "OrderBookReconstructor", "OrderbookDelta", @@ -283,6 +312,14 @@ "OxArchiveError", "CursorResponse", "ResponseMeta", + # API Contract + "ErrorCode", + "ERROR_CODES", + "WEBSOCKET_ERROR_CODES", + "Venue", + "VENUES", + "Capability", + "TradeSide", # Cumulative Volume Delta, HIP-3 Oracle, HIP-4 Questions "CvdBucket", "CvdInterval", diff --git a/oxarchive/client.py b/oxarchive/client.py index 58d0e06..c9a18f4 100644 --- a/oxarchive/client.py +++ b/oxarchive/client.py @@ -7,6 +7,7 @@ from .http import HttpClient from .exchanges import HyperliquidClient, LighterClient, RhLighterClient, SpotClient +from .types import Capability from .resources import ( OrderBookResource, TradesResource, @@ -35,7 +36,8 @@ class Client: - `client.hyperliquid.hip3` - Hyperliquid HIP-3 builder perps under the Hyperliquid namespace - `client.hyperliquid.hip4` - Hyperliquid HIP-4 outcome markets under the Hyperliquid namespace - `client.spot` - Hyperliquid spot pairs (trades and candles from 2025-03-22, - orderbook/L4/TWAP live from 2026-05-05; no funding, OI, or liquidations) + L4 and order history from 2026-03-10, order book from 2026-05-05; no + funding, OI, or liquidations) - `client.lighter` - Lighter mainnet - `client.rh_lighter` - Lighter on Robinhood Chain (USDG-quoted perps and spot; trades and liquidations from 2026-06-26; order book, OI and @@ -45,10 +47,16 @@ class Client: `client.hyperliquid.hip3.positions`, `client.lighter.positions` and `client.rh_lighter.positions`. - Cross-venue: `client.symbols` (the public symbol universe), + Cross-venue: `client.capabilities()` (what each venue serves, per + datatype), `client.symbols` (the public symbol universe), `client.data_quality`, and `client.webhooks` (push delivery; verify deliveries with :class:`~oxarchive.WebhookVerifier`). + Every request sends the API version this SDK is written against + (``0xArchive-Version: 2026-10-01``). Errors raise + :class:`~oxarchive.OxArchiveError`, whose ``error_code`` is the stable code + to branch on. + Example: >>> from oxarchive import Client >>> @@ -58,7 +66,7 @@ class Client: >>> hl_orderbook = client.hyperliquid.orderbook.get("BTC") >>> print(f"BTC mid price: {hl_orderbook.mid_price}") >>> - >>> # Lighter.xyz data + >>> # Lighter data >>> lighter_orderbook = client.lighter.orderbook.get("BTC") >>> >>> # Lighter on Robinhood Chain @@ -128,8 +136,9 @@ def __init__( """Hyperliquid exchange data (orderbook, trades, funding, OI from April 2023)""" self.spot = SpotClient(self._http) - """Hyperliquid spot pairs. Trades and candles from 2025-03-22; orderbook, L4, - TWAP, and orders live from 2026-05-05. No funding, OI, or liquidations.""" + """Hyperliquid spot pairs. Trades and candles from 2025-03-22; L4 and order + history from 2026-03-10; order book from 2026-05-05. No funding, OI, or + liquidations.""" self.lighter = LighterClient(self._http) """Lighter mainnet data. Trade history begins January 17, 2025; @@ -179,6 +188,30 @@ def __init__( self.open_interest = OpenInterestResource(self._http, legacy_base) """[DEPRECATED] Use client.hyperliquid.open_interest instead""" + def capabilities(self) -> list[Capability]: + """ + List what every venue serves, one row per venue and datatype. + + Each :class:`~oxarchive.Capability` names the REST routes and + WebSocket channels for the datatype, whether it streams live and + replays, the first served instant (``available_from``), its cadence, + the largest page and the accepted intervals. The route needs no API + key and costs no credits. Per-symbol coverage is on + ``client.symbols.list()``. + + Example: + >>> rows = client.capabilities() + >>> replayable = {c for row in rows if row.replay for c in row.ws_channels} + >>> [row.datatype for row in rows if row.venue == "spot"] + """ + data = self._http.get("/v1/capabilities") + return [Capability.model_validate(row) for row in data["data"]] + + async def acapabilities(self) -> list[Capability]: + """Async version of :meth:`capabilities`.""" + data = await self._http.aget("/v1/capabilities") + return [Capability.model_validate(row) for row in data["data"]] + def close(self) -> None: """Close the HTTP client and release resources.""" self._http.close() diff --git a/oxarchive/exchanges.py b/oxarchive/exchanges.py index 370ffc6..84faa67 100644 --- a/oxarchive/exchanges.py +++ b/oxarchive/exchanges.py @@ -52,8 +52,10 @@ Hip4Question, LiquidationVolume, PriceSnapshot, + ResponseMeta, SpotTableFreshness, Timestamp, + _record, ) @@ -189,6 +191,7 @@ def get_liquidation_volume( return CursorResponse( data=[LiquidationVolume.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aget_liquidation_volume( @@ -218,6 +221,7 @@ async def aget_liquidation_volume( return CursorResponse( data=[LiquidationVolume.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness: @@ -235,13 +239,13 @@ def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness: """ symbol = _resolve_symbol(symbol, kwargs) data = self._http.get(f"/v1/hyperliquid/freshness/{symbol.upper()}") - return CoinFreshness.model_validate(data["data"]) + return _record(CoinFreshness, data) async def aget_freshness(self, symbol: str, **kwargs) -> CoinFreshness: """Async version of get_freshness().""" symbol = _resolve_symbol(symbol, kwargs) data = await self._http.aget(f"/v1/hyperliquid/freshness/{symbol.upper()}") - return CoinFreshness.model_validate(data["data"]) + return _record(CoinFreshness, data) def get_summary(self, symbol: str, **kwargs) -> CoinSummary: """ @@ -258,13 +262,13 @@ def get_summary(self, symbol: str, **kwargs) -> CoinSummary: """ symbol = _resolve_symbol(symbol, kwargs) data = self._http.get(f"/v1/hyperliquid/summary/{symbol.upper()}") - return CoinSummary.model_validate(data["data"]) + return _record(CoinSummary, data) async def aget_summary(self, symbol: str, **kwargs) -> CoinSummary: """Async version of get_summary().""" symbol = _resolve_symbol(symbol, kwargs) data = await self._http.aget(f"/v1/hyperliquid/summary/{symbol.upper()}") - return CoinSummary.model_validate(data["data"]) + return _record(CoinSummary, data) def get_price_history( self, @@ -306,6 +310,7 @@ def get_price_history( return CursorResponse( data=[PriceSnapshot.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aget_price_history( @@ -335,6 +340,7 @@ async def aget_price_history( return CursorResponse( data=[PriceSnapshot.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @@ -419,13 +425,13 @@ def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness: """ symbol = _resolve_symbol(symbol, kwargs) data = self._http.get(f"/v1/hyperliquid/hip3/freshness/{symbol}") - return CoinFreshness.model_validate(data["data"]) + return _record(CoinFreshness, data) async def aget_freshness(self, symbol: str, **kwargs) -> CoinFreshness: """Async version of get_freshness().""" symbol = _resolve_symbol(symbol, kwargs) data = await self._http.aget(f"/v1/hyperliquid/hip3/freshness/{symbol}") - return CoinFreshness.model_validate(data["data"]) + return _record(CoinFreshness, data) def get_summary(self, symbol: str, **kwargs) -> CoinSummary: """ @@ -439,13 +445,13 @@ def get_summary(self, symbol: str, **kwargs) -> CoinSummary: """ symbol = _resolve_symbol(symbol, kwargs) data = self._http.get(f"/v1/hyperliquid/hip3/summary/{symbol}") - return CoinSummary.model_validate(data["data"]) + return _record(CoinSummary, data) async def aget_summary(self, symbol: str, **kwargs) -> CoinSummary: """Async version of get_summary().""" symbol = _resolve_symbol(symbol, kwargs) data = await self._http.aget(f"/v1/hyperliquid/hip3/summary/{symbol}") - return CoinSummary.model_validate(data["data"]) + return _record(CoinSummary, data) def get_price_history( self, @@ -487,6 +493,7 @@ def get_price_history( return CursorResponse( data=[PriceSnapshot.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aget_price_history( @@ -516,6 +523,7 @@ async def aget_price_history( return CursorResponse( data=[PriceSnapshot.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @@ -772,13 +780,13 @@ def get_freshness(self, symbol: str) -> CoinFreshness: """Get data freshness for a HIP-4 symbol across data types.""" encoded = _hip4_encode(symbol) data = self._http.get(f"/v1/hyperliquid/hip4/freshness/{encoded}") - return CoinFreshness.model_validate(data["data"]) + return _record(CoinFreshness, data) async def aget_freshness(self, symbol: str) -> CoinFreshness: """Async version of get_freshness().""" encoded = _hip4_encode(symbol) data = await self._http.aget(f"/v1/hyperliquid/hip4/freshness/{encoded}") - return CoinFreshness.model_validate(data["data"]) + return _record(CoinFreshness, data) def get_summary(self, symbol: str) -> CoinSummary: """Get 24h market summary for a HIP-4 symbol. @@ -788,13 +796,13 @@ def get_summary(self, symbol: str) -> CoinSummary: """ encoded = _hip4_encode(symbol) data = self._http.get(f"/v1/hyperliquid/hip4/summary/{encoded}") - return CoinSummary.model_validate(data["data"]) + return _record(CoinSummary, data) async def aget_summary(self, symbol: str) -> CoinSummary: """Async version of get_summary().""" encoded = _hip4_encode(symbol) data = await self._http.aget(f"/v1/hyperliquid/hip4/summary/{encoded}") - return CoinSummary.model_validate(data["data"]) + return _record(CoinSummary, data) def get_prices( self, @@ -823,6 +831,7 @@ def get_prices( return CursorResponse( data=[PriceSnapshot.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aget_prices( @@ -848,8 +857,16 @@ async def aget_prices( return CursorResponse( data=[PriceSnapshot.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) + get_price_history = get_prices + """Mid-price history: the same method as :meth:`get_prices`, under the name + the other venue clients use.""" + + aget_price_history = aget_prices + """Async version of :meth:`get_price_history`.""" + def get_order_history(self, symbol: str, **kwargs): """Get order lifecycle history.""" return self.orders.history(symbol, **kwargs) @@ -950,13 +967,13 @@ def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness: """ symbol = _resolve_symbol(symbol, kwargs) data = self._http.get(f"{self._base_path}/freshness/{_lighter_encode(symbol)}") - return CoinFreshness.model_validate(data["data"]) + return _record(CoinFreshness, data) async def aget_freshness(self, symbol: str, **kwargs) -> CoinFreshness: """Async version of get_freshness().""" symbol = _resolve_symbol(symbol, kwargs) data = await self._http.aget(f"{self._base_path}/freshness/{_lighter_encode(symbol)}") - return CoinFreshness.model_validate(data["data"]) + return _record(CoinFreshness, data) def get_summary(self, symbol: str, **kwargs) -> CoinSummary: """ @@ -970,13 +987,13 @@ def get_summary(self, symbol: str, **kwargs) -> CoinSummary: """ symbol = _resolve_symbol(symbol, kwargs) data = self._http.get(f"{self._base_path}/summary/{_lighter_encode(symbol)}") - return CoinSummary.model_validate(data["data"]) + return _record(CoinSummary, data) async def aget_summary(self, symbol: str, **kwargs) -> CoinSummary: """Async version of get_summary().""" symbol = _resolve_symbol(symbol, kwargs) data = await self._http.aget(f"{self._base_path}/summary/{_lighter_encode(symbol)}") - return CoinSummary.model_validate(data["data"]) + return _record(CoinSummary, data) def get_price_history( self, @@ -1018,6 +1035,7 @@ def get_price_history( return CursorResponse( data=[PriceSnapshot.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aget_price_history( @@ -1047,14 +1065,15 @@ async def aget_price_history( return CursorResponse( data=[PriceSnapshot.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) class LighterClient(_LighterDeploymentClient): """ - Lighter.xyz exchange client (Lighter mainnet). + Lighter exchange client (Lighter mainnet). - Access Lighter.xyz market data through the 0xarchive API. Lighter has two + Access Lighter market data through the 0xarchive API. Lighter has two deployments: mainnet (this client, ``client.lighter``) and Robinhood Chain (``client.rh_lighter``, see :class:`RhLighterClient`). @@ -1132,8 +1151,9 @@ class SpotClient: (``PURR/USDC`` or ``@107``) internally. Spot has no funding, no open interest, or liquidations. Candle history is - served from ``2025-03-22T10:50:22Z``; orderbook, L4, TWAP, and orders are - live-only from 2026-05-05. Candle pages are capped at 1,000 rows. + served from ``2025-03-22T10:50:22Z``; L4 and order history from + 2026-03-10; the order book from 2026-05-05. Candle pages are capped at + 1,000 rows. Example: >>> client = oxarchive.Client(api_key="...") @@ -1150,7 +1170,7 @@ def __init__(self, http: HttpClient): """Spot pair metadata (list and per-pair detail).""" self.orderbook = OrderBookResource(http, base_path) - """L2 orderbook snapshots (live from 2026-05-05).""" + """L2 orderbook snapshots (from 2026-05-05).""" self.trades = TradesResource(http, base_path) """Trade/fill history (from 2025-03-22), including ``recent()``.""" @@ -1159,12 +1179,12 @@ def __init__(self, http: HttpClient): """OHLCV candle history (from 2025-03-22T10:50:22Z; max 1,000 rows).""" self.orders = SpotOrdersResource(http, base_path) - """L4 order lifecycle history (live from 2026-05-05). Spot serves + """L4 order lifecycle history (from 2026-03-10). Spot serves ``history()`` only: no flow, TP/SL or trigger levels.""" self.l4_orderbook = L4OrderBookResource(http, base_path) """L4 order-level orderbook: full reconstruction, raw diffs, - and checkpoint history. Live from 2026-05-05.""" + and checkpoint history (from 2026-03-10).""" self.twap = SpotTwapResource(http, base_path) """TWAP status records by pair or by user wallet.""" @@ -1185,10 +1205,10 @@ def get_freshness(self, symbol: str, **kwargs) -> SpotTableFreshness: """ symbol = _resolve_symbol(symbol, kwargs) data = self._http.get(f"/v1/hyperliquid/spot/freshness/{symbol.upper()}") - return SpotTableFreshness.model_validate(data["data"]) + return _record(SpotTableFreshness, data) async def aget_freshness(self, symbol: str, **kwargs) -> SpotTableFreshness: """Async version of get_freshness().""" symbol = _resolve_symbol(symbol, kwargs) data = await self._http.aget(f"/v1/hyperliquid/spot/freshness/{symbol.upper()}") - return SpotTableFreshness.model_validate(data["data"]) + return _record(SpotTableFreshness, data) diff --git a/oxarchive/http.py b/oxarchive/http.py index 69248ed..5293a32 100644 --- a/oxarchive/http.py +++ b/oxarchive/http.py @@ -6,7 +6,18 @@ import httpx -from .types import OxArchiveError +from .types import OxArchiveError, _code_for_status + +API_VERSION = "2026-10-01" +"""The API version this SDK is written against. Every REST request sends it in +the ``0xArchive-Version`` header, and the WebSocket client sends it as the +``version`` connection parameter. It selects the response shapes the SDK +parses: the standard ``{success, data, meta}`` envelope on every route, RFC +3339 times with integer ``*_ms`` companions, and the live message shapes on +Lighter replay.""" + +API_VERSION_HEADER = "0xArchive-Version" +"""Request header that selects the API version (echoed on the response).""" class HttpClient: @@ -23,6 +34,7 @@ def _get_headers(self) -> dict[str, str]: return { "X-API-Key": self.api_key, "Content-Type": "application/json", + API_VERSION_HEADER: API_VERSION, } @property @@ -87,20 +99,36 @@ async def aclose(self) -> None: self._async_client = None def _handle_response(self, response: httpx.Response) -> dict[str, Any]: - """Handle the API response and raise errors if needed.""" + """Handle the API response and raise errors if needed. + + A non-2xx response raises :class:`OxArchiveError` carrying the status, + ``error_code``, ``request_id``, ``param`` and ``valid_values`` from the + error body, and the whole body as ``details``. + """ try: data = response.json() except Exception: + if not response.is_success: + snippet = response.text[:200].strip() + message = f"Request failed with status {response.status_code}" + raise OxArchiveError( + f"{message}: {snippet}" if snippet else message, + response.status_code, + error_code=_code_for_status(response.status_code), + ) from None raise OxArchiveError( f"Invalid JSON response: {response.text[:200]}", response.status_code, - ) + ) from None if not response.is_success: - error_msg = data.get("error", f"Request failed with status {response.status_code}") - request_id = data.get("request_id") - raise OxArchiveError(error_msg, response.status_code, request_id) + raise OxArchiveError.from_response(response.status_code, data) + if not isinstance(data, dict): + raise OxArchiveError( + f"Unexpected response: expected a JSON object, got {type(data).__name__}", + response.status_code, + ) return data def get( diff --git a/oxarchive/resources/breadth.py b/oxarchive/resources/breadth.py index 8091c3a..ef86974 100644 --- a/oxarchive/resources/breadth.py +++ b/oxarchive/resources/breadth.py @@ -6,7 +6,7 @@ from .._time import to_unix_ms from ..http import HttpClient -from ..types import BreadthSnapshot, CursorResponse, Timestamp +from ..types import BreadthSnapshot, CursorResponse, ResponseMeta, Timestamp, _record BreadthInterval = Literal["1m", "5m", "15m", "30m", "1h", "4h", "1d"] BREADTH_INTERVALS = frozenset({"1m", "5m", "15m", "30m", "1h", "4h", "1d"}) @@ -53,17 +53,18 @@ def _history_response(payload: dict[str, Any]) -> CursorResponse[list[BreadthSna return CursorResponse( data=[BreadthSnapshot.model_validate(item) for item in payload["data"]], next_cursor=payload.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(payload), ) def current(self) -> BreadthSnapshot: """Return the latest validated breadth snapshot.""" payload = self._http.get(f"{self._base_path}/breadth/above-vwap/current") - return BreadthSnapshot.model_validate(payload["data"]) + return _record(BreadthSnapshot, payload) async def acurrent(self) -> BreadthSnapshot: """Async version of :meth:`current`.""" payload = await self._http.aget(f"{self._base_path}/breadth/above-vwap/current") - return BreadthSnapshot.model_validate(payload["data"]) + return _record(BreadthSnapshot, payload) def history( self, diff --git a/oxarchive/resources/candles.py b/oxarchive/resources/candles.py index b0fa00e..2ba50e5 100644 --- a/oxarchive/resources/candles.py +++ b/oxarchive/resources/candles.py @@ -6,7 +6,7 @@ from .._time import to_unix_ms from ..http import HttpClient -from ..types import Candle, CandleInterval, CursorResponse, Timestamp +from ..types import Candle, CandleInterval, CursorResponse, ResponseMeta, Timestamp class CandlesResource: @@ -24,7 +24,7 @@ class CandlesResource: >>> >>> # Paginate through large datasets >>> all_candles = result.data - >>> while result.next_cursor: + >>> while result.has_more: ... result = client.candles.history( ... "BTC", start=start, end=end, cursor=result.next_cursor ... ) @@ -80,7 +80,7 @@ def history( ... "BTC", start=start, end=end, interval="1h", limit=10000 ... ) >>> candles = result.data - >>> while result.next_cursor: + >>> while result.has_more: ... result = client.candles.history( ... "BTC", start=start, end=end, interval="1h", ... cursor=result.next_cursor, limit=10000 @@ -102,6 +102,7 @@ def history( return CursorResponse( data=[Candle.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -131,6 +132,7 @@ async def ahistory( return CursorResponse( data=[Candle.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @staticmethod diff --git a/oxarchive/resources/cvd.py b/oxarchive/resources/cvd.py index c06323d..603aa5a 100644 --- a/oxarchive/resources/cvd.py +++ b/oxarchive/resources/cvd.py @@ -98,11 +98,12 @@ def history( """ Get one page of cumulative volume delta buckets. - While ``next_cursor`` is set, more buckets may follow inside - ``[start, end]``: pass it back as ``cursor`` with ``start``, ``end`` - and ``interval`` unchanged, and stop when it is None. Below ``1h`` a - page can hold fewer than ``limit`` buckets and still carry a cursor, - so stop on the cursor, not on a short page. Without ``start`` or + While ``has_more`` is true, more buckets follow inside + ``[start, end]``: pass ``next_cursor`` back as ``cursor`` with + ``start``, ``end`` and ``interval`` unchanged, and stop when + ``has_more`` is false. Below ``1h`` a page can hold fewer than + ``limit`` buckets and still not be the last, so stop on ``has_more``, + not on a short page. Without ``start`` or ``cursor``, the response is the newest ``limit`` buckets of the 24 hours before ``end`` (or now), with no cursor. @@ -150,7 +151,8 @@ def iterate( limit: Optional[int] = None, ) -> Iterator[CvdBucket]: """ - Yield every bucket in ``[start, end]``, following ``next_cursor``. + Yield every bucket in ``[start, end]``, following ``next_cursor`` + while ``has_more`` is true. Each page is requested with the same ``start``, ``end`` and ``interval``. ``cumulative_delta`` on the yielded buckets restarts at @@ -161,7 +163,7 @@ def iterate( while True: page = _page(self._http.get(path, params=params)) yield from page.data - if not page.next_cursor: + if not (page.has_more and page.next_cursor): return params = {**params, "cursor": page.next_cursor} @@ -181,6 +183,6 @@ async def aiterate( page = _page(await self._http.aget(path, params=params)) for bucket in page.data: yield bucket - if not page.next_cursor: + if not (page.has_more and page.next_cursor): return params = {**params, "cursor": page.next_cursor} diff --git a/oxarchive/resources/data_quality.py b/oxarchive/resources/data_quality.py index 3f85305..b2daf8f 100644 --- a/oxarchive/resources/data_quality.py +++ b/oxarchive/resources/data_quality.py @@ -18,6 +18,7 @@ StatusResponse, SymbolCoverageResponse, Timestamp, + _outlier_record, ) @@ -71,12 +72,12 @@ def status(self) -> StatusResponse: ... print(f"{exchange}: {info.status}") """ data = self._http.get(f"{self._base_path}/status") - return StatusResponse.model_validate(data) + return _outlier_record(StatusResponse, data) async def astatus(self) -> StatusResponse: """Async version of status().""" data = await self._http.aget(f"{self._base_path}/status") - return StatusResponse.model_validate(data) + return _outlier_record(StatusResponse, data) # ========================================================================= # Coverage Endpoints @@ -97,12 +98,29 @@ def coverage(self) -> CoverageResponse: ... print(f" {dtype}: {info.total_records} records") """ data = self._http.get(f"{self._base_path}/coverage") - return CoverageResponse.model_validate(data) + return _outlier_record(CoverageResponse, data) async def acoverage(self) -> CoverageResponse: """Async version of coverage().""" data = await self._http.aget(f"{self._base_path}/coverage") - return CoverageResponse.model_validate(data) + return _outlier_record(CoverageResponse, data) + + def status_coverage(self) -> CoverageResponse: + """ + Get the public coverage summary served at ``/v1/status/coverage``. + + The same payload as :meth:`coverage`, from the public route that needs + no API key (cached for five minutes). It is a coarse summary; for a + symbol and dataset window use ``client.symbols.list()`` or + :meth:`symbol_coverage`. + """ + data = self._http.get("/v1/status/coverage") + return _outlier_record(CoverageResponse, data) + + async def astatus_coverage(self) -> CoverageResponse: + """Async version of status_coverage().""" + data = await self._http.aget("/v1/status/coverage") + return _outlier_record(CoverageResponse, data) def exchange_coverage(self, exchange: str) -> ExchangeCoverage: """ @@ -120,12 +138,12 @@ def exchange_coverage(self, exchange: str) -> ExchangeCoverage: >>> print(f"Orderbook earliest: {hl.data_types['orderbook'].earliest}") """ data = self._http.get(f"{self._base_path}/coverage/{exchange.lower()}") - return ExchangeCoverage.model_validate(data) + return _outlier_record(ExchangeCoverage, data) async def aexchange_coverage(self, exchange: str) -> ExchangeCoverage: """Async version of exchange_coverage().""" data = await self._http.aget(f"{self._base_path}/coverage/{exchange.lower()}") - return ExchangeCoverage.model_validate(data) + return _outlier_record(ExchangeCoverage, data) def symbol_coverage( self, @@ -181,7 +199,7 @@ def symbol_coverage( "to": self._convert_timestamp(to_time), }, ) - return SymbolCoverageResponse.model_validate(data) + return _outlier_record(SymbolCoverageResponse, data) async def asymbol_coverage( self, @@ -199,7 +217,7 @@ async def asymbol_coverage( "to": self._convert_timestamp(to_time), }, ) - return SymbolCoverageResponse.model_validate(data) + return _outlier_record(SymbolCoverageResponse, data) # ========================================================================= # Incidents Endpoints @@ -244,7 +262,7 @@ def list_incidents( "offset": offset, }, ) - return IncidentsResponse.model_validate(data) + return _outlier_record(IncidentsResponse, data) async def alist_incidents( self, @@ -266,7 +284,7 @@ async def alist_incidents( "offset": offset, }, ) - return IncidentsResponse.model_validate(data) + return _outlier_record(IncidentsResponse, data) def get_incident(self, incident_id: str) -> Incident: """ @@ -284,12 +302,12 @@ def get_incident(self, incident_id: str) -> Incident: >>> print(f"Root cause: {incident.root_cause}") """ data = self._http.get(f"{self._base_path}/incidents/{incident_id}") - return Incident.model_validate(data) + return _outlier_record(Incident, data) async def aget_incident(self, incident_id: str) -> Incident: """Async version of get_incident().""" data = await self._http.aget(f"{self._base_path}/incidents/{incident_id}") - return Incident.model_validate(data) + return _outlier_record(Incident, data) # ========================================================================= # Latency Endpoints @@ -311,12 +329,12 @@ def latency(self) -> LatencyResponse: ... print(f" OB lag: {metrics.data_freshness.orderbook_lag_ms}ms") """ data = self._http.get(f"{self._base_path}/latency") - return LatencyResponse.model_validate(data) + return _outlier_record(LatencyResponse, data) async def alatency(self) -> LatencyResponse: """Async version of latency().""" data = await self._http.aget(f"{self._base_path}/latency") - return LatencyResponse.model_validate(data) + return _outlier_record(LatencyResponse, data) # ========================================================================= # SLA Endpoints @@ -352,7 +370,7 @@ def sla( "month": month, }, ) - return SlaResponse.model_validate(data) + return _outlier_record(SlaResponse, data) async def asla( self, @@ -368,7 +386,7 @@ async def asla( "month": month, }, ) - return SlaResponse.model_validate(data) + return _outlier_record(SlaResponse, data) # ========================================================================= # Account Positions Freshness diff --git a/oxarchive/resources/funding.py b/oxarchive/resources/funding.py index 309d6d4..47a29ea 100644 --- a/oxarchive/resources/funding.py +++ b/oxarchive/resources/funding.py @@ -6,7 +6,7 @@ from .._time import to_unix_ms from ..http import HttpClient -from ..types import CursorResponse, FundingRate, Timestamp +from ..types import CursorResponse, FundingRate, ResponseMeta, Timestamp, _record class FundingResource: @@ -58,7 +58,7 @@ def history( Example: >>> result = client.funding.history("BTC", start=start, end=end, limit=1000) >>> rates = result.data - >>> while result.next_cursor: + >>> while result.has_more: ... result = client.funding.history( ... "BTC", start=start, end=end, cursor=result.next_cursor, limit=1000 ... ) @@ -80,6 +80,7 @@ def history( return CursorResponse( data=[FundingRate.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -110,6 +111,7 @@ async def ahistory( return CursorResponse( data=[FundingRate.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def current(self, symbol: str, **kwargs) -> FundingRate: @@ -124,7 +126,7 @@ def current(self, symbol: str, **kwargs) -> FundingRate: """ symbol = self._resolve_symbol(symbol, kwargs) data = self._http.get(f"{self._base_path}/funding/{self._coin_transform(symbol)}/current") - return FundingRate.model_validate(data["data"]) + return _record(FundingRate, data) async def acurrent(self, symbol: str, **kwargs) -> FundingRate: """Async version of current().""" @@ -132,7 +134,7 @@ async def acurrent(self, symbol: str, **kwargs) -> FundingRate: data = await self._http.aget( f"{self._base_path}/funding/{self._coin_transform(symbol)}/current" ) - return FundingRate.model_validate(data["data"]) + return _record(FundingRate, data) @staticmethod def _resolve_symbol(symbol, kwargs): diff --git a/oxarchive/resources/instruments.py b/oxarchive/resources/instruments.py index 74b8774..74e24bf 100644 --- a/oxarchive/resources/instruments.py +++ b/oxarchive/resources/instruments.py @@ -6,7 +6,7 @@ from urllib.parse import quote from ..http import HttpClient -from ..types import Hip3Instrument, Hip4Outcome, Instrument, LighterInstrument +from ..types import Hip3Instrument, Hip4Outcome, Instrument, LighterInstrument, _record def _hip4_path_segment(symbol: str) -> str: @@ -63,17 +63,17 @@ def get(self, coin: str) -> Instrument: Instrument details """ data = self._http.get(f"{self._base_path}/instruments/{coin.upper()}") - return Instrument.model_validate(data["data"]) + return _record(Instrument, data) async def aget(self, coin: str) -> Instrument: """Async version of get().""" data = await self._http.aget(f"{self._base_path}/instruments/{coin.upper()}") - return Instrument.model_validate(data["data"]) + return _record(Instrument, data) class LighterInstrumentsResource: """ - Lighter.xyz Instruments API resource. + Lighter Instruments API resource. Lighter instruments have a different schema than Hyperliquid with more detailed market configuration including fees and minimum amounts. @@ -123,14 +123,14 @@ def get(self, coin: str) -> LighterInstrument: Lighter instrument details with full market configuration """ data = self._http.get(f"{self._base_path}/instruments/{self._coin_transform(coin)}") - return LighterInstrument.model_validate(data["data"]) + return _record(LighterInstrument, data) async def aget(self, coin: str) -> LighterInstrument: """Async version of get().""" data = await self._http.aget( f"{self._base_path}/instruments/{self._coin_transform(coin)}" ) - return LighterInstrument.model_validate(data["data"]) + return _record(LighterInstrument, data) class Hip3InstrumentsResource: @@ -181,13 +181,13 @@ def get(self, coin: str) -> Hip3Instrument: """ coin = self._coin_transform(coin) data = self._http.get(f"{self._base_path}/instruments/{coin}") - return Hip3Instrument.model_validate(data["data"]) + return _record(Hip3Instrument, data) async def aget(self, coin: str) -> Hip3Instrument: """Async version of get().""" coin = self._coin_transform(coin) data = await self._http.aget(f"{self._base_path}/instruments/{coin}") - return Hip3Instrument.model_validate(data["data"]) + return _record(Hip3Instrument, data) class Hip4InstrumentsResource: @@ -229,10 +229,10 @@ def get(self, symbol: str) -> Hip4Outcome: """ seg = _hip4_path_segment(symbol) data = self._http.get(f"{self._base_path}/instruments/{seg}") - return Hip4Outcome.model_validate(data["data"]) + return _record(Hip4Outcome, data) async def aget(self, symbol: str) -> Hip4Outcome: """Async version of get().""" seg = _hip4_path_segment(symbol) data = await self._http.aget(f"{self._base_path}/instruments/{seg}") - return Hip4Outcome.model_validate(data["data"]) + return _record(Hip4Outcome, data) diff --git a/oxarchive/resources/l2_orderbook.py b/oxarchive/resources/l2_orderbook.py index a5c877f..c4300b5 100644 --- a/oxarchive/resources/l2_orderbook.py +++ b/oxarchive/resources/l2_orderbook.py @@ -4,16 +4,9 @@ from typing import Optional -from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient -from ..types import CursorResponse, Timestamp - -_UNSUPPORTED_HISTORY = { - "depth": ( - "history returns every price level of each snapshot. depth applies to get() only." - ), -} +from ..types import CursorResponse, ResponseMeta, Timestamp class L2OrderBookResource: @@ -94,6 +87,7 @@ def history( end: Timestamp, cursor: Optional[str] = None, limit: Optional[int] = None, + depth: Optional[int] = None, **kwargs, ) -> CursorResponse: """ @@ -105,12 +99,13 @@ def history( end: End timestamp (required) cursor: Cursor from previous response's next_cursor limit: Maximum number of results + depth: Price levels per side in each snapshot (omit for every + level the plan allows) Returns: CursorResponse with L2 orderbook checkpoints and next_cursor for pagination """ symbol = self._resolve_symbol(symbol, kwargs) - reject_unsupported("history", kwargs, _UNSUPPORTED_HISTORY) data = self._http.get( f"{self._base_path}/orderbook/{self._coin_transform(symbol)}/l2/history", params={ @@ -118,11 +113,13 @@ def history( "end": self._convert_timestamp(end), "cursor": cursor, "limit": limit, + "depth": depth, }, ) return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -133,11 +130,11 @@ async def ahistory( end: Timestamp, cursor: Optional[str] = None, limit: Optional[int] = None, + depth: Optional[int] = None, **kwargs, ) -> CursorResponse: """Async version of history().""" symbol = self._resolve_symbol(symbol, kwargs) - reject_unsupported("ahistory", kwargs, _UNSUPPORTED_HISTORY) data = await self._http.aget( f"{self._base_path}/orderbook/{self._coin_transform(symbol)}/l2/history", params={ @@ -145,11 +142,13 @@ async def ahistory( "end": self._convert_timestamp(end), "cursor": cursor, "limit": limit, + "depth": depth, }, ) return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def diffs( @@ -188,6 +187,7 @@ def diffs( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def adiffs( @@ -214,6 +214,7 @@ async def adiffs( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @staticmethod diff --git a/oxarchive/resources/l3_orderbook.py b/oxarchive/resources/l3_orderbook.py index 2220af4..11a1bd7 100644 --- a/oxarchive/resources/l3_orderbook.py +++ b/oxarchive/resources/l3_orderbook.py @@ -7,19 +7,23 @@ from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient -from ..types import CursorResponse, Timestamp +from ..types import CursorResponse, ResponseMeta, Timestamp _UNSUPPORTED_HISTORY = { "depth": ( "history returns every resting order of each snapshot, up to 250 per side. " "depth applies to get() only." ), + "granularity": ( + "the L3 history route takes no granularity. granularity applies to " + "orderbook.history() on Lighter." + ), } class L3OrderBookResource: """ - L3 order book resource (Lighter.xyz only). + L3 order book resource (Lighter only). Provides individual order-level orderbook data. @@ -141,6 +145,7 @@ def history( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -170,6 +175,7 @@ async def ahistory( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @staticmethod diff --git a/oxarchive/resources/l4_orderbook.py b/oxarchive/resources/l4_orderbook.py index 97715ea..1af5beb 100644 --- a/oxarchive/resources/l4_orderbook.py +++ b/oxarchive/resources/l4_orderbook.py @@ -7,7 +7,7 @@ from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient -from ..types import CursorResponse, Timestamp +from ..types import CursorResponse, ResponseMeta, Timestamp _UNSUPPORTED_HISTORY = { "depth": ( @@ -122,6 +122,7 @@ def diffs( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def adiffs( @@ -148,6 +149,7 @@ async def adiffs( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def history( @@ -187,6 +189,7 @@ def history( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -214,6 +217,7 @@ async def ahistory( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @staticmethod diff --git a/oxarchive/resources/liquidations.py b/oxarchive/resources/liquidations.py index 72869bd..01117f2 100644 --- a/oxarchive/resources/liquidations.py +++ b/oxarchive/resources/liquidations.py @@ -12,7 +12,9 @@ LiquidationLevels, LiquidationLevelsHistoryItem, LiquidationVolume, + ResponseMeta, Timestamp, + _record, ) @@ -52,7 +54,7 @@ def history( Example: >>> result = client.hyperliquid.liquidations.history("BTC", start=start, end=end, limit=1000) >>> liquidations = result.data - >>> while result.next_cursor: + >>> while result.has_more: ... result = client.hyperliquid.liquidations.history( ... "BTC", start=start, end=end, cursor=result.next_cursor, limit=1000 ... ) @@ -71,6 +73,7 @@ def history( return CursorResponse( data=[Liquidation.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -97,6 +100,7 @@ async def ahistory( return CursorResponse( data=[Liquidation.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def volume( @@ -143,6 +147,7 @@ def volume( return CursorResponse( data=[LiquidationVolume.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def avolume( @@ -171,6 +176,7 @@ async def avolume( return CursorResponse( data=[LiquidationVolume.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @@ -207,7 +213,7 @@ def levels( f"{self._base_path}/liquidations/{self._coin_transform(symbol)}/levels", params={"range_pct": range_pct, "buckets": buckets, "side": side, "at": at}, ) - return LiquidationLevels.model_validate(data["data"]) + return _record(LiquidationLevels, data) async def alevels( self, @@ -225,7 +231,7 @@ async def alevels( f"{self._base_path}/liquidations/{self._coin_transform(symbol)}/levels", params={"range_pct": range_pct, "buckets": buckets, "side": side, "at": at}, ) - return LiquidationLevels.model_validate(data["data"]) + return _record(LiquidationLevels, data) def levels_history( self, @@ -276,6 +282,7 @@ def levels_history( return CursorResponse( data=[LiquidationLevelsHistoryItem.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def alevels_history( @@ -310,6 +317,7 @@ async def alevels_history( return CursorResponse( data=[LiquidationLevelsHistoryItem.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @staticmethod @@ -412,6 +420,7 @@ def by_user( return CursorResponse( data=[Liquidation.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aby_user( @@ -456,6 +465,7 @@ async def aby_user( return CursorResponse( data=[Liquidation.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) diff --git a/oxarchive/resources/openinterest.py b/oxarchive/resources/openinterest.py index a039b25..f7eef93 100644 --- a/oxarchive/resources/openinterest.py +++ b/oxarchive/resources/openinterest.py @@ -4,13 +4,19 @@ from typing import Generic, Optional, TypeVar -from pydantic import BaseModel - from .._time import to_unix_ms from ..http import HttpClient -from ..types import CursorResponse, Hip4OpenInterestRecord, OpenInterest, Timestamp +from ..types import ( + CursorResponse, + Hip4OpenInterestRecord, + OpenInterest, + ResponseMeta, + Timestamp, + _ApiRecord, + _record, +) -RecordT = TypeVar("RecordT", bound=BaseModel) +RecordT = TypeVar("RecordT", bound=_ApiRecord) class _OpenInterestResourceBase(Generic[RecordT]): @@ -70,7 +76,7 @@ def history( Example: >>> result = client.open_interest.history("BTC", start=start, end=end, limit=1000) >>> records = result.data - >>> while result.next_cursor: + >>> while result.has_more: ... result = client.open_interest.history( ... "BTC", start=start, end=end, cursor=result.next_cursor, limit=1000 ... ) @@ -92,6 +98,7 @@ def history( return CursorResponse( data=[self._record_model.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -122,6 +129,7 @@ async def ahistory( return CursorResponse( data=[self._record_model.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def current(self, symbol: str, **kwargs) -> RecordT: @@ -138,7 +146,7 @@ def current(self, symbol: str, **kwargs) -> RecordT: data = self._http.get( f"{self._base_path}/openinterest/{self._coin_transform(symbol)}/current" ) - return self._record_model.model_validate(data["data"]) + return _record(self._record_model, data) async def acurrent(self, symbol: str, **kwargs) -> RecordT: """Async version of current().""" @@ -146,7 +154,7 @@ async def acurrent(self, symbol: str, **kwargs) -> RecordT: data = await self._http.aget( f"{self._base_path}/openinterest/{self._coin_transform(symbol)}/current" ) - return self._record_model.model_validate(data["data"]) + return _record(self._record_model, data) @staticmethod def _resolve_symbol(symbol, kwargs): diff --git a/oxarchive/resources/oracle.py b/oxarchive/resources/oracle.py index 9a6e152..bdf86fb 100644 --- a/oxarchive/resources/oracle.py +++ b/oxarchive/resources/oracle.py @@ -3,7 +3,7 @@ from __future__ import annotations from ..http import HttpClient -from ..types import Hip3OracleDiscoveryBounds, Hip3OracleExternalPrice +from ..types import Hip3OracleDiscoveryBounds, Hip3OracleExternalPrice, _record class Hip3OracleResource: @@ -37,12 +37,12 @@ def discovery_bounds(self, symbol: str) -> Hip3OracleDiscoveryBounds: symbol: HIP-3 symbol with its builder prefix (case-sensitive). """ data = self._http.get(f"{self._base_path}/oracle/discovery-bounds/{symbol}") - return Hip3OracleDiscoveryBounds.model_validate(data["data"]) + return _record(Hip3OracleDiscoveryBounds, data) async def adiscovery_bounds(self, symbol: str) -> Hip3OracleDiscoveryBounds: """Async version of :meth:`discovery_bounds`.""" data = await self._http.aget(f"{self._base_path}/oracle/discovery-bounds/{symbol}") - return Hip3OracleDiscoveryBounds.model_validate(data["data"]) + return _record(Hip3OracleDiscoveryBounds, data) def external_price(self, symbol: str) -> Hip3OracleExternalPrice: """ @@ -52,9 +52,9 @@ def external_price(self, symbol: str) -> Hip3OracleExternalPrice: symbol: HIP-3 symbol with its builder prefix (case-sensitive). """ data = self._http.get(f"{self._base_path}/oracle/external-price/{symbol}") - return Hip3OracleExternalPrice.model_validate(data["data"]) + return _record(Hip3OracleExternalPrice, data) async def aexternal_price(self, symbol: str) -> Hip3OracleExternalPrice: """Async version of :meth:`external_price`.""" data = await self._http.aget(f"{self._base_path}/oracle/external-price/{symbol}") - return Hip3OracleExternalPrice.model_validate(data["data"]) + return _record(Hip3OracleExternalPrice, data) diff --git a/oxarchive/resources/orderbook.py b/oxarchive/resources/orderbook.py index 21f6681..3130100 100644 --- a/oxarchive/resources/orderbook.py +++ b/oxarchive/resources/orderbook.py @@ -6,7 +6,7 @@ from .._time import to_unix_ms from ..http import HttpClient -from ..types import CursorResponse, OrderBook, Timestamp +from ..types import CursorResponse, OrderBook, ResponseMeta, Timestamp, _record from ..orderbook_reconstructor import ( OrderBookReconstructor, OrderbookDelta, @@ -15,7 +15,7 @@ ReconstructOptions, ) -# Lighter orderbook granularity levels (Lighter.xyz only) +# Lighter orderbook granularity levels (Lighter only) LighterGranularity = Literal["checkpoint", "30s", "10s", "1s", "tick"] @@ -33,7 +33,7 @@ class OrderBookResource: >>> # Get order book history >>> history = client.hyperliquid.orderbook.history("BTC", start="2024-01-01", end="2024-01-02") >>> - >>> # Lighter.xyz order book + >>> # Lighter order book >>> lighter_ob = client.lighter.orderbook.get("BTC") """ @@ -71,7 +71,7 @@ def get( "depth": depth, }, ) - return OrderBook.model_validate(data["data"]) + return _record(OrderBook, data) async def aget( self, @@ -90,7 +90,7 @@ async def aget( "depth": depth, }, ) - return OrderBook.model_validate(data["data"]) + return _record(OrderBook, data) def history( self, @@ -113,8 +113,10 @@ def history( end: End timestamp (required) cursor: Cursor from previous response's next_cursor (timestamp) limit: Maximum number of results (default: 100, max: 1000) - depth: Number of price levels per side - granularity: Data resolution for Lighter orderbook (Lighter.xyz only, ignored for Hyperliquid). + depth: Number of price levels per side in each snapshot (every + venue: Hyperliquid core, HIP-3, HIP-4, spot and Lighter) + granularity: Data resolution for the Lighter order book (Lighter + mainnet and Robinhood Chain only). Options: 'checkpoint' (1min, default), '30s', '10s', '1s', 'tick'. Credit multipliers: checkpoint=1x, 30s=2x, 10s=3x, 1s=10x, tick=20x. @@ -124,13 +126,13 @@ def history( Example: >>> result = client.orderbook.history("BTC", start=start, end=end, limit=1000) >>> snapshots = result.data - >>> while result.next_cursor: + >>> while result.has_more: ... result = client.orderbook.history( ... "BTC", start=start, end=end, cursor=result.next_cursor, limit=1000 ... ) ... snapshots.extend(result.data) >>> - >>> # Lighter.xyz with 10s granularity + >>> # Lighter with 10s granularity >>> result = client.lighter.orderbook.history( ... "BTC", start=start, end=end, granularity="10s" ... ) @@ -150,6 +152,7 @@ def history( return CursorResponse( data=[OrderBook.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -180,6 +183,7 @@ async def ahistory( return CursorResponse( data=[OrderBook.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def history_tick( diff --git a/oxarchive/resources/orders.py b/oxarchive/resources/orders.py index c06b046..bdc83e0 100644 --- a/oxarchive/resources/orders.py +++ b/oxarchive/resources/orders.py @@ -9,9 +9,11 @@ from ..http import HttpClient from ..types import ( CursorResponse, + ResponseMeta, Timestamp, TriggerLevels, TriggerLevelsHistoryItem, + _record, ) @@ -54,6 +56,7 @@ def history( user: Optional[str] = None, status: Optional[str] = None, order_type: Optional[str] = None, + triggered: Optional[bool] = None, cursor: Optional[str] = None, limit: Optional[int] = None, **kwargs, @@ -68,6 +71,9 @@ def history( user: Filter by user address status: Filter by order status order_type: Filter by order type + triggered: ``True`` keeps only orders whose trigger fired + (``status == "triggered"``); ``False`` excludes them. The + API filters before paging. cursor: Cursor from previous response's next_cursor limit: Maximum number of results @@ -83,6 +89,7 @@ def history( "user": user, "status": status, "order_type": order_type, + "triggered": triggered, "cursor": cursor, "limit": limit, }, @@ -90,6 +97,7 @@ def history( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -101,6 +109,7 @@ async def ahistory( user: Optional[str] = None, status: Optional[str] = None, order_type: Optional[str] = None, + triggered: Optional[bool] = None, cursor: Optional[str] = None, limit: Optional[int] = None, **kwargs, @@ -115,6 +124,7 @@ async def ahistory( "user": user, "status": status, "order_type": order_type, + "triggered": triggered, "cursor": cursor, "limit": limit, }, @@ -122,6 +132,7 @@ async def ahistory( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def flow( @@ -140,8 +151,9 @@ def flow( Buckets are labelled by their open time in UTC, and buckets with no events are omitted. A page holds the oldest ``limit`` buckets of the - window. While ``next_cursor`` is set, pass it back as ``cursor`` with - the same ``start``, ``end`` and ``interval``; stop when it is None. + window. While ``has_more`` is true, pass ``next_cursor`` back as + ``cursor`` with the same ``start``, ``end`` and ``interval``; stop + when ``has_more`` is false. Args: symbol: The symbol (e.g., 'BTC', 'ETH') @@ -169,6 +181,7 @@ def flow( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aflow( @@ -197,6 +210,7 @@ async def aflow( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def tpsl( @@ -241,6 +255,7 @@ def tpsl( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def atpsl( @@ -271,6 +286,7 @@ async def atpsl( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @@ -319,7 +335,7 @@ def trigger_levels( f"{self._base_path}/orders/{self._coin_transform(symbol)}/trigger-levels", params={"range_pct": range_pct, "buckets": buckets, "side": side}, ) - return TriggerLevels.model_validate(data["data"]) + return _record(TriggerLevels, data) async def atrigger_levels( self, @@ -336,7 +352,7 @@ async def atrigger_levels( f"{self._base_path}/orders/{self._coin_transform(symbol)}/trigger-levels", params={"range_pct": range_pct, "buckets": buckets, "side": side}, ) - return TriggerLevels.model_validate(data["data"]) + return _record(TriggerLevels, data) def trigger_levels_history( self, @@ -376,6 +392,7 @@ def trigger_levels_history( return CursorResponse( data=[TriggerLevelsHistoryItem.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def atrigger_levels_history( @@ -410,6 +427,7 @@ async def atrigger_levels_history( return CursorResponse( data=[TriggerLevelsHistoryItem.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) @@ -427,7 +445,7 @@ class Hip4OrdersResource(_OrderFlowResource): _UNSUPPORTED_SPOT_HISTORY = { name: "the spot order history route does not filter by it. Filter the returned rows instead." - for name in ("user", "status", "order_type") + for name in ("user", "status", "order_type", "triggered") } @@ -436,11 +454,12 @@ class SpotOrdersResource(_OrdersBase): Hyperliquid spot L4 order lifecycle history (live from 2026-05-05). Spot serves order history only: there is no flow, TP/SL or trigger-level - route, and the history route takes no user, status or order-type filter. + route, and the history route takes no user, status, order-type or + triggered filter. Example: >>> result = client.spot.orders.history("HYPE-USDC", start=..., end=...) - >>> while result.next_cursor: + >>> while result.has_more: ... result = client.spot.orders.history( ... "HYPE-USDC", start=..., end=..., cursor=result.next_cursor ... ) @@ -483,6 +502,7 @@ def history( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def ahistory( @@ -510,4 +530,5 @@ async def ahistory( return CursorResponse( data=data["data"], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) diff --git a/oxarchive/resources/outcomes.py b/oxarchive/resources/outcomes.py index eebf680..6c595a9 100644 --- a/oxarchive/resources/outcomes.py +++ b/oxarchive/resources/outcomes.py @@ -6,7 +6,7 @@ from urllib.parse import quote from ..http import HttpClient -from ..types import CursorResponse, Hip4OutcomeAggregate, Hip4Question, ResponseMeta +from ..types import CursorResponse, Hip4OutcomeAggregate, Hip4Question, ResponseMeta, _record class Hip4OutcomesResource: @@ -54,6 +54,7 @@ def list( return CursorResponse( data=[Hip4OutcomeAggregate.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def alist( @@ -77,17 +78,18 @@ async def alist( return CursorResponse( data=[Hip4OutcomeAggregate.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) def get(self, outcome_id: int) -> Hip4OutcomeAggregate: """Get a single outcome market by id. Includes ``aggregated_oi``.""" data = self._http.get(f"{self._base_path}/outcomes/{int(outcome_id)}") - return Hip4OutcomeAggregate.model_validate(data["data"]) + return _record(Hip4OutcomeAggregate, data) async def aget(self, outcome_id: int) -> Hip4OutcomeAggregate: """Async version of get().""" data = await self._http.aget(f"{self._base_path}/outcomes/{int(outcome_id)}") - return Hip4OutcomeAggregate.model_validate(data["data"]) + return _record(Hip4OutcomeAggregate, data) def get_by_slug(self, slug: str) -> Hip4OutcomeAggregate: """Look up an outcome by its synthesized slug. @@ -98,12 +100,12 @@ def get_by_slug(self, slug: str) -> Hip4OutcomeAggregate: :py:meth:`get`. """ data = self._http.get(f"{self._base_path}/outcomes/by-slug/{quote(slug, safe='')}") - return Hip4OutcomeAggregate.model_validate(data["data"]) + return _record(Hip4OutcomeAggregate, data) async def aget_by_slug(self, slug: str) -> Hip4OutcomeAggregate: """Async version of get_by_slug().""" data = await self._http.aget(f"{self._base_path}/outcomes/by-slug/{quote(slug, safe='')}") - return Hip4OutcomeAggregate.model_validate(data["data"]) + return _record(Hip4OutcomeAggregate, data) def _questions_page(payload: dict[str, Any]) -> CursorResponse[list[Hip4Question]]: @@ -147,8 +149,8 @@ def list( limit: Page size (default 100, max 1000). Returns: - CursorResponse with the questions and ``next_cursor``; ``None`` on - the last page. + CursorResponse with the questions, ``has_more`` and + ``next_cursor`` (``None`` on the last page). """ data = self._http.get( f"{self._base_path}/questions", @@ -172,9 +174,9 @@ async def alist( def get(self, question_id: int) -> Hip4Question: """Get a single question by its numeric ID.""" data = self._http.get(f"{self._base_path}/questions/{int(question_id)}") - return Hip4Question.model_validate(data["data"]) + return _record(Hip4Question, data) async def aget(self, question_id: int) -> Hip4Question: """Async version of get().""" data = await self._http.aget(f"{self._base_path}/questions/{int(question_id)}") - return Hip4Question.model_validate(data["data"]) + return _record(Hip4Question, data) diff --git a/oxarchive/resources/positions.py b/oxarchive/resources/positions.py index 8efbcfb..a7f2a8b 100644 --- a/oxarchive/resources/positions.py +++ b/oxarchive/resources/positions.py @@ -143,7 +143,7 @@ def _iterate(self, ep: _Endpoint, rows: Callable[[Any], list[R]]) -> Iterator[R] while True: page = self._fetch(_Endpoint(ep.path, params, ep.parse)) yield from rows(page.data) - if not page.next_cursor: + if not (page.has_more and page.next_cursor): return params["cursor"] = page.next_cursor @@ -153,7 +153,7 @@ async def _aiterate(self, ep: _Endpoint, rows: Callable[[Any], list[R]]) -> Asyn page = await self._afetch(_Endpoint(ep.path, params, ep.parse)) for row in rows(page.data): yield row - if not page.next_cursor: + if not (page.has_more and page.next_cursor): return params["cursor"] = page.next_cursor diff --git a/oxarchive/resources/spot.py b/oxarchive/resources/spot.py index 4c8a20a..58a3228 100644 --- a/oxarchive/resources/spot.py +++ b/oxarchive/resources/spot.py @@ -7,7 +7,8 @@ Spot has no funding, no open interest, or liquidations. Candle history is served from ``2025-03-22T10:50:22Z`` with 1,000-row pages; trades go back to -2025-03-22. Orderbook / L4 / TWAP / orders are live-only from 2026-05-05. +2025-03-22. L4 and order history are served from 2026-03-10 and the order book +from 2026-05-05. """ from __future__ import annotations @@ -16,7 +17,7 @@ from .._time import to_unix_ms from ..http import HttpClient -from ..types import CursorResponse, SpotPair, SpotTwapStatus, Timestamp +from ..types import CursorResponse, ResponseMeta, SpotPair, SpotTwapStatus, Timestamp, _record class SpotPairsResource: @@ -55,12 +56,12 @@ def get(self, symbol: str) -> SpotPair: ``PURR-USDC``). Symbol is upper-cased before sending. """ data = self._http.get(f"{self._base_path}/pairs/{symbol.upper()}") - return SpotPair.model_validate(data["data"]) + return _record(SpotPair, data) async def aget(self, symbol: str) -> SpotPair: """Async version of get().""" data = await self._http.aget(f"{self._base_path}/pairs/{symbol.upper()}") - return SpotPair.model_validate(data["data"]) + return _record(SpotPair, data) class SpotTwapResource: @@ -73,8 +74,11 @@ class SpotTwapResource: * by user: ``/v1/hyperliquid/spot/twap/user/{user}`` returns TWAP statuses for that wallet across all pairs. + ``history(symbol)`` is the same method as ``by_symbol(symbol)``, under + the name every paged series uses. + Example: - >>> recent = client.spot.twap.by_symbol("HYPE-USDC", start=..., end=...) + >>> recent = client.spot.twap.history("HYPE-USDC", start=..., end=...) >>> mine = client.spot.twap.by_user("0xabc...", start=..., end=...) """ @@ -115,6 +119,7 @@ def by_symbol( return CursorResponse( data=[SpotTwapStatus.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aby_symbol( @@ -139,8 +144,16 @@ async def aby_symbol( return CursorResponse( data=[SpotTwapStatus.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) + history = by_symbol + """TWAP status records for a spot pair, one page at a time: the same method + as :meth:`by_symbol`.""" + + ahistory = aby_symbol + """Async version of :meth:`history` (the same method as :meth:`aby_symbol`).""" + def by_user( self, user: str, @@ -172,6 +185,7 @@ def by_user( return CursorResponse( data=[SpotTwapStatus.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) async def aby_user( @@ -196,4 +210,5 @@ async def aby_user( return CursorResponse( data=[SpotTwapStatus.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.of(data), ) diff --git a/oxarchive/resources/symbols.py b/oxarchive/resources/symbols.py index ba20df6..c8195e8 100644 --- a/oxarchive/resources/symbols.py +++ b/oxarchive/resources/symbols.py @@ -5,11 +5,13 @@ from typing import Any, List from ..http import HttpClient -from ..types import SymbolEntry +from ..types import SymbolEntry, _body def _entries(payload: dict[str, Any]) -> list[SymbolEntry]: - return [SymbolEntry.model_validate(item) for item in payload.get("symbols") or []] + body = _body(payload) + rows = body if isinstance(body, list) else body.get("symbols") or [] + return [SymbolEntry.model_validate(item) for item in rows] class SymbolsResource: diff --git a/oxarchive/resources/trades.py b/oxarchive/resources/trades.py index 4a4def4..8c39fae 100644 --- a/oxarchive/resources/trades.py +++ b/oxarchive/resources/trades.py @@ -2,34 +2,42 @@ from __future__ import annotations -from typing import Optional, Union +from typing import Literal, Optional, Union -from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient -from ..types import CursorResponse, OxArchiveError, ResponseMeta, Trade, Timestamp +from ..types import CursorResponse, OxArchiveError, ResponseMeta, Timestamp, Trade - -_UNSUPPORTED_LIST = { - "side": "the API does not filter trades by side. Filter the returned trades on Trade.side.", -} +TradeSide = Literal["buy", "sell"] +"""Trade side filter: ``"buy"`` keeps rows with ``side == "B"``, ``"sell"`` keeps +rows with ``side == "A"``.""" class TradesResource: """ Trades API resource. + ``history()`` pages the trade tape of a window (``list()`` is the same + method under its earlier name); ``recent()`` returns the latest trades. + Both take ``side="buy"`` or ``side="sell"``, which the API applies before + paging, so a full page holds ``limit`` matching trades. + Example: >>> # Get trade history with cursor-based pagination (recommended) - >>> result = client.hyperliquid.trades.list("BTC", start="2024-01-01", end="2024-01-02") + >>> result = client.hyperliquid.trades.history("BTC", start="2024-01-01", end="2024-01-02") >>> trades = result.data >>> >>> # Get all pages - >>> while result.next_cursor: - ... result = client.hyperliquid.trades.list("BTC", start="2024-01-01", end="2024-01-02", cursor=result.next_cursor) + >>> while result.has_more: + ... result = client.hyperliquid.trades.history( + ... "BTC", start="2024-01-01", end="2024-01-02", cursor=result.next_cursor + ... ) ... trades.extend(result.data) >>> - >>> # Get recent trades (Lighter only - has real-time data) + >>> # Only buys + >>> buys = client.hyperliquid.trades.history("BTC", start=..., end=..., side="buy") + >>> + >>> # Get recent trades (HIP-3, HIP-4, spot and Lighter) >>> recent = client.lighter.trades.recent("BTC") """ @@ -73,13 +81,14 @@ def list( end: Timestamp, cursor: Optional[Timestamp] = None, limit: Optional[int] = None, + side: Optional[TradeSide] = None, **kwargs, ) -> CursorResponse[list[Trade]]: """ Get trade history for a symbol using cursor-based pagination. - Uses cursor-based pagination by default, which is more efficient for large datasets. - Use the next_cursor from the response as the cursor parameter to get the next page. + Also available as :meth:`history`. While ``has_more`` is true, pass + ``next_cursor`` back as ``cursor`` with the other arguments unchanged. Args: symbol: The symbol (e.g., 'BTC', 'ETH') @@ -87,6 +96,9 @@ def list( end: End timestamp (required) cursor: The previous response's next_cursor, passed back unchanged limit: Maximum number of results (default: 100, max: 1000) + side: ``"buy"`` or ``"sell"`` to keep only that side (``side == + "B"`` or ``"A"``). The API filters before paging, and the + cursor pages the filtered tape. Returns: CursorResponse with trades and next_cursor for pagination. On @@ -100,14 +112,13 @@ def list( >>> trades = result.data >>> >>> # Subsequent pages - >>> while result.next_cursor: + >>> while result.has_more: ... result = client.trades.list( ... "BTC", start=start, end=end, cursor=result.next_cursor, limit=1000 ... ) ... trades.extend(result.data) """ symbol = self._resolve_symbol(symbol, kwargs) - reject_unsupported("list", kwargs, _UNSUPPORTED_LIST) data = self._http.get( f"{self._base_path}/trades/{self._coin_transform(symbol)}", params={ @@ -115,6 +126,7 @@ def list( "end": self._convert_timestamp(end), "cursor": self._cursor(cursor), "limit": limit, + "side": side, }, ) return CursorResponse( @@ -131,15 +143,13 @@ async def alist( end: Timestamp, cursor: Optional[Timestamp] = None, limit: Optional[int] = None, + side: Optional[TradeSide] = None, **kwargs, ) -> CursorResponse[list[Trade]]: """ - Async version of list(). - - Uses cursor-based pagination by default. + Async version of list() (also available as :meth:`ahistory`). """ symbol = self._resolve_symbol(symbol, kwargs) - reject_unsupported("alist", kwargs, _UNSUPPORTED_LIST) data = await self._http.aget( f"{self._base_path}/trades/{self._coin_transform(symbol)}", params={ @@ -147,6 +157,7 @@ async def alist( "end": self._convert_timestamp(end), "cursor": self._cursor(cursor), "limit": limit, + "side": side, }, ) return CursorResponse( @@ -155,21 +166,39 @@ async def alist( meta=ResponseMeta.model_validate(data.get("meta") or {}), ) - def recent(self, symbol: str, limit: Optional[int] = None, **kwargs) -> list[Trade]: + history = list + """Trade history for a window, one page at a time: the same method as + :meth:`list`, under the name every paged series uses.""" + + ahistory = alist + """Async version of :meth:`history` (the same method as :meth:`alist`).""" + + def recent( + self, + symbol: str, + limit: Optional[int] = None, + *, + side: Optional[TradeSide] = None, + **kwargs, + ) -> list[Trade]: """ Get most recent trades for a symbol. - Note: This method is available for Lighter (``client.lighter.trades.recent()`` - and ``client.rh_lighter.trades.recent()``, the preliminary tier), - HIP-3 (``client.hyperliquid.hip3.trades.recent()``), and HIP-4 - (``client.hyperliquid.hip4.trades.recent()``), all of which have - real-time data ingestion. Hyperliquid uses hourly S3 backfill so this - endpoint is not exposed for the bare Hyperliquid namespace; calling - ``client.hyperliquid.trades.recent()`` raises :class:`OxArchiveError`. + Note: This method is available for HIP-3 + (``client.hyperliquid.hip3.trades.recent()``), HIP-4 + (``client.hyperliquid.hip4.trades.recent()``), Hyperliquid spot + (``client.spot.trades.recent()``) and Lighter + (``client.lighter.trades.recent()`` and + ``client.rh_lighter.trades.recent()``, the preliminary tier). The API + does not serve it for Hyperliquid core, so + ``client.hyperliquid.trades.recent()`` raises :class:`OxArchiveError` + with ``error_code == "unsupported_for_venue"`` before sending; use + ``client.hyperliquid.trades.history()`` there. Args: symbol: The symbol (e.g., 'BTC', 'ETH') limit: Number of trades to return (default: 100) + side: ``"buy"`` or ``"sell"`` to keep only that side. Returns: List of recent trades. The response meta is not returned: on @@ -177,39 +206,46 @@ def recent(self, symbol: str, limit: Optional[int] = None, **kwargs) -> list[Tra """ if not self._allow_recent: raise OxArchiveError( - "trades.recent() is not available for Hyperliquid (hourly S3 " - "backfill). Use client.hyperliquid.trades.list(symbol, " - "start=..., end=...) for trade history, or " - "client.lighter.trades.recent() / " - "client.hyperliquid.hip3.trades.recent() / " - "client.hyperliquid.hip4.trades.recent() for venues with " - "real-time data.", + "trades.recent() is not offered for Hyperliquid core. Use " + "client.hyperliquid.trades.history(symbol, start=..., end=...) " + "for trade history, or recent() on " + "client.hyperliquid.hip3.trades, client.hyperliquid.hip4.trades, " + "client.spot.trades, client.lighter.trades or " + "client.rh_lighter.trades.", 404, + error_code="unsupported_for_venue", ) symbol = self._resolve_symbol(symbol, kwargs) data = self._http.get( f"{self._base_path}/trades/{self._coin_transform(symbol)}/recent", - params={"limit": limit}, + params={"limit": limit, "side": side}, ) return [Trade.model_validate(item) for item in data["data"]] - async def arecent(self, symbol: str, limit: Optional[int] = None, **kwargs) -> list[Trade]: + async def arecent( + self, + symbol: str, + limit: Optional[int] = None, + *, + side: Optional[TradeSide] = None, + **kwargs, + ) -> list[Trade]: """Async version of recent().""" if not self._allow_recent: raise OxArchiveError( - "trades.recent() is not available for Hyperliquid (hourly S3 " - "backfill). Use client.hyperliquid.trades.alist(symbol, " - "start=..., end=...) for trade history, or " - "client.lighter.trades.arecent() / " - "client.hyperliquid.hip3.trades.arecent() / " - "client.hyperliquid.hip4.trades.arecent() for venues with " - "real-time data.", + "trades.arecent() is not offered for Hyperliquid core. Use " + "client.hyperliquid.trades.ahistory(symbol, start=..., end=...) " + "for trade history, or arecent() on " + "client.hyperliquid.hip3.trades, client.hyperliquid.hip4.trades, " + "client.spot.trades, client.lighter.trades or " + "client.rh_lighter.trades.", 404, + error_code="unsupported_for_venue", ) symbol = self._resolve_symbol(symbol, kwargs) data = await self._http.aget( f"{self._base_path}/trades/{self._coin_transform(symbol)}/recent", - params={"limit": limit}, + params={"limit": limit, "side": side}, ) return [Trade.model_validate(item) for item in data["data"]] diff --git a/oxarchive/resources/wallets.py b/oxarchive/resources/wallets.py index 6e6fe30..9bba606 100644 --- a/oxarchive/resources/wallets.py +++ b/oxarchive/resources/wallets.py @@ -6,7 +6,7 @@ from typing import Any, Literal, Optional, Union from ..http import HttpClient -from ..types import WalletClassification, WalletClassifySort +from ..types import WalletClassification, WalletClassifySort, _record def _snapshot_date(value: Union[str, date, datetime, None]) -> Optional[str]: @@ -128,7 +128,7 @@ def classify( date, ), ) - return WalletClassification.model_validate(data["data"]) + return _record(WalletClassification, data) async def aclassify( self, @@ -162,5 +162,5 @@ async def aclassify( date, ), ) - return WalletClassification.model_validate(data["data"]) + return _record(WalletClassification, data) diff --git a/oxarchive/types.py b/oxarchive/types.py index 8919285..704c013 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -5,7 +5,7 @@ from datetime import date, datetime from typing import Any, Generic, Literal, Optional, TypeVar, Union -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator # ============================================================================= @@ -15,6 +15,143 @@ T = TypeVar("T") +# ============================================================================= +# API Contract Types +# ============================================================================= + +Venue = Literal["hyperliquid", "hip3", "hip4", "spot", "lighter", "rh-lighter"] +"""A venue as the API names it in ``meta.venue``, ``/v1/capabilities`` and +``/v1/symbols``: Hyperliquid core, HIP-3, HIP-4, Hyperliquid spot, Lighter and +Lighter on Robinhood Chain (``"rh-lighter"``).""" + +VENUES: tuple[str, ...] = ("hyperliquid", "hip3", "hip4", "spot", "lighter", "rh-lighter") +"""Every :data:`Venue`, in the API's order.""" + +ErrorCode = Literal[ + "invalid_parameter", + "invalid_symbol", + "invalid_interval", + "invalid_cursor", + "invalid_time_range", + "range_before_coverage", + "historical_range_exceeded", + "historical_depth_exceeded", + "unsupported_for_venue", + "route_not_found", + "not_found", + "unauthorized", + "forbidden", + "insufficient_credits", + "rate_limited", + "conflict", + "upstream_unavailable", + "internal_error", + "endpoint_unsupported", + "slow_consumer", + "positions_unavailable", + "api_key_limit_reached", + "oauth_not_permitted", +] +"""The stable ``error_code`` values of the API's public error set. + +- ``invalid_parameter``: a parameter failed to parse or validate (see + ``param`` and ``valid_values`` on the error). +- ``invalid_symbol``: the symbol is not listed on this venue. +- ``invalid_interval``: the interval is not one the route accepts. +- ``invalid_cursor``: the cursor is malformed, stale or was issued for other + filters. +- ``invalid_time_range``: ``start`` is after ``end``, or a time could not be + parsed. +- ``range_before_coverage``: the whole range ends before the dataset's first + served instant. +- ``historical_range_exceeded``: the span is longer than the plan allows per + request. +- ``historical_depth_exceeded``: the request reaches further back than the + plan's history window. +- ``unsupported_for_venue``: the datatype, or the WebSocket mode (live, + replay, ``replay.seek``), is not offered on this venue; the message names + where it is. +- ``route_not_found``: no route matches the path. +- ``not_found``: the route exists but the resource id does not. +- ``unauthorized``, ``forbidden``, ``insufficient_credits``, ``rate_limited``, + ``conflict``: authentication, plan, credit, rate and state refusals. +- ``upstream_unavailable``: a dependency is unavailable; retry later. +- ``internal_error``: an unexpected server error. +- ``endpoint_unsupported`` (WebSocket only): this endpoint does not serve the + channel or operation; the message names the endpoint that does. +- ``slow_consumer`` (WebSocket only): the connection fell behind a stream and + messages were dropped; re-subscribe or restart the replay to resync. +- ``positions_unavailable``, ``api_key_limit_reached``, + ``oauth_not_permitted``: route-specific codes. + +Branch on these strings rather than on messages. A server can add a code +before an SDK release names it, so ``error_code`` attributes are typed as +``str``.""" + +ERROR_CODES: tuple[str, ...] = ( + "invalid_parameter", + "invalid_symbol", + "invalid_interval", + "invalid_cursor", + "invalid_time_range", + "range_before_coverage", + "historical_range_exceeded", + "historical_depth_exceeded", + "unsupported_for_venue", + "route_not_found", + "not_found", + "unauthorized", + "forbidden", + "insufficient_credits", + "rate_limited", + "conflict", + "upstream_unavailable", + "internal_error", + "endpoint_unsupported", + "slow_consumer", + "positions_unavailable", + "api_key_limit_reached", + "oauth_not_permitted", +) +"""Every :data:`ErrorCode`, in the API's order.""" + +WEBSOCKET_ERROR_CODES: frozenset[str] = frozenset({"endpoint_unsupported", "slow_consumer"}) +"""Codes only WebSocket error messages carry; REST never answers them.""" + + +def _ms_from_legacy(values: Any, field: str) -> Any: + """Fill ``_ms`` from an integer ```` (the pre-2026-10 shape). + + With the ``0xArchive-Version`` the SDK sends, the API returns the time as an + RFC 3339 string and the integer as ``_ms``. A body in the older shape + carries Unix milliseconds in ```` itself; this keeps ``_ms`` + set in both cases. + """ + if isinstance(values, dict): + value = values.get(field) + if ( + values.get(f"{field}_ms") is None + and isinstance(value, (int, float)) + and not isinstance(value, bool) + ): + values = {**values, f"{field}_ms": int(value)} + return values + + +class _ApiRecord(BaseModel): + """A record a method returns on its own, with the response's ``meta`` attached.""" + + _response_meta: Optional["ResponseMeta"] = PrivateAttr(default=None) + + @property + def response_meta(self) -> Optional["ResponseMeta"]: + """The ``meta`` of the response this record came in: ``request_id``, + and on per-symbol routes ``symbol`` (the canonical public symbol) and + ``venue``. ``None`` on a record that was not returned on its own (for + example a row inside a list).""" + return self._response_meta + + class ApiMeta(BaseModel): """Response metadata.""" @@ -55,7 +192,7 @@ class PriceLevel(BaseModel): """Number of orders at this level.""" -class OrderBook(BaseModel): +class OrderBook(_ApiRecord): """L2 order book snapshot.""" coin: str @@ -172,7 +309,7 @@ class Trade(BaseModel): # ============================================================================= -class Instrument(BaseModel): +class Instrument(_ApiRecord): """Trading instrument specification (Hyperliquid). Accepts either snake_case (``sz_decimals``, ``is_active``) or camelCase @@ -204,7 +341,7 @@ class Instrument(BaseModel): model_config = {"populate_by_name": True} -class Hip3Instrument(BaseModel): +class Hip3Instrument(_ApiRecord): """HIP-3 Builder Perps instrument with latest market data. Derived from live open interest data. Useful for discovering @@ -271,7 +408,7 @@ class Hip4AggregatedOi(BaseModel): side1_as_of: Optional[datetime] = None -class Hip4Outcome(BaseModel): +class Hip4Outcome(_ApiRecord): """HIP-4 per-side instrument metadata. Returned by /v1/hyperliquid/hip4/instruments and /instruments/{symbol}. @@ -322,7 +459,7 @@ class Hip4Outcome(BaseModel): """Per-side URL slug mirroring HL's pattern.""" -class Hip4OutcomeAggregate(BaseModel): +class Hip4OutcomeAggregate(_ApiRecord): """HIP-4 per-outcome aggregated metadata. Returned by /v1/hyperliquid/hip4/outcomes (list, no aggregated_oi) @@ -366,7 +503,7 @@ class Hip4OutcomeAggregate(BaseModel): model_config = {"populate_by_name": True} -class Hip4OpenInterestRecord(BaseModel): +class Hip4OpenInterestRecord(_ApiRecord): """HIP-4 OI record (per side). Mirrors HIP-3 OI plus outcome_id and side. Note: `mark_price` is an implied probability in [0, 1], not a USD price. @@ -386,7 +523,7 @@ class Hip4OpenInterestRecord(BaseModel): mid_price: Optional[str] = None -class SpotPair(BaseModel): +class SpotPair(_ApiRecord): """Hyperliquid spot trading pair metadata. Returned by ``/v1/hyperliquid/spot/pairs`` and the per-pair detail endpoint. @@ -510,7 +647,7 @@ class SpotTwapStatus(BaseModel): model_config = {"extra": "ignore"} -class SpotTableFreshness(BaseModel): +class SpotTableFreshness(_ApiRecord): """Freshness of each dataset for a spot pair, from ``client.spot.get_freshness()``. One entry per dataset spot serves: ``orderbook``, ``trades``, @@ -562,8 +699,8 @@ def tables(self) -> dict[str, DataTypeFreshness]: return {name: value for name in names if (value := getattr(self, name)) is not None} -class LighterInstrument(BaseModel): - """Trading instrument specification (Lighter.xyz). +class LighterInstrument(_ApiRecord): + """Trading instrument specification (Lighter). Lighter instruments have a different schema than Hyperliquid with more detailed market configuration including fees and minimum amounts. @@ -655,7 +792,7 @@ class BreadthCounts(BaseModel): """Candidates excluded because their completed candle is over five minutes old.""" -class BreadthSnapshot(BaseModel): +class BreadthSnapshot(_ApiRecord): """Validated HIP-3 percent-above-session-VWAP market snapshot. Snapshots use the current UTC session and the close of the most recently @@ -689,7 +826,7 @@ class BreadthSnapshot(BaseModel): # ============================================================================= -class FundingRate(BaseModel): +class FundingRate(_ApiRecord): """Funding rate record.""" coin: str @@ -714,7 +851,7 @@ class FundingRate(BaseModel): # ============================================================================= -class OpenInterest(BaseModel): +class OpenInterest(_ApiRecord): """Open interest snapshot with market context.""" coin: str @@ -852,7 +989,7 @@ class LiquidationLevelBucket(BaseModel): """Number of short positions in this bucket.""" -class LiquidationLevels(BaseModel): +class LiquidationLevels(_ApiRecord): """Projected forced-liquidation levels for one snapshot. Computed from clearinghouse positions and margin state, bucketed around @@ -864,7 +1001,10 @@ class LiquidationLevels(BaseModel): """Mark price at the snapshot, center of the requested range.""" snapshot_ts: str - """UTC snapshot time the levels reflect.""" + """UTC snapshot time the levels reflect, as an RFC 3339 string.""" + + snapshot_ts_ms: Optional[int] = None + """``snapshot_ts`` in Unix milliseconds.""" block_number: int """Hyperliquid block height the snapshot reflects.""" @@ -891,6 +1031,11 @@ class LiquidationLevelsHistoryItem(BaseModel): """ snapshot_ts: str + """UTC snapshot time, as an RFC 3339 string.""" + + snapshot_ts_ms: Optional[int] = None + """``snapshot_ts`` in Unix milliseconds.""" + block_number: int mid_price: float total_long: float @@ -923,7 +1068,7 @@ class TriggerLevelBucket(BaseModel): """Ask-side trigger size in the bucket.""" -class TriggerLevels(BaseModel): +class TriggerLevels(_ApiRecord): """Currently pending stop-loss and take-profit trigger orders. Grouped into price buckets near the current mid/mark price. Voluntary @@ -955,6 +1100,11 @@ class TriggerLevelsHistoryItem(BaseModel): """ snapshot_ts: str + """UTC snapshot time, as an RFC 3339 string.""" + + snapshot_ts_ms: Optional[int] = None + """``snapshot_ts`` in Unix milliseconds.""" + mid_price: float total_bid_size: float total_ask_size: float @@ -976,7 +1126,7 @@ class DataTypeFreshness(BaseModel): """Lag in milliseconds from real-time.""" -class CoinFreshness(BaseModel): +class CoinFreshness(_ApiRecord): """Per-coin freshness across all data types.""" coin: str @@ -1009,7 +1159,7 @@ class CoinFreshness(BaseModel): # ============================================================================= -class CoinSummary(BaseModel): +class CoinSummary(_ApiRecord): """Combined market summary for a coin.""" coin: str @@ -1124,42 +1274,33 @@ class Candle(BaseModel): ] """Available WebSocket channels. -Notes: -- ticker/all_tickers are real-time only. -- liquidations and hip3_liquidations now stream live (realtime + replay). - Each item shares the trades wire shape (a fill row with ``is_liquidation: true``). -- open_interest and funding (Hyperliquid core) support live subscriptions - and historical replay. -- hip3_open_interest, hip3_funding are historical only (replay). -- lighter_orderbook, lighter_trades, lighter_open_interest and lighter_funding - support live subscriptions and historical replay. Live messages use the - Hyperliquid-style shapes described on :class:`LighterLiveTrade` and - :class:`LighterMarketContext`; replay rows keep their historical shapes. -- lighter_candles and lighter_l3_orderbook support historical replay only. -- rh_lighter_orderbook, rh_lighter_trades, rh_lighter_open_interest and - rh_lighter_funding (Lighter on Robinhood Chain) support live subscriptions - and historical replay, with the same live message shapes as the mainnet - ``lighter_*`` channels. rh_lighter_orderbook accepts ``interval_ms`` like - lighter_orderbook. rh_lighter_candles supports historical replay only. Live - Robinhood Chain channels are served on wss://api.0xarchive.io/ws only. -- l4_diffs, l4_orders: Hyperliquid core L4 order-level data. Historical replay - emits one ``l4_snapshot`` followed by ordered ``l4_batch`` messages. -- hip3_l4_diffs, hip3_l4_orders: HIP-3 L4 order-level data (live-only). -- orderbook_full, hip3_orderbook_full: full-depth L2 books (every price level) - for Hyperliquid core and HIP-3, aggregated from the order-level book. Live - subscriptions open with an ``l4_snapshot`` of the whole book, followed by - ``l4_batch`` messages of changed levels. Live-only: replay is rejected with - ``ValueError``; stored full-depth history is served by REST - ``l2_orderbook.history()`` and ``l2_orderbook.diffs()``. -- hip4_trades: HIP-4 outcome-market fills (realtime + replay). -- hip4_orderbook, hip4_open_interest: stored replay only; live bridges paused. -- hip4_l4_diffs, hip4_l4_orders: HIP-4 L4 order-level data (live-only). -- HIP-4 has no funding or liquidation channels. Candles are served through REST. -- spot_orderbook, spot_trades, spot_twap: Hyperliquid spot (realtime). -- spot_l4_diffs, spot_l4_orders: Hyperliquid spot L4 (live-only). -- Spot has no funding / open interest / liquidations WebSocket channels. - Candle history is REST-only; subscribe to the supported ``spot_*`` realtime - channels for live spot streams. +Which channels stream live and which replay is listed per channel in +``oxarchive.websocket.WS_CHANNELS``, which mirrors ``GET /v1/capabilities`` +(``client.capabilities()``). In short: + +- Live and replay: ``orderbook``, ``trades``, ``liquidations``, + ``open_interest``, ``funding`` and their ``hip3_*`` counterparts; + ``hip4_orderbook``, ``hip4_trades``, ``hip4_open_interest``; the four live + ``lighter_*`` and ``rh_lighter_*`` channels; every L4 channel + (``l4_diffs``, ``l4_orders`` and the ``hip3_``, ``hip4_`` and ``spot_`` + versions); and the full-depth L2 channels ``orderbook_full`` and + ``hip3_orderbook_full``. +- Replay only: ``candles``, ``hip3_candles``, ``lighter_candles``, + ``lighter_l3_orderbook`` and ``rh_lighter_candles``. +- Live only: ``ticker``, ``all_tickers``, ``spot_orderbook``, ``spot_trades`` + and ``spot_twap``. + +L4 and full-depth replay is bulk: it is single-channel only, ``speed`` is +ignored and ``replay.seek`` is refused. It opens with one ``l4_snapshot`` from +the nearest checkpoint at or before ``start``, followed by ordered +``l4_batch`` messages. Full-depth replay batches carry changed price levels, +exactly like the live channel. + +Liquidation items share the trades wire shape (a fill row with +``is_liquidation: true``). Lighter and Robinhood Chain live and replay +messages share one shape per channel (see :class:`LighterLiveTrade` and +:class:`LighterMarketContext`). HIP-4 has no funding or liquidation channels, +and spot has no funding, open interest or liquidation channels. """ WsConnectionState = Literal["connecting", "connected", "disconnected", "reconnecting"] @@ -1174,6 +1315,8 @@ class WsSubscribed(BaseModel): coin: Optional[str] = None symbol: Optional[str] = None """Symbol as the server echoes it (Lighter symbols are echoed uppercase).""" + version: Optional[str] = None + """The API version the connection selected (``2026-10-01`` with this SDK).""" class WsUnsubscribed(BaseModel): @@ -1193,10 +1336,22 @@ class WsPong(BaseModel): class WsError(BaseModel): - """Error from server.""" + """Error from server. + + ``error_code`` is the stable code to branch on (see :data:`ErrorCode`), + for example ``"invalid_parameter"``, ``"unsupported_for_venue"`` (the + channel does not offer that mode), ``"slow_consumer"`` (the connection + fell behind and messages were dropped: re-subscribe or restart the replay + to resync) or ``"endpoint_unsupported"`` (this endpoint does not serve the + channel; the message names the one that does). + """ + + model_config = ConfigDict(extra="allow") type: Literal["error"] message: str + error_code: Optional[str] = None + """Stable machine-readable code; ``None`` only from a server that predates it.""" class WsData(BaseModel): @@ -1218,7 +1373,7 @@ class WsData(BaseModel): class WsL4Snapshot(BaseModel): - """Initial L4 orderbook state for a live stream or core replay.""" + """Initial L4 (or full-depth L2) book state for a live stream or a replay.""" type: Literal["l4_snapshot"] channel: WsChannel @@ -1257,7 +1412,7 @@ class WsL4Batch(BaseModel): # ``lighter_orderbook`` books share the Hyperliquid live book shape # (``{coin, time, levels: [bids, asks]}``) and decode to :class:`OrderBook`. # Trades and market context carry Lighter-specific fields and are modelled here. -# Replay of the same channels keeps its historical row shapes. +# Replay of the same channels uses the same shapes. class LighterLiveTrade(BaseModel): @@ -1401,7 +1556,12 @@ class WsReplayStarted(BaseModel): end: int """End timestamp in milliseconds.""" speed: float - """Playback speed multiplier.""" + """Playback speed multiplier. Echoed but not applied on the bulk L4 and + full-depth replays.""" + symbol: Optional[str] = None + """Symbol as the server echoes it.""" + version: Optional[str] = None + """The API version the connection selected (``2026-10-01`` with this SDK).""" class WsReplayPaused(BaseModel): @@ -1440,13 +1600,18 @@ class WsReplayStopped(BaseModel): class WsHistoricalData(BaseModel): - """Historical data point (replay mode).""" + """Historical data point (replay mode). + + ``data`` has the live shape of the channel. On ``lighter_trades`` and + ``rh_lighter_trades`` it is a list with one trade leg (see + :class:`LighterLiveTrade`); elsewhere it is an object. + """ type: Literal["historical_data"] channel: WsChannel coin: str timestamp: int - data: dict[str, Any] + data: Union[dict[str, Any], list[dict[str, Any]]] class WsReplaySnapshot(BaseModel): @@ -1463,8 +1628,9 @@ class WsReplaySnapshot(BaseModel): coin: str timestamp: int """Timestamp of the snapshot (ms).""" - data: dict[str, Any] - """Initial state data for this channel.""" + data: Union[dict[str, Any], list[dict[str, Any]]] + """Initial state data for this channel, in the channel's live shape (a + list of trade legs on the Lighter trade channels).""" class OrderbookDelta(BaseModel): @@ -1489,7 +1655,7 @@ class OrderbookDelta(BaseModel): class WsHistoricalTickData(BaseModel): """Historical tick data (granularity='tick' mode) - checkpoint + deltas. - This message type is sent when using granularity='tick' for Lighter.xyz + This message type is sent when using granularity='tick' for Lighter orderbook data. It provides a full checkpoint followed by incremental deltas. """ @@ -1767,18 +1933,114 @@ class Web3SubscribeResult(BaseModel): # ============================================================================= +def _code_for_status(status: int) -> Optional[str]: + """The ``error_code`` for an error body that carries none, from its HTTP status. + + Mirrors the API's own fallback, so a proxy error page (a 502 with an HTML + body, say) still gets a code to branch on. + """ + if status < 400: + return None + if status == 401: + return "unauthorized" + if status == 402: + return "insufficient_credits" + if status == 403: + return "forbidden" + if status in (404, 410): + return "not_found" + if status == 405: + return "route_not_found" + if status == 409: + return "conflict" + if status == 429: + return "rate_limited" + if 502 <= status <= 504: + return "upstream_unavailable" + if status >= 500: + return "internal_error" + return "invalid_parameter" + + class OxArchiveError(Exception): - """SDK error class.""" + """An error from the API, or a request the SDK refused before sending. + + Attributes: + message: The error message. + code: The HTTP status (``0`` for a network error). Also available as + :attr:`status`. + request_id: The API's request id, when the response carried one. + error_code: The stable, machine-readable code (see + :data:`ErrorCode`), for example ``"invalid_parameter"`` or + ``"unsupported_for_venue"``. Branch on it rather than on + ``message``. ``None`` for network errors. When an error body + carries no code (a proxy error page, for example), it is derived + from the HTTP status the way the API derives it. + param: The parameter that was refused, when the API names one. + valid_values: The values that parameter accepts, when the API lists + them. + details: The whole error body as the API sent it, including any + route-specific fields (``available_on`` on + ``unsupported_for_venue``, for example). Empty when there was no + JSON body. + """ - def __init__(self, message: str, code: int, request_id: Optional[str] = None): + def __init__( + self, + message: str, + code: int, + request_id: Optional[str] = None, + *, + error_code: Optional[str] = None, + param: Optional[str] = None, + valid_values: Optional[list[Any]] = None, + details: Optional[dict[str, Any]] = None, + ): super().__init__(message) self.message = message self.code = code self.request_id = request_id + self.error_code = error_code + self.param = param + self.valid_values = valid_values + self.details: dict[str, Any] = dict(details or {}) + + @property + def status(self) -> int: + """The HTTP status (``0`` for a network error); the same value as ``code``.""" + return self.code + + @classmethod + def from_response(cls, status: int, body: Any) -> "OxArchiveError": + """Build the error for a non-2xx response from its status and JSON body.""" + details = body if isinstance(body, dict) else {} + message = details.get("error") or details.get("message") + if not isinstance(message, str) or not message: + message = f"Request failed with status {status}" + error_code = details.get("error_code") + if not isinstance(error_code, str) or not error_code: + error_code = _code_for_status(status) + valid_values = details.get("valid_values") + param = details.get("param") + request_id = details.get("request_id") + return cls( + message, + status, + request_id if isinstance(request_id, str) else None, + error_code=error_code, + param=param if isinstance(param, str) else None, + valid_values=valid_values if isinstance(valid_values, list) else None, + details=details, + ) def __str__(self) -> str: + context = [] + if self.error_code: + context.append(f"error_code: {self.error_code}") if self.request_id: - return f"[{self.code}] {self.message} (request_id: {self.request_id})" + context.append(f"request_id: {self.request_id}") + if context: + return f"[{self.code}] {self.message} ({', '.join(context)})" return f"[{self.code}] {self.message}" @@ -1803,7 +2065,10 @@ class LighterLiquidation(BaseModel): symbol: str """Market symbol.""" - timestamp: int + timestamp: datetime + """Trade time (UTC).""" + + timestamp_ms: Optional[int] = None """Trade time in Unix milliseconds.""" transaction_time_us: Optional[int] = None @@ -1897,6 +2162,11 @@ class LighterLiquidation(BaseModel): from the venue's finalized export, which carry an empty ``raw_json``; on Robinhood Chain these cover the span before live capture.""" + @model_validator(mode="before") + @classmethod + def _timestamp_ms(cls, values: Any) -> Any: + return _ms_from_legacy(values, "timestamp") + class LighterLiquidationVolume(BaseModel): """Aggregated Lighter liquidation volume for one time bucket. @@ -1908,7 +2178,10 @@ class LighterLiquidationVolume(BaseModel): symbol: str """Market symbol.""" - timestamp: int + timestamp: datetime + """Bucket start (UTC).""" + + timestamp_ms: Optional[int] = None """Bucket start in Unix milliseconds.""" total_usd: float @@ -1917,6 +2190,11 @@ class LighterLiquidationVolume(BaseModel): count: int """Number of liquidation trades in the bucket.""" + @model_validator(mode="before") + @classmethod + def _timestamp_ms(cls, values: Any) -> Any: + return _ms_from_legacy(values, "timestamp") + # ============================================================================= # Account Positions Types @@ -2335,8 +2613,22 @@ class ResponseMeta(BaseModel): next_cursor: Optional[str] = None """Cursor for the next page; ``None`` on the last page.""" + has_more: Optional[bool] = None + """On cursor-paged routes, whether another page follows. ``next_cursor`` + is set exactly when it is ``True``. A final page can be empty when the + page before it was exactly full.""" + request_id: Optional[str] = None + symbol: Optional[str] = None + """On per-symbol routes, the canonical public symbol the response is for + (for example ``BTC``, ``km:US500``, ``HYPE-USDC``, ``#0``).""" + + venue: Optional[str] = None + """On per-symbol routes, the venue that served it: ``"hyperliquid"``, + ``"hip3"``, ``"hip4"``, ``"spot"``, ``"lighter"`` or ``"rh-lighter"`` + (see :data:`Venue`).""" + finalized_through: Optional[datetime] = None """Every event before this instant is final and will not be re-derived. On Lighter trades, the canonical boundary; on account positions, how far the @@ -2403,24 +2695,99 @@ class ResponseMeta(BaseModel): def _empty_instant_is_none(cls, value: Any) -> Any: return None if value == "" else value + @classmethod + def of(cls, envelope: Any) -> "ResponseMeta": + """The typed ``meta`` of a response envelope (empty when it has none).""" + meta = envelope.get("meta") if isinstance(envelope, dict) else None + return cls.model_validate(meta if isinstance(meta, dict) else {}) + # ============================================================================= # Pagination Types # ============================================================================= +RecordT = TypeVar("RecordT", bound=_ApiRecord) + + +def _record(model: type[RecordT], envelope: dict[str, Any]) -> RecordT: + """Parse ``envelope["data"]`` as ``model`` and attach the response's meta.""" + record = model.model_validate(envelope["data"]) + record._response_meta = ResponseMeta.of(envelope) + return record + + +def _body(envelope: dict[str, Any]) -> Any: + """The payload of a data quality, status coverage or symbols response. + + With the ``0xArchive-Version`` the SDK sends, these routes answer with the + standard envelope and the payload is ``data``. A body in the older shape + has the payload's fields at the top level instead; it is returned as is. + """ + data = envelope.get("data") + return data if isinstance(data, (dict, list)) and "success" in envelope else envelope + + +def _outlier_record(model: type[RecordT], envelope: dict[str, Any]) -> RecordT: + """Parse a data quality style response as ``model`` and attach its meta.""" + record = model.model_validate(_body(envelope)) + record._response_meta = ResponseMeta.of(envelope) + return record + + class CursorResponse(BaseModel, Generic[T]): - """Response with cursor for pagination.""" + """One page of a cursor-paged response. + + While ``has_more`` is ``True``, pass ``next_cursor`` back unchanged as + ``cursor`` with the other arguments unchanged. Stop when ``has_more`` is + ``False``: a page can be short, or even empty, and still not be the last, + so stop on ``has_more`` rather than on the page size. + """ data: T """The paginated data.""" next_cursor: Optional[str] = None - """Cursor for the next page (use as cursor parameter).""" + """Cursor for the next page (use as cursor parameter). Set exactly when + ``has_more`` is ``True``.""" + + has_more: bool = False + """Whether another page follows. Taken from the response's + ``meta.has_more``; on a route that does not send it, ``True`` exactly when + ``next_cursor`` is set.""" meta: Optional[ResponseMeta] = None - """The response's metadata, where the method exposes it: account positions, - trades and Lighter liquidations. ``None`` elsewhere.""" + """The response's metadata: ``request_id``, ``count``, ``has_more``, and on + per-symbol routes ``symbol`` and ``venue``, plus route-specific fields such + as ``finalized_through`` (Lighter trades) or ``coverage_from`` and + ``notice`` (a window before coverage).""" + + @model_validator(mode="before") + @classmethod + def _page_state(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + meta = values.get("meta") + if isinstance(meta, ResponseMeta): + meta_cursor, meta_more = meta.next_cursor, meta.has_more + elif isinstance(meta, dict): + meta_cursor, meta_more = meta.get("next_cursor"), meta.get("has_more") + else: + meta_cursor, meta_more = None, None + values = dict(values) + if values.get("next_cursor") is None and meta_cursor is not None: + values["next_cursor"] = meta_cursor + if values.get("has_more") is None: + values["has_more"] = ( + bool(meta_more) if meta_more is not None else values.get("next_cursor") is not None + ) + return values + + @classmethod + def from_envelope(cls, envelope: dict[str, Any], data: Any) -> "CursorResponse[Any]": + """A page from a response envelope and its already-parsed ``data``.""" + meta = ResponseMeta.of(envelope) + return cls(data=data, next_cursor=meta.next_cursor, meta=meta) # Type alias for timestamp parameters @@ -2432,6 +2799,58 @@ class CursorResponse(BaseModel, Generic[T]): UTC) mean the same instant on every machine.""" +# ============================================================================= +# Capabilities +# ============================================================================= + + +class Capability(BaseModel): + """What one venue serves for one datatype, from ``client.capabilities()``. + + One row per venue and datatype. Coverage per symbol is on + ``client.symbols.list()``. + """ + + model_config = ConfigDict(extra="allow") + + venue: str + """The venue (see :data:`Venue`).""" + + datatype: str + """Datatype id, for example ``"trades"``, ``"l2_orderbook"``, + ``"l4_diffs"``, ``"funding"`` or ``"oi"``.""" + + rest_routes: list[str] = Field(default_factory=list) + """REST route templates that serve it, for example + ``/v1/hyperliquid/trades/{symbol}``.""" + + ws_channels: list[str] = Field(default_factory=list) + """WebSocket channels that carry it.""" + + live: bool = False + """``True`` when a WebSocket subscription streams it live.""" + + replay: bool = False + """``True`` when a WebSocket replay serves its history.""" + + available_from: Optional[datetime] = None + """The first served instant across the venue (UTC), or ``None`` where the + datatype has no venue-wide floor.""" + + cadence: Optional[str] = None + """``"event"``, ``"snapshot"``, ``"sample"``, ``"interval"`` or + ``"reference"``.""" + + page_limit: Optional[int] = None + """The largest ``limit`` one page accepts.""" + + intervals: list[str] = Field(default_factory=list) + """Values ``interval`` accepts, where the datatype is bucketed.""" + + notes: Optional[str] = None + """Anything else worth knowing, such as how its replay behaves.""" + + # ============================================================================= # Data Quality Types # ============================================================================= @@ -2466,7 +2885,7 @@ class DataTypeStatus(BaseModel): """Data completeness over last 24 hours (0-100).""" -class StatusResponse(BaseModel): +class StatusResponse(_ApiRecord): """Overall system status response.""" status: Literal["operational", "degraded", "outage", "maintenance"] @@ -2510,7 +2929,7 @@ class DataTypeCoverage(BaseModel): """Completeness percentage (0-100).""" -class ExchangeCoverage(BaseModel): +class ExchangeCoverage(_ApiRecord): """Coverage for a single exchange.""" exchange: str @@ -2520,7 +2939,7 @@ class ExchangeCoverage(BaseModel): """Coverage per data type.""" -class CoverageResponse(BaseModel): +class CoverageResponse(_ApiRecord): """Overall coverage response.""" exchanges: list[ExchangeCoverage] @@ -2578,7 +2997,7 @@ class SymbolDataTypeCoverage(BaseModel): """Empirical data cadence (present when sufficient data exists).""" -class SymbolCoverageResponse(BaseModel): +class SymbolCoverageResponse(_ApiRecord): """Per-symbol coverage response.""" exchange: str @@ -2591,7 +3010,7 @@ class SymbolCoverageResponse(BaseModel): """Coverage per data type.""" -class Incident(BaseModel): +class Incident(_ApiRecord): """Data quality incident.""" id: str @@ -2653,7 +3072,7 @@ class Pagination(BaseModel): """Current offset.""" -class IncidentsResponse(BaseModel): +class IncidentsResponse(_ApiRecord): """Incidents list response.""" incidents: list[Incident] @@ -2722,7 +3141,7 @@ class ExchangeLatency(BaseModel): """Data freshness metrics.""" -class LatencyResponse(BaseModel): +class LatencyResponse(_ApiRecord): """Overall latency response.""" measured_at: datetime @@ -2783,7 +3202,7 @@ class SlaActual(BaseModel): """'met' or 'missed'.""" -class SlaResponse(BaseModel): +class SlaResponse(_ApiRecord): """SLA compliance response.""" period: str @@ -2814,8 +3233,16 @@ class SlaResponse(BaseModel): class CvdBucket(BaseModel): """One cumulative volume delta bucket: taker buy and sell notional.""" - timestamp: int - """Bucket open time in Unix milliseconds (UTC).""" + timestamp: datetime + """Bucket open time (UTC).""" + + timestamp_ms: Optional[int] = None + """Bucket open time in Unix milliseconds.""" + + @model_validator(mode="before") + @classmethod + def _timestamp_ms(cls, values: Any) -> Any: + return _ms_from_legacy(values, "timestamp") buy_volume: float """Taker buy notional in the bucket.""" @@ -2836,7 +3263,7 @@ class CvdBucket(BaseModel): # ============================================================================= -class Hip3OracleDiscoveryBounds(BaseModel): +class Hip3OracleDiscoveryBounds(_ApiRecord): """Instantaneous HIP-3 discovery bounds from the reference price and max leverage. The full ratcheted range can be wider when a deployer's reset @@ -2867,11 +3294,19 @@ class Hip3OracleDiscoveryBounds(BaseModel): block_number: int """Source block number.""" - timestamp: int - """Source timestamp in Unix milliseconds.""" + timestamp: datetime + """Source time (UTC).""" + + timestamp_ms: Optional[int] = None + """Source time in Unix milliseconds.""" + @model_validator(mode="before") + @classmethod + def _timestamp_ms(cls, values: Any) -> Any: + return _ms_from_legacy(values, "timestamp") -class Hip3OracleExternalPrice(BaseModel): + +class Hip3OracleExternalPrice(_ApiRecord): """Latest deployer-pushed external price and mark price for a HIP-3 market.""" symbol: str @@ -2886,8 +3321,16 @@ class Hip3OracleExternalPrice(BaseModel): block_number: int """Source block number.""" - timestamp: int - """Source timestamp in Unix milliseconds.""" + timestamp: datetime + """Source time (UTC).""" + + timestamp_ms: Optional[int] = None + """Source time in Unix milliseconds.""" + + @model_validator(mode="before") + @classmethod + def _timestamp_ms(cls, values: Any) -> Any: + return _ms_from_legacy(values, "timestamp") # ============================================================================= @@ -2895,7 +3338,7 @@ class Hip3OracleExternalPrice(BaseModel): # ============================================================================= -class Hip4Question(BaseModel): +class Hip4Question(_ApiRecord): """A HIP-4 question: a multi-choice resolver grouping binary outcome markets. One named outcome per choice, plus a fallback outcome that resolves Yes @@ -3017,7 +3460,7 @@ class ClassifiedWallet(BaseModel): """Metric lookback period (for example ``"24h"``).""" -class WalletClassification(BaseModel): +class WalletClassification(_ApiRecord): """One page of wallet classification results.""" wallets: list[ClassifiedWallet] = Field(default_factory=list) diff --git a/oxarchive/websocket.py b/oxarchive/websocket.py index dc96327..4071eb3 100644 --- a/oxarchive/websocket.py +++ b/oxarchive/websocket.py @@ -13,6 +13,10 @@ >>> ws.on_orderbook(lambda coin, ob: print(f"{coin}: {ob.mid_price}")) >>> ws.subscribe_orderbook("BTC") + Every connection selects API version ``2026-10-01`` (the ``version`` + connection parameter): error messages carry ``error_code``, and Lighter + replay rows have the same shapes as the live messages. + Live Lighter data (books default to one per second): >>> ws.on_lighter_orderbook(lambda coin, ob: print(f"Lighter {coin}: {ob.mid_price}")) >>> ws.subscribe_lighter_orderbook("BTC", interval_ms=250) @@ -23,7 +27,7 @@ >>> ws.subscribe_rh_lighter_orderbook("AAPL-USDG") >>> ws.subscribe_rh_lighter_trades("BTC") - Historical replay (like Tardis.dev): + Historical replay: >>> ws = OxArchiveWs(WsOptions(api_key="ox_...")) >>> await ws.connect() >>> ws.on_historical_data(lambda coin, ts, data: print(f"{ts}: {data}")) @@ -47,7 +51,9 @@ import logging import warnings from dataclasses import dataclass -from typing import Any, Callable, Literal, Optional, Set, Union +from types import MappingProxyType +from typing import Any, Callable, Literal, Mapping, Optional, Set, Union +from urllib.parse import urlencode try: from websockets.asyncio.client import connect as ws_connect, ClientConnection @@ -59,6 +65,7 @@ "Install with: pip install oxarchive[websocket]" ) +from .http import API_VERSION from .types import ( LighterLiveTrade, LighterMarketContext, @@ -102,6 +109,113 @@ DEFAULT_RECONNECT_DELAY = 1.0 DEFAULT_MAX_RECONNECT_ATTEMPTS = 10 + +@dataclass(frozen=True) +class WsChannelSpec: + """What one WebSocket channel offers, as ``GET /v1/capabilities`` lists it. + + Attributes: + venue: The venue (``"hyperliquid"``, ``"hip3"``, ``"hip4"``, + ``"spot"``, ``"lighter"`` or ``"rh-lighter"``). + datatype: The capabilities datatype the channel carries. + live: A subscription streams it live. + replay: A replay serves its history. + bulk_replay: The replay is bulk: single-channel only (not part of a + multi-channel replay), ``speed`` is ignored and ``replay.seek`` is + refused. It opens with an ``l4_snapshot`` from the nearest + checkpoint at or before ``start`` and continues with ordered + ``l4_batch`` messages. + """ + + venue: str + datatype: str + live: bool + replay: bool + bulk_replay: bool = False + + +def _both(venue: str, datatype: str, *, bulk: bool = False) -> WsChannelSpec: + return WsChannelSpec(venue, datatype, live=True, replay=True, bulk_replay=bulk) + + +def _live_only(venue: str, datatype: str) -> WsChannelSpec: + return WsChannelSpec(venue, datatype, live=True, replay=False) + + +def _replay_only(venue: str, datatype: str) -> WsChannelSpec: + return WsChannelSpec(venue, datatype, live=False, replay=True) + + +WS_CHANNELS: Mapping[str, WsChannelSpec] = MappingProxyType( + { + # Hyperliquid core + "orderbook": _both("hyperliquid", "l2_orderbook"), + "orderbook_full": _both("hyperliquid", "l2_full_depth", bulk=True), + "l4_diffs": _both("hyperliquid", "l4_diffs", bulk=True), + "l4_orders": _both("hyperliquid", "l4_orders", bulk=True), + "trades": _both("hyperliquid", "trades"), + "candles": _replay_only("hyperliquid", "candles"), + "funding": _both("hyperliquid", "funding"), + "open_interest": _both("hyperliquid", "oi"), + "liquidations": _both("hyperliquid", "liquidations"), + "ticker": _live_only("hyperliquid", "ticker"), + "all_tickers": _live_only("hyperliquid", "ticker"), + # HIP-3 + "hip3_orderbook": _both("hip3", "l2_orderbook"), + "hip3_orderbook_full": _both("hip3", "l2_full_depth", bulk=True), + "hip3_l4_diffs": _both("hip3", "l4_diffs", bulk=True), + "hip3_l4_orders": _both("hip3", "l4_orders", bulk=True), + "hip3_trades": _both("hip3", "trades"), + "hip3_candles": _replay_only("hip3", "candles"), + "hip3_funding": _both("hip3", "funding"), + "hip3_open_interest": _both("hip3", "oi"), + "hip3_liquidations": _both("hip3", "liquidations"), + # HIP-4 + "hip4_orderbook": _both("hip4", "l2_orderbook"), + "hip4_l4_diffs": _both("hip4", "l4_diffs", bulk=True), + "hip4_l4_orders": _both("hip4", "l4_orders", bulk=True), + "hip4_trades": _both("hip4", "trades"), + "hip4_open_interest": _both("hip4", "oi"), + # Hyperliquid spot + "spot_orderbook": _live_only("spot", "l2_orderbook"), + "spot_l4_diffs": _both("spot", "l4_diffs", bulk=True), + "spot_l4_orders": _both("spot", "l4_orders", bulk=True), + "spot_trades": _live_only("spot", "trades"), + "spot_twap": _live_only("spot", "twap"), + # Lighter + "lighter_orderbook": _both("lighter", "l2_orderbook"), + "lighter_l3_orderbook": _replay_only("lighter", "l3_orderbook"), + "lighter_trades": _both("lighter", "trades"), + "lighter_candles": _replay_only("lighter", "candles"), + "lighter_funding": _both("lighter", "funding"), + "lighter_open_interest": _both("lighter", "oi"), + # Lighter on Robinhood Chain + "rh_lighter_orderbook": _both("rh-lighter", "l2_orderbook"), + "rh_lighter_trades": _both("rh-lighter", "trades"), + "rh_lighter_candles": _replay_only("rh-lighter", "candles"), + "rh_lighter_funding": _both("rh-lighter", "funding"), + "rh_lighter_open_interest": _both("rh-lighter", "oi"), + } +) +"""Every WebSocket channel and what it offers, mirroring ``GET /v1/capabilities`` +(``client.capabilities()``, the ``ws_channels``, ``live`` and ``replay`` of +each row). The SDK refuses, before sending, a live subscription to a channel +whose ``live`` is false, a replay of a channel whose ``replay`` is false, and a +bulk-replay channel inside :meth:`OxArchiveWs.multi_replay`. A channel missing +from this table is sent as given and the server decides.""" + +LIVE_CHANNELS: frozenset[str] = frozenset(c for c, spec in WS_CHANNELS.items() if spec.live) +"""Channels a subscription streams live.""" + +REPLAY_CHANNELS: frozenset[str] = frozenset(c for c, spec in WS_CHANNELS.items() if spec.replay) +"""Channels a replay serves.""" + +BULK_REPLAY_CHANNELS: frozenset[str] = frozenset( + c for c, spec in WS_CHANNELS.items() if spec.bulk_replay +) +"""Channels whose replay is bulk: single-channel only, ``speed`` ignored, +``replay.seek`` refused (every L4 channel and both full-depth L2 channels).""" + LIGHTER_LIVE_CHANNELS: frozenset[str] = frozenset( { "lighter_orderbook", @@ -168,10 +282,13 @@ """Book interval the server uses when ``interval_ms`` is left out.""" CORE_L4_REPLAY_CHANNELS: frozenset[str] = frozenset({"l4_diffs", "l4_orders"}) -"""Hyperliquid core L4 channels whose replay starts with a snapshot.""" +"""Hyperliquid core L4 channels. Every L4 channel now replays; see +:data:`L4_REPLAY_CHANNELS`.""" -L4_LIVE_ONLY_CHANNELS: frozenset[str] = frozenset( +L4_REPLAY_CHANNELS: frozenset[str] = frozenset( { + "l4_diffs", + "l4_orders", "hip3_l4_diffs", "hip3_l4_orders", "hip4_l4_diffs", @@ -180,24 +297,42 @@ "spot_l4_orders", } ) -"""HIP-3, HIP-4, and Hyperliquid Spot L4 channels without replay support.""" +"""L4 channels (Hyperliquid core, HIP-3, HIP-4 and spot). Each streams live +and replays: a replay opens with one ``l4_snapshot`` and continues with ordered +``l4_batch`` messages.""" + +L4_LIVE_ONLY_CHANNELS: frozenset[str] = frozenset() +"""Deprecated and empty: every L4 channel now replays (:data:`L4_REPLAY_CHANNELS`).""" + +L4_LIVE_ONLY_ERROR = ( + "Deprecated: every L4 channel now supports historical replay. " + "This message is no longer raised." +) +"""Deprecated: no longer raised.""" FULL_DEPTH_L2_CHANNELS: frozenset[str] = frozenset({"orderbook_full", "hip3_orderbook_full"}) """Full-depth L2 book channels (Hyperliquid core and HIP-3), aggregated from -the order-level book. Live subscriptions only: a subscription opens with an +the order-level book. A live subscription or a replay opens with an ``l4_snapshot`` of every price level, followed by ``l4_batch`` messages of changed levels.""" -FULL_DEPTH_LIVE_ONLY_ERROR = ( - "orderbook_full and hip3_orderbook_full support live subscriptions only. " - "Stored full-depth history is served over REST by l2_orderbook.history() " - "and l2_orderbook.diffs()." +BULK_REPLAY_MULTI_CHANNEL_ERROR = ( + "{channel} replays on its own: L4 and full-depth replay is single-channel only. " + "Call replay() for it, separately from the other channels." ) +"""Raised by :meth:`OxArchiveWs.multi_replay` for a bulk-replay channel.""" -L4_LIVE_ONLY_ERROR = ( - "HIP-3, HIP-4, and Hyperliquid Spot L4 channels support live subscriptions only. " - "Use subscribe for current streams." +NOT_REPLAYABLE_ERROR = ( + "{channel} does not support historical replay; it streams live only. " + "client.capabilities() lists what each channel offers." +) +"""Raised by :meth:`OxArchiveWs.replay` for a channel without replay.""" + +NOT_LIVE_ERROR = ( + "{channel} supports replay, not live subscriptions. " + "Use REST for current data or a replay request for stored history." ) +"""Raised by :meth:`OxArchiveWs.subscribe` for a replay-only channel.""" # Large dataset downloads: the S3 Parquet bulk export. @@ -213,30 +348,39 @@ def _bulk_stream_deprecation(name: str) -> str: ) -def _validate_historical_l4_channel(channel: WsChannel) -> None: - """Reject historical operations for live-only channels, before anything is sent. +def _validate_replay_channel(channel: WsChannel, *, multi: bool = False) -> None: + """Reject replays :data:`WS_CHANNELS` says the server refuses, before anything is sent. - Covers the HIP-3, HIP-4 and Hyperliquid Spot L4 channels and the - full-depth L2 channels. + A channel without replay is refused, and so is a bulk-replay channel + (L4 and full depth) inside a multi-channel replay. A channel the table + does not list is left to the server. """ - if channel in L4_LIVE_ONLY_CHANNELS: - raise ValueError(L4_LIVE_ONLY_ERROR) - if channel in FULL_DEPTH_L2_CHANNELS: - raise ValueError(FULL_DEPTH_LIVE_ONLY_ERROR) + spec = WS_CHANNELS.get(channel) + if spec is None: + return + if not spec.replay: + raise ValueError(NOT_REPLAYABLE_ERROR.format(channel=channel)) + if multi and spec.bulk_replay: + raise ValueError(BULK_REPLAY_MULTI_CHANNEL_ERROR.format(channel=channel)) def _validate_live_subscription(channel: WsChannel, interval_ms: Optional[int] = None) -> None: """Reject live subscriptions the server would refuse, before any state changes. - ``lighter_candles``, ``lighter_l3_orderbook`` and ``rh_lighter_candles`` - are replay-only, and ``interval_ms`` is accepted only on - ``lighter_orderbook`` and ``rh_lighter_orderbook``, as an integer between + Replay-only channels (:data:`WS_CHANNELS` entries whose ``live`` is false: + ``candles``, ``hip3_candles``, ``lighter_candles``, + ``lighter_l3_orderbook`` and ``rh_lighter_candles``) are refused, and + ``interval_ms`` is accepted only on ``lighter_orderbook`` and + ``rh_lighter_orderbook``, as an integer between ``LIGHTER_BOOK_INTERVAL_MIN_MS`` and ``LIGHTER_BOOK_INTERVAL_MAX_MS``. """ if channel in LIGHTER_REPLAY_ONLY_CHANNELS: raise ValueError(LIGHTER_SUBSCRIPTION_ERROR) if channel in RH_LIGHTER_REPLAY_ONLY_CHANNELS: raise ValueError(RH_LIGHTER_SUBSCRIPTION_ERROR) + spec = WS_CHANNELS.get(channel) + if spec is not None and not spec.live: + raise ValueError(NOT_LIVE_ERROR.format(channel=channel)) if interval_ms is None: return if channel not in LIGHTER_BOOK_CHANNELS: @@ -312,7 +456,8 @@ class WsOptions: ErrorHandler = Callable[[Exception], None] # Replay handlers -HistoricalDataHandler = Callable[[str, int, dict], None] +HistoricalDataHandler = Callable[[str, int, Any], None] # coin, timestamp, data +ReplayDataHandler = Callable[[WsChannel, str, int, Any], None] # channel, coin, timestamp, record HistoricalTickDataHandler = Callable[[str, dict, list[OrderbookDelta]], None] # coin, checkpoint, deltas ReplaySnapshotHandler = Callable[[WsChannel, str, int, dict], None] # channel, coin, timestamp, data ReplayStartHandler = Callable[[WsChannel, str, int, int, float], None] # channel, coin, start, end, speed @@ -443,6 +588,36 @@ def _transform_lighter_market_context(coin: str, raw: Any) -> LighterMarketConte ) +def decode_lighter_payload(channel: str, coin: str, data: Any) -> Any: + """Decode a Lighter or Lighter on Robinhood Chain payload with the live parsers. + + Live and replayed messages of these channels share one shape, so the same + decoding applies to ``data`` from a live message, a ``historical_data`` + message and a ``replay_snapshot``: + + - ``lighter_orderbook``, ``rh_lighter_orderbook``: :class:`OrderBook` + - ``lighter_trades``, ``rh_lighter_trades``: ``list[Trade]``, one per leg + - ``lighter_open_interest``, ``lighter_funding`` and their + ``rh_lighter_*`` counterparts: :class:`LighterMarketContextUpdate` + + Any other channel's payload (``lighter_candles``, + ``lighter_l3_orderbook``, ``rh_lighter_candles`` and every non-Lighter + channel) is returned unchanged. + """ + if channel in ("lighter_orderbook", "rh_lighter_orderbook") and isinstance(data, dict): + return _transform_orderbook(coin, data) + if channel in ("lighter_trades", "rh_lighter_trades"): + return _transform_lighter_trades(coin, data) + if channel in ( + "lighter_open_interest", + "lighter_funding", + "rh_lighter_open_interest", + "rh_lighter_funding", + ) and isinstance(data, dict): + return _transform_lighter_market_context(coin, data) + return data + + def _transform_liquidation(coin: str, raw: dict) -> Liquidation: """Transform a single liquidation fill row to a Liquidation. @@ -605,6 +780,7 @@ def __init__(self, options: WsOptions): # Replay handlers (Option B) self._on_historical_data: Optional[HistoricalDataHandler] = None + self._on_replay_data: Optional[ReplayDataHandler] = None self._on_historical_tick_data: Optional[HistoricalTickDataHandler] = None self._on_replay_snapshot: Optional[ReplaySnapshotHandler] = None self._on_replay_start: Optional[ReplayStartHandler] = None @@ -638,7 +814,7 @@ async def _connect(self) -> None: """Internal connect method.""" self._set_state("connecting") - url = f"{self.options.ws_url}?apiKey={self.options.api_key}" + url = self._connection_url() try: # Increase max_size to 50MB for large Lighter orderbook data with high granularity @@ -666,6 +842,12 @@ async def _connect(self) -> None: else: self._set_state("disconnected") + def _connection_url(self) -> str: + """The connection URL: ``ws_url`` plus the API key and the API version.""" + separator = "&" if "?" in self.options.ws_url else "?" + query = urlencode({"apiKey": self.options.api_key, "version": API_VERSION}) + return f"{self.options.ws_url}{separator}{query}" + async def disconnect(self) -> None: """Disconnect from the WebSocket server.""" self._running = False @@ -701,10 +883,12 @@ def subscribe( second. Each book sent is one metered message. Raises: - ValueError: If ``channel`` is ``lighter_candles``, - ``lighter_l3_orderbook`` or ``rh_lighter_candles`` - (replay-only), or if ``interval_ms`` is passed for another - channel, is not an integer, or is out of range. + ValueError: If ``channel`` is replay-only in :data:`WS_CHANNELS` + (``candles``, ``hip3_candles``, ``lighter_candles``, + ``lighter_l3_orderbook`` or ``rh_lighter_candles``), or if + ``interval_ms`` is passed for another channel than + ``lighter_orderbook`` or ``rh_lighter_orderbook``, is not an + integer, or is out of range. """ _validate_live_subscription(channel, interval_ms) self._remember_subscription(channel, coin, interval_ms) @@ -721,9 +905,8 @@ async def subscribe_async( ) -> None: """Subscribe asynchronously to a supported live channel. - Takes the same arguments as :meth:`subscribe`. ``lighter_candles``, - ``lighter_l3_orderbook`` and ``rh_lighter_candles`` are replay-only; - use REST for current data or :meth:`replay` for stored history. + Takes the same arguments as :meth:`subscribe` and refuses the same + replay-only channels. """ _validate_live_subscription(channel, interval_ms) self._remember_subscription(channel, coin, interval_ms) @@ -816,7 +999,8 @@ def subscribe_orderbook_full(self, coin: str) -> None: are changed levels ``{"side", "px", "sz", "n", "bn"}``; a level whose ``sz`` is 0 has been removed. Receive them with :meth:`on_l4_snapshot` and :meth:`on_l4_batch`, applying batches in the order received. - Live only: stored full-depth history is served over REST by + Stored history replays over the same messages + (``replay("orderbook_full", ...)``) and is also served over REST by ``l2_orderbook.history()`` and ``l2_orderbook.diffs()``. """ self.subscribe("orderbook_full", coin) @@ -839,17 +1023,16 @@ def unsubscribe_hip3_orderbook_full(self, coin: str) -> None: # -- HIP-4 outcome markets ----------------------------------------------- def subscribe_hip4_orderbook(self, coin: str) -> None: - """Subscribe to HIP-4 L2 replay for a per-side coin. + """Subscribe to the live HIP-4 L2 book for a per-side coin. - Live delivery is paused. Use REST for current books and WebSocket replay - for stored history. ``coin`` should be the on-chain ``#N`` form (e.g. - ``"#0"``). The raw ``#`` is sent in the JSON body; only the REST path - strips it. + ``coin`` should be the on-chain ``#N`` form (e.g. ``"#0"``). The raw + ``#`` is sent in the JSON body; only the REST path strips it. Stored + history replays on the same channel. """ self.subscribe("hip4_orderbook", coin) def unsubscribe_hip4_orderbook(self, coin: str) -> None: - """Unsubscribe from HIP-4 L2 orderbook replay for a per-side coin.""" + """Unsubscribe from the live HIP-4 L2 book for a per-side coin.""" self.unsubscribe("hip4_orderbook", coin) def subscribe_hip4_trades(self, coin: str) -> None: @@ -861,9 +1044,9 @@ def unsubscribe_hip4_trades(self, coin: str) -> None: self.unsubscribe("hip4_trades", coin) def subscribe_hip4_open_interest(self, coin: str) -> None: - """Subscribe to HIP-4 OI; live delivery is paused. + """Subscribe to live HIP-4 per-side open interest for a coin. - Use REST for current OI and WebSocket replay for stored history. + Stored history replays on the same channel. """ self.subscribe("hip4_open_interest", coin) @@ -872,7 +1055,7 @@ def unsubscribe_hip4_open_interest(self, coin: str) -> None: self.unsubscribe("hip4_open_interest", coin) def subscribe_hip4_l4_diffs(self, coin: str) -> None: - """Subscribe to HIP-4 L4 orderbook diffs (realtime only). + """Subscribe to live HIP-4 L4 orderbook diffs (history replays too). On subscribe the server first pushes an ``l4_snapshot`` followed by a stream of ``l4_batch`` messages. @@ -884,7 +1067,7 @@ def unsubscribe_hip4_l4_diffs(self, coin: str) -> None: self.unsubscribe("hip4_l4_diffs", coin) def subscribe_hip4_l4_orders(self, coin: str) -> None: - """Subscribe to HIP-4 L4 order lifecycle events (realtime only).""" + """Subscribe to live HIP-4 L4 order lifecycle events (history replays too).""" self.subscribe("hip4_l4_orders", coin) def unsubscribe_hip4_l4_orders(self, coin: str) -> None: @@ -914,7 +1097,7 @@ def unsubscribe_spot_trades(self, coin: str) -> None: self.unsubscribe("spot_trades", coin) def subscribe_spot_l4_diffs(self, coin: str) -> None: - """Subscribe to spot L4 orderbook diffs (realtime only). + """Subscribe to live spot L4 orderbook diffs (history replays too). On subscribe the server first pushes an initial L4 snapshot followed by a stream of batched diff messages. @@ -926,7 +1109,7 @@ def unsubscribe_spot_l4_diffs(self, coin: str) -> None: self.unsubscribe("spot_l4_diffs", coin) def subscribe_spot_l4_orders(self, coin: str) -> None: - """Subscribe to spot L4 order lifecycle events (realtime only).""" + """Subscribe to live spot L4 order lifecycle events (history replays too).""" self.subscribe("spot_l4_orders", coin) def unsubscribe_spot_l4_orders(self, coin: str) -> None: @@ -941,7 +1124,7 @@ def unsubscribe_spot_twap(self, coin: str) -> None: """Unsubscribe from live spot TWAP status updates for a pair.""" self.unsubscribe("spot_twap", coin) - # -- Lighter.xyz (live) --------------------------------------------------- + # -- Lighter (live) --------------------------------------------------- def subscribe_lighter_orderbook(self, coin: str, interval_ms: Optional[int] = None) -> None: """Subscribe to live Lighter L2 books for a market. @@ -1090,15 +1273,25 @@ async def replay( ) -> None: """Start historical replay with timing preserved. - Hyperliquid core ``l4_diffs`` and ``l4_orders`` replay as one typed - ``l4_snapshot`` followed by ordered ``l4_batch`` messages. HIP-3, - HIP-4, and Hyperliquid Spot L4 channels and the full-depth L2 channels - (``orderbook_full``, ``hip3_orderbook_full``) are live-only and are - rejected here with ``ValueError`` before anything is sent. All six - ``lighter_*`` channels and all five ``rh_lighter_*`` channels (Lighter - on Robinhood Chain; ``rh_lighter_candles`` is replay-only) support - bounded historical replay. Replay rows keep their - historical shapes, which differ from the live Lighter messages. + Every channel whose :data:`WS_CHANNELS` entry has ``replay`` replays; + the live-only channels (``ticker``, ``all_tickers``, + ``spot_orderbook``, ``spot_trades``, ``spot_twap``) are refused here + with ``ValueError`` before anything is sent. + + L4 channels (``l4_diffs``, ``l4_orders`` and the ``hip3_``, ``hip4_`` + and ``spot_`` versions) and the full-depth L2 channels + (``orderbook_full``, ``hip3_orderbook_full``) replay in bulk: one + ``l4_snapshot`` from the nearest checkpoint at or before ``start``, + then ordered ``l4_batch`` messages until ``end``, as fast as the + connection takes them (``speed`` is ignored and :meth:`replay_seek` is + refused). Full-depth batches carry changed price levels, like the live + channel. Receive them with :meth:`on_l4_snapshot` and + :meth:`on_l4_batch`. + + Every other replay arrives on :meth:`on_historical_data` and + :meth:`on_replay_data`. Lighter and Robinhood Chain replay rows have + the same shapes as the live messages (a list with one trade leg on the + trade channels); :meth:`on_replay_data` decodes them. Args: channel: Data channel to replay @@ -1111,10 +1304,13 @@ async def replay( Example: >>> await ws.replay("orderbook", "BTC", start=time.time()*1000 - 86400000, speed=10) - >>> await ws.replay("l4_diffs", "BTC", start=..., speed=10) + >>> await ws.replay("hip3_l4_diffs", "xyz:TSLA", start=..., end=...) >>> await ws.replay("candles", "BTC", start=..., speed=10, interval="15m") + + Raises: + ValueError: If ``channel`` does not replay (see :data:`WS_CHANNELS`). """ - _validate_historical_l4_channel(channel) + _validate_replay_channel(channel) msg = { "op": "replay", "channel": channel, @@ -1141,6 +1337,11 @@ async def replay_resume(self) -> None: async def replay_seek(self, timestamp: int) -> None: """Seek to a specific timestamp in the replay. + Not available on the bulk L4 and full-depth replays: the server answers + with a :class:`~oxarchive.types.WsError` whose ``error_code`` is + ``"unsupported_for_venue"``. Restart the replay at the new start + instead. + Args: timestamp: Unix timestamp in milliseconds """ @@ -1170,6 +1371,9 @@ async def multi_replay( ``channel`` field on each ``historical_data`` message indicates which channel the record belongs to. + All channels must belong to one venue. The bulk L4 and full-depth + channels replay on their own (see :meth:`replay`) and are refused here. + Args: channels: List of channels to replay together (e.g., ``["orderbook", "trades", "funding"]``). @@ -1178,6 +1382,10 @@ async def multi_replay( end: End timestamp (Unix ms, defaults to now). speed: Playback speed multiplier (1 = real-time, 10 = 10x faster). + Raises: + ValueError: If a channel does not replay, or replays only on its + own (see :data:`WS_CHANNELS`). + Example: >>> await ws.multi_replay( ... ["orderbook", "trades", "funding"], @@ -1187,7 +1395,7 @@ async def multi_replay( ... ) """ for channel in channels: - _validate_historical_l4_channel(channel) + _validate_replay_channel(channel, multi=True) msg: dict[str, Any] = { "op": "replay", @@ -1236,7 +1444,6 @@ async def stream( interval: Candle interval for candles channel ('1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w') """ warnings.warn(_bulk_stream_deprecation("stream"), DeprecationWarning, stacklevel=2) - _validate_historical_l4_channel(channel) msg = { "op": "stream", "channel": channel, @@ -1295,9 +1502,6 @@ async def multi_stream( batch_size: Records per batch message. """ warnings.warn(_bulk_stream_deprecation("multi_stream"), DeprecationWarning, stacklevel=2) - for channel in channels: - _validate_historical_l4_channel(channel) - msg: dict[str, Any] = { "op": "stream", "channels": channels, @@ -1439,14 +1643,38 @@ def on_close(self, handler: Callable[[int, str], None]) -> None: def on_historical_data(self, handler: HistoricalDataHandler) -> None: """Set handler for historical data points (replay mode). - Handler receives: (coin, timestamp, data) + Handler receives: (coin, timestamp, data). ``data`` is the record as + sent, in the channel's live shape: an object, or on ``lighter_trades`` + and ``rh_lighter_trades`` a list with one trade leg. Use + :meth:`on_replay_data` to also receive the channel and decoded Lighter + records. """ self._on_historical_data = handler + def on_replay_data(self, handler: ReplayDataHandler) -> None: + """Set a handler for replayed records, with the channel they belong to. + + Receives ``(channel, coin, timestamp, record)`` for every + ``historical_data`` message, alongside :meth:`on_historical_data`. + Lighter and Robinhood Chain records are decoded with the live parsers + (see :func:`decode_lighter_payload`): an :class:`OrderBook` on the book + channels, a ``list[Trade]`` on the trade channels and a + :class:`LighterMarketContextUpdate` on open interest and funding. Other + channels' records are passed through as sent. + + Example: + >>> def on_record(channel, coin, ts, record): + ... if channel == "lighter_trades": + ... for leg in record: + ... print(ts, leg.side, leg.price, leg.account_index) + >>> ws.on_replay_data(on_record) + """ + self._on_replay_data = handler + def on_historical_tick_data(self, handler: HistoricalTickDataHandler) -> None: """Set handler for historical tick data (granularity='tick' mode). - This is for tick-level granularity on Lighter.xyz orderbook data. + This is for tick-level granularity on Lighter orderbook data. Receives a checkpoint (full orderbook) followed by incremental deltas. Handler receives: (coin, checkpoint, deltas) @@ -1813,8 +2041,18 @@ def _handle_message(self, raw: str) -> None: data["channel"], data["coin"], data["timestamp"], data["data"] ) - elif msg_type == "historical_data" and self._on_historical_data: - self._on_historical_data(data["coin"], data["timestamp"], data["data"]) + elif msg_type == "historical_data": + if self._on_historical_data: + self._on_historical_data(data["coin"], data["timestamp"], data["data"]) + if self._on_replay_data: + channel = data.get("channel") + coin = data.get("coin", "") + self._on_replay_data( + channel, + coin, + data["timestamp"], + decode_lighter_payload(channel, coin, data.get("data")), + ) elif msg_type == "historical_tick_data" and self._on_historical_tick_data: msg = WsHistoricalTickData(**data) diff --git a/pyproject.toml b/pyproject.toml index bfe55fb..5f8215c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ license = "MIT" authors = [ { name = "0xarchive", email = "support@0xarchive.io" } ] -keywords = ["0xarchive", "market-data", "hyperliquid", "lighterxyz", "orderbook", "trades", "replay", "api", "sdk"] +keywords = ["0xarchive", "market-data", "hyperliquid", "lighter", "orderbook", "trades", "replay", "api", "sdk"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py new file mode 100644 index 0000000..0bc1dbc --- /dev/null +++ b/tests/test_api_contract.py @@ -0,0 +1,1271 @@ +"""The 2026-10-01 API contract: version selector, error codes, pagination state, +response meta, capabilities, working filters, WebSocket replay rules and verbs. + +Every REST call goes through the real ``Client`` into a mocked transport, so +the tests see the exact path, query and headers the SDK sends. The response +bodies are the shapes the API returns under ``0xArchive-Version: 2026-10-01``. +""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, cast, get_args + +import httpx +import pytest +from _mock_api import envelope, mock_client + +import oxarchive +from oxarchive import ( + API_VERSION, + API_VERSION_HEADER, + ERROR_CODES, + VENUES, + WEBSOCKET_ERROR_CODES, + Capability, + CursorResponse, + ErrorCode, + OxArchiveError, + ResponseMeta, + Venue, +) +from oxarchive.resources.trades import TradesResource +from oxarchive.types import ( + CvdBucket, + Hip3OracleDiscoveryBounds, + Hip3OracleExternalPrice, + LighterLiquidation, + LighterLiquidationVolume, + LighterMarketContextUpdate, + OrderBook, + Trade, + WsChannel, + WsError, + WsHistoricalData, + WsReplaySnapshot, + WsReplayStarted, + WsSubscribed, +) +from oxarchive.websocket import ( + BULK_REPLAY_CHANNELS, + L4_LIVE_ONLY_CHANNELS, + L4_REPLAY_CHANNELS, + LIVE_CHANNELS, + REPLAY_CHANNELS, + WS_CHANNELS, + OxArchiveWs, + WsOptions, + decode_lighter_payload, +) + +T_START = 1790640000000 # 2026-09-29T00:00:00Z +T_END = 1790726400000 # 2026-09-30T00:00:00Z +ROOT = Path(__file__).resolve().parents[1] + + +def _page(data: Any, *, has_more: bool, next_cursor: str | None = None, **meta: Any) -> dict[str, Any]: + body = envelope(data, next_cursor=next_cursor, **meta) + body["meta"]["has_more"] = has_more + return body + + +def _subject(data: Any, symbol: str, venue: str, **meta: Any) -> dict[str, Any]: + return envelope(data, symbol=symbol, venue=venue, **meta) + + +# =========================================================================== +# 1. Version selector +# =========================================================================== + + +def test_api_version_constants() -> None: + assert API_VERSION == "2026-10-01" + assert API_VERSION_HEADER == "0xArchive-Version" + + +SYNC_CALLS: list[Callable[[oxarchive.Client], Any]] = [ + lambda c: c.hyperliquid.orderbook.get("BTC"), + lambda c: c.hyperliquid.trades.history("BTC", start=T_START, end=T_END), + lambda c: c.lighter.trades.recent("BTC"), + lambda c: c.capabilities(), + lambda c: c.symbols.list(), + lambda c: c.data_quality.status(), + lambda c: c.webhooks.list_endpoints(), + lambda c: c.hyperliquid.positions.get("0xabc"), +] + + +def _any_body(path: str, query: dict[str, str]) -> dict[str, Any]: + if path.endswith("/orderbook/BTC"): + return envelope({"coin": "BTC", "timestamp": "2026-09-29T00:00:00Z", "bids": [], "asks": []}) + if path.endswith("/data-quality/status"): + return envelope({"status": "operational", "updated_at": "2026-09-29T00:00:00Z", "exchanges": {}, "data_types": {}, "active_incidents": 0}) + if "/positions" in path or "/wallets/" in path: + return envelope({"address": "0xabc", "positions": [], "account": None, "account_seen": None}) + return envelope([]) + + +@pytest.mark.parametrize("call", SYNC_CALLS) +def test_every_sync_request_sends_the_version_header(call: Callable[[oxarchive.Client], Any]) -> None: + client, api = mock_client(_any_body) + + call(client) + + assert api.requests, "no request was sent" + for request in api.requests: + assert request.headers[API_VERSION_HEADER] == API_VERSION + assert request.headers["X-API-Key"] == "0xa_test" + + +def test_every_async_request_sends_the_version_header() -> None: + client, api = mock_client(_any_body) + + async def run() -> None: + await client.hyperliquid.orderbook.aget("BTC") + await client.hyperliquid.trades.ahistory("BTC", start=T_START, end=T_END) + await client.acapabilities() + await client.symbols.alist() + await client.data_quality.astatus() + + asyncio.run(run()) + + assert len(api.requests) == 5 + assert {r.headers[API_VERSION_HEADER] for r in api.requests} == {API_VERSION} + + +def test_a_fresh_client_sends_the_header_on_both_transports() -> None: + client = oxarchive.Client(api_key="0xa_test", base_url="https://api.example.test") + assert client._http.client.headers[API_VERSION_HEADER] == API_VERSION + assert client._http.async_client.headers[API_VERSION_HEADER] == API_VERSION + client.close() + + +def test_websocket_connects_with_the_version_parameter() -> None: + ws = OxArchiveWs(WsOptions(api_key="0xa_key")) + assert ws._connection_url() == "wss://api.0xarchive.io/ws?apiKey=0xa_key&version=2026-10-01" + + custom = OxArchiveWs(WsOptions(api_key="0xa_key", ws_url="wss://example.test/ws?region=eu")) + assert custom._connection_url() == ( + "wss://example.test/ws?region=eu&apiKey=0xa_key&version=2026-10-01" + ) + + +def test_websocket_connect_opens_the_versioned_url(monkeypatch: pytest.MonkeyPatch) -> None: + opened: list[str] = [] + + async def fake_connect(url: str, **kwargs: Any) -> Any: + opened.append(url) + raise OSError("offline") + + monkeypatch.setattr("oxarchive.websocket.ws_connect", fake_connect) + ws = OxArchiveWs(WsOptions(api_key="0xa_key", auto_reconnect=False)) + errors: list[Exception] = [] + ws.on_error(errors.append) + + asyncio.run(ws.connect()) + + assert opened == ["wss://api.0xarchive.io/ws?apiKey=0xa_key&version=2026-10-01"] + assert ws.state == "disconnected" and errors + + +# ---- data quality, status coverage and symbols envelopes ---------------------- + +STATUS = { + "status": "degraded", + "updated_at": "2026-09-29T14:57:56.919Z", + "exchanges": {"hyperliquid": {"status": "operational", "last_data_at": "2026-09-29T14:55:10Z", "latency_ms": 1200}}, + "data_types": {}, + "active_incidents": 0, +} +COVERAGE = { + "exchanges": [ + { + "exchange": "hyperliquid", + "data_types": { + "orderbook": { + "earliest": "2023-04-15T00:00:07Z", + "latest": "2026-09-29T14:56:01Z", + "total_records": 28098760700, + "symbols": 234, + "resolution": "~1.2 seconds", + "completeness": 100.0, + } + }, + } + ] +} +SYMBOL_COVERAGE = { + "exchange": "hyperliquid", + "symbol": "BTC", + "data_types": { + "funding": { + "earliest": "2023-05-20T02:50:04Z", + "latest": "2026-09-29T14:55:42.583Z", + "total_records": 1755931, + "completeness": 100.0, + "historical_coverage": 99.0, + "gaps": [], + } + }, +} +INCIDENT = { + "id": "INC-2026-001", + "status": "resolved", + "severity": "major", + "exchange": "lighter", + "data_types": ["orderbook"], + "symbols_affected": ["ALL"], + "started_at": "2026-02-12T22:02:03Z", + "resolved_at": "2026-02-12T22:52:00Z", + "duration_minutes": 49, + "title": "Upstream outage", +} +LATENCY = {"measured_at": "2026-09-29T14:57:57.571Z", "exchanges": {}} +SLA = { + "period": "2026-09", + "sla_targets": {"uptime": 99.9, "data_completeness": 99.5, "api_latency_p99_ms": 500}, + "actual": { + "uptime": 100.0, + "uptime_status": "met", + "data_completeness": {"orderbook": 99.9, "funding": 99.9, "open_interest": 99.9, "overall": 99.9}, + "completeness_status": "met", + "api_latency_p99_ms": 369, + "latency_status": "met", + }, + "incidents_this_period": 0, + "total_downtime_minutes": 0, +} + + +def _versioned(payload: Any) -> dict[str, Any]: + return {"success": True, "data": payload, "meta": {"request_id": "req-dq"}} + + +def _legacy(payload: dict[str, Any]) -> dict[str, Any]: + return {"success": True, **payload, "meta": {"request_id": "req-dq"}} + + +DQ_ROUTES: dict[str, Any] = { + "/v1/data-quality/status": STATUS, + "/v1/data-quality/coverage": COVERAGE, + "/v1/status/coverage": COVERAGE, + "/v1/data-quality/coverage/hyperliquid": COVERAGE["exchanges"][0], + "/v1/data-quality/coverage/hyperliquid/BTC": SYMBOL_COVERAGE, + "/v1/data-quality/incidents": {"incidents": [INCIDENT], "pagination": {"total": 1, "limit": 20, "offset": 0}}, + "/v1/data-quality/incidents/INC-2026-001": INCIDENT, + "/v1/data-quality/latency": LATENCY, + "/v1/data-quality/sla": SLA, +} + + +def _check_data_quality(client: oxarchive.Client) -> None: + dq = client.data_quality + status = dq.status() + assert status.status == "degraded" and status.exchanges["hyperliquid"].status == "operational" + assert status.response_meta is not None and status.response_meta.request_id == "req-dq" + assert dq.coverage().exchanges[0].data_types["orderbook"].total_records == 28098760700 + assert dq.status_coverage().exchanges[0].exchange == "hyperliquid" + assert dq.exchange_coverage("hyperliquid").exchange == "hyperliquid" + symbol = dq.symbol_coverage("hyperliquid", "BTC") + assert symbol.symbol == "BTC" and symbol.data_types["funding"].completeness == 100.0 + assert dq.list_incidents().incidents[0].id == "INC-2026-001" + assert dq.get_incident("INC-2026-001").duration_minutes == 49 + assert dq.latency().exchanges == {} + assert dq.sla().period == "2026-09" + + +def test_data_quality_parses_the_versioned_envelope() -> None: + client, api = mock_client(lambda path, q: _versioned(DQ_ROUTES[path])) + _check_data_quality(client) + assert len(api.requests) == 9 + + +def test_data_quality_still_parses_the_older_top_level_body() -> None: + client, _ = mock_client(lambda path, q: _legacy(DQ_ROUTES[path])) + _check_data_quality(client) + + +def test_data_quality_async_parses_the_versioned_envelope() -> None: + client, _ = mock_client(lambda path, q: _versioned(DQ_ROUTES[path])) + + async def run() -> None: + assert (await client.data_quality.astatus()).status == "degraded" + assert (await client.data_quality.astatus_coverage()).exchanges[0].exchange == "hyperliquid" + assert (await client.data_quality.asymbol_coverage("hyperliquid", "BTC")).symbol == "BTC" + + asyncio.run(run()) + + +SYMBOL_ROW = { + "symbol": "BTC", + "exchange": "hyperliquid", + "coverage_from": "2023-04-15T00:00:00Z", + "data_types": ["trades", "l2_orderbook"], + "coverage_by_type": {"trades": "2023-04-15T00:00:00Z"}, +} + + +def test_symbols_parse_the_versioned_data_array() -> None: + client, api = mock_client( + lambda p, q: {"success": True, "data": [SYMBOL_ROW], "meta": {"count": 1, "request_id": "r"}} + ) + rows = client.symbols.list() + assert [r.symbol for r in rows] == ["BTC"] and api.calls[0][0] == "/v1/symbols" + + +def test_symbols_still_parse_the_older_symbols_key() -> None: + client, _ = mock_client( + lambda p, q: {"success": True, "symbols": [SYMBOL_ROW], "meta": {"request_id": "r"}} + ) + assert [r.exchange for r in client.symbols.list()] == ["hyperliquid"] + assert [r.symbol for r in asyncio.run(client.symbols.alist())] == ["BTC"] + + +# ---- time outliers ------------------------------------------------------------- + +CVD_V = { + "buy_volume": 94913298.5, + "sell_volume": 97951950.4, + "delta": -3038651.9, + "cumulative_delta": -3038651.9, + "timestamp": "2026-09-29T13:00:00.000Z", + "timestamp_ms": 1790686800000, +} +ORACLE_V = { + "symbol": "km:US500", + "external_price": 749.64, + "mark_price": 750.97, + "block_number": 1165513777, + "timestamp": "2026-09-29T14:58:00.016Z", + "timestamp_ms": 1790693880016, +} +BOUNDS_V = { + "symbol": "km:US500", + "reference_price": 749.64, + "reference_source": "external", + "max_leverage": 25, + "bound_fraction": 0.04, + "lower_bound": 719.6544, + "upper_bound": 779.6256, + "block_number": 1165513777, + "timestamp": "2026-09-29T14:58:00.016Z", + "timestamp_ms": 1790693880016, +} +LIGHTER_LIQ_V = { + "symbol": "BTC", + "timestamp": "2026-09-20T02:40:57.104Z", + "timestamp_ms": 1789872057104, + "transaction_time_us": 1789872057136978, + "trade_id": 31156998607, + "price": 80866.3, + "size": 0.00064, +} +LIGHTER_VOLUME_V = { + "symbol": "BTC", + "timestamp": "2026-09-20T00:00:00.000Z", + "timestamp_ms": 1789862400000, + "total_usd": 125479.97, + "count": 194, +} + + +def test_versioned_time_fields_parse_to_utc_datetimes_with_ms() -> None: + cvd = CvdBucket.model_validate(CVD_V) + assert cvd.timestamp == datetime(2026, 9, 29, 13, tzinfo=timezone.utc) + assert cvd.timestamp_ms == 1790686800000 + + for model, row in ( + (Hip3OracleExternalPrice, ORACLE_V), + (Hip3OracleDiscoveryBounds, BOUNDS_V), + ): + parsed = model.model_validate(row) + assert parsed.timestamp == datetime(2026, 9, 29, 14, 58, 0, 16000, tzinfo=timezone.utc) + assert parsed.timestamp_ms == 1790693880016 + + liq = LighterLiquidation.model_validate(LIGHTER_LIQ_V) + assert liq.timestamp_ms == 1789872057104 and liq.timestamp.tzinfo is not None + vol = LighterLiquidationVolume.model_validate(LIGHTER_VOLUME_V) + assert vol.timestamp == datetime(2026, 9, 20, tzinfo=timezone.utc) and vol.timestamp_ms == 1789862400000 + + +def test_older_integer_times_still_parse_and_fill_the_ms_field() -> None: + legacy = {**CVD_V, "timestamp": 1790686800000} + del legacy["timestamp_ms"] + cvd = CvdBucket.model_validate(legacy) + assert cvd.timestamp == datetime(2026, 9, 29, 13, tzinfo=timezone.utc) + assert cvd.timestamp_ms == 1790686800000 + + +def test_levels_snapshot_ts_ms_is_typed() -> None: + client, _ = mock_client( + lambda path, q: _subject( + [{"snapshot_ts": "2026-09-28T15:01:49.000Z", "snapshot_ts_ms": 1790607709000, "block_number": 1, "mid_price": 1.0, "total_long": 0.0, "total_short": 0.0, "flagged_notional": 0.0}] + if path.endswith("/levels/history") + else [{"snapshot_ts": "2026-09-28T15:00:01.000Z", "snapshot_ts_ms": 1790607601000, "mid_price": 1.0, "total_bid_size": 0.0, "total_ask_size": 0.0}] + if path.endswith("/trigger-levels/history") + else {"snapshot_ts": "2026-09-29T14:56:47.000Z", "snapshot_ts_ms": 1790693807000, "block_number": 1, "mid_price": 1.0, "total_long": 0.0, "total_short": 0.0, "flagged_notional": 0.0, "levels": []}, + "BTC", + "hyperliquid", + ) + ) + levels = client.hyperliquid.liquidations.levels("BTC") + assert levels.snapshot_ts == "2026-09-29T14:56:47.000Z" and levels.snapshot_ts_ms == 1790693807000 + history = client.hyperliquid.liquidations.levels_history("BTC", summary=True) + assert history.data[0].snapshot_ts_ms == 1790607709000 + triggers = client.hyperliquid.orders.trigger_levels_history("BTC", summary=True) + assert triggers.data[0].snapshot_ts_ms == 1790607601000 + + +def test_cvd_oracle_and_lighter_liquidations_parse_the_versioned_bodies() -> None: + def respond(path: str, q: dict[str, str]) -> dict[str, Any]: + if "/cvd/" in path: + return _page([CVD_V], has_more=False, symbol="BTC", venue="hyperliquid") + if "external-price" in path: + return _subject(ORACLE_V, "km:US500", "hip3") + if "discovery-bounds" in path: + return _subject(BOUNDS_V, "km:US500", "hip3") + if path.endswith("/volume"): + return _page([LIGHTER_VOLUME_V], has_more=True, next_cursor="1789862400000", symbol="BTC", venue="rh-lighter") + return _page([LIGHTER_LIQ_V], has_more=False, symbol="BTC", venue="lighter") + + client, _ = mock_client(respond) + assert client.hyperliquid.cvd.history("BTC").data[0].timestamp_ms == 1790686800000 + price = client.hyperliquid.hip3.oracle.external_price("km:US500") + assert price.timestamp_ms == 1790693880016 and price.response_meta.venue == "hip3" + assert client.hyperliquid.hip3.oracle.discovery_bounds("km:US500").timestamp_ms == 1790693880016 + liqs = client.lighter.liquidations.history("BTC", start=T_START, end=T_END) + assert liqs.data[0].timestamp_ms == 1789872057104 and liqs.has_more is False + volume = client.rh_lighter.liquidations.volume("BTC", start=T_START, end=T_END) + assert volume.data[0].timestamp_ms == 1789862400000 and volume.has_more is True + assert volume.meta.venue == "rh-lighter" + + +def test_l4_snapshot_order_times_pass_through_in_the_versioned_shape() -> None: + snapshot = { + "coin": "BTC", + "timestamp": "2026-09-29T14:57:29.345Z", + "last_block_number": 1165513326, + "bids": [ + {"oid": 1, "price": 83824.0, "side": "B", "size": 0.28, "timestamp": "2026-09-29T14:57:27.983Z", "timestamp_ms": 1790693847983, "user_address": "0x7b"}, + {"oid": 2, "price": 83823.0, "side": "B", "size": 0.1, "timestamp": None, "timestamp_ms": 0, "user_address": "0x7c"}, + ], + "asks": [], + } + client, _ = mock_client(lambda p, q: _subject(snapshot, "BTC", "hyperliquid")) + book = client.hyperliquid.l4_orderbook.get("BTC") + assert book["bids"][0]["timestamp"] == "2026-09-29T14:57:27.983Z" + assert book["bids"][0]["timestamp_ms"] == 1790693847983 + assert book["bids"][1]["timestamp"] is None and book["bids"][1]["timestamp_ms"] == 0 + + +# ---- Lighter replay uses the live shapes ---------------------------------------- + +LIVE_LEG = { + "coin": "BTC", + "side": "A", + "px": "84925.1", + "sz": "0.001", + "time": 1790503200112, + "hash": "55ce05e1", + "tid": 32208547226, + "oid": 562953431283143, + "crossed": False, + "dir": None, + "fee": "0.000028", + "fee_token": None, + "closed_pnl": None, + "start_position": "-0.04286", + "users": ["281474976656674"], +} +LIVE_BOOK = { + "coin": "BTC", + "time": 1790503223423, + "levels": [ + [{"n": 1, "px": "84870.8", "sz": "0.00244"}], + [{"n": 1, "px": "84881.4", "sz": "0.05275"}], + ], +} +LIVE_CTX = { + "coin": "BTC", + "ctx": { + "openInterest": "175884735.9", + "funding": "0.000008", + "markPx": "84349.7", + "oraclePx": "84391.7", + "premium": None, + "midPx": None, + "dayNtlVlm": None, + "dayBaseVlm": None, + "prevDayPx": None, + "impactPxs": None, + }, +} + + +def _historical(channel: str, data: Any, ts: int = 1790503200112) -> str: + return json.dumps( + {"type": "historical_data", "channel": channel, "coin": "BTC", "symbol": "BTC", "timestamp": ts, "data": data} + ) + + +@pytest.mark.parametrize("prefix", ["lighter", "rh_lighter"]) +def test_lighter_replay_records_decode_with_the_live_parsers(prefix: str) -> None: + ws = OxArchiveWs(WsOptions(api_key="k")) + raw: list[tuple[str, int, Any]] = [] + decoded: list[tuple[str, str, int, Any]] = [] + ws.on_historical_data(lambda coin, ts, data: raw.append((coin, ts, data))) + ws.on_replay_data(lambda channel, coin, ts, record: decoded.append((channel, coin, ts, record))) + + ws._handle_message(_historical(f"{prefix}_trades", [LIVE_LEG])) + ws._handle_message(_historical(f"{prefix}_orderbook", LIVE_BOOK, 1790503223423)) + ws._handle_message(_historical(f"{prefix}_funding", LIVE_CTX)) + + assert raw[0] == ("BTC", 1790503200112, [LIVE_LEG]) + channel, coin, ts, legs = decoded[0] + assert channel == f"{prefix}_trades" and coin == "BTC" and ts == 1790503200112 + assert isinstance(legs, list) and isinstance(legs[0], Trade) + assert (legs[0].side, legs[0].price, legs[0].account_index, legs[0].fee) == ("A", "84925.1", "281474976656674", "0.000028") + book = decoded[1][3] + assert isinstance(book, OrderBook) and book.bids[0].px == "84870.8" and book.asks[0].px == "84881.4" + ctx = decoded[2][3] + assert isinstance(ctx, LighterMarketContextUpdate) and ctx.ctx.funding_rate == "0.000008" + + +def test_other_replay_records_pass_through_unchanged() -> None: + ws = OxArchiveWs(WsOptions(api_key="k")) + decoded: list[Any] = [] + ws.on_replay_data(lambda channel, coin, ts, record: decoded.append((channel, record))) + candle = {"open": 1.0, "close": 2.0} + + ws._handle_message(_historical("lighter_candles", candle)) + ws._handle_message(_historical("orderbook", {"bids": [], "asks": []})) + + assert decoded == [("lighter_candles", candle), ("orderbook", {"bids": [], "asks": []})] + assert decode_lighter_payload("funding", "BTC", candle) is candle + + +def test_replay_messages_with_list_payloads_are_typed() -> None: + data = WsHistoricalData.model_validate( + {"type": "historical_data", "channel": "lighter_trades", "coin": "BTC", "timestamp": 1, "data": [LIVE_LEG]} + ) + assert data.data == [LIVE_LEG] + snap = WsReplaySnapshot.model_validate( + {"type": "replay_snapshot", "channel": "lighter_trades", "coin": "BTC", "timestamp": 1, "data": [LIVE_LEG]} + ) + assert isinstance(snap.data, list) + decoded = decode_lighter_payload("lighter_trades", "BTC", snap.data) + assert [t.trade_id for t in decoded] == [32208547226] + + +def test_acknowledgements_carry_the_selected_version() -> None: + sub = WsSubscribed.model_validate( + {"type": "subscribed", "channel": "trades", "coin": "BTC", "symbol": "BTC", "version": "2026-10-01"} + ) + assert sub.version == API_VERSION + started = WsReplayStarted.model_validate( + {"type": "replay_started", "channel": "hip3_l4_diffs", "coin": "xyz:TSLA", "symbol": "xyz:TSLA", "start": 1, "end": 2, "speed": 3.0, "version": "2026-10-01"} + ) + assert started.version == API_VERSION and started.symbol == "xyz:TSLA" + + +# =========================================================================== +# 2. Errors +# =========================================================================== + + +def test_error_code_set_matches_the_contract() -> None: + assert ERROR_CODES == get_args(ErrorCode) + assert len(set(ERROR_CODES)) == len(ERROR_CODES) + assert ERROR_CODES[:5] == ( + "invalid_parameter", + "invalid_symbol", + "invalid_interval", + "invalid_cursor", + "invalid_time_range", + ) + for code in ( + "range_before_coverage", + "historical_range_exceeded", + "historical_depth_exceeded", + "unsupported_for_venue", + "route_not_found", + "not_found", + "unauthorized", + "forbidden", + "insufficient_credits", + "rate_limited", + "conflict", + "upstream_unavailable", + "internal_error", + "positions_unavailable", + "api_key_limit_reached", + "oauth_not_permitted", + ): + assert code in ERROR_CODES + assert WEBSOCKET_ERROR_CODES == {"slow_consumer", "endpoint_unsupported"} + assert WEBSOCKET_ERROR_CODES <= set(ERROR_CODES) + assert oxarchive.ErrorCode is ErrorCode and "ERROR_CODES" in oxarchive.__all__ + + +def test_rest_errors_expose_the_contract_fields() -> None: + body = { + "success": False, + "code": 400, + "error_code": "invalid_parameter", + "error": "Invalid side 'both'. Use buy or sell.", + "request_id": "req-1", + "param": "side", + "valid_values": ["buy", "sell"], + } + client, _ = mock_client(lambda p, q: httpx.Response(400, json=body)) + + with pytest.raises(OxArchiveError) as caught: + client.hyperliquid.trades.history("BTC", start=T_START, end=T_END, side=cast(Any, "both")) + + error = caught.value + assert (error.status, error.code, error.error_code) == (400, 400, "invalid_parameter") + assert (error.request_id, error.param, error.valid_values) == ("req-1", "side", ["buy", "sell"]) + assert error.message == "Invalid side 'both'. Use buy or sell." + assert error.details == body + assert "error_code: invalid_parameter" in str(error) and "request_id: req-1" in str(error) + + +def test_unsupported_for_venue_keeps_where_it_is_available() -> None: + body = { + "success": False, + "code": 404, + "error_code": "unsupported_for_venue", + "error": "Order flow is not offered on Hyperliquid spot.", + "request_id": "req-2", + "datatype": "order_flow", + "venue": "spot", + "available_on": [{"route": "/v1/hyperliquid/orders/{symbol}/flow", "venue": "hyperliquid"}], + } + client, _ = mock_client(lambda p, q: httpx.Response(404, json=body)) + + with pytest.raises(OxArchiveError) as caught: + asyncio.run(client._http.aget("/v1/hyperliquid/spot/orders/HYPE-USDC/flow")) + + assert caught.value.error_code == "unsupported_for_venue" + assert caught.value.details["available_on"][0]["venue"] == "hyperliquid" + + +@pytest.mark.parametrize( + "status, code", + [(401, "unauthorized"), (403, "forbidden"), (404, "not_found"), (429, "rate_limited"), (502, "upstream_unavailable"), (500, "internal_error")], +) +def test_an_error_page_without_a_body_gets_a_code_from_its_status(status: int, code: str) -> None: + client, _ = mock_client(lambda p, q: httpx.Response(status, text="Bad Gateway")) + + with pytest.raises(OxArchiveError) as caught: + client.hyperliquid.orderbook.get("BTC") + + assert caught.value.status == status and caught.value.error_code == code + assert "Bad Gateway" in caught.value.message + + +def test_a_json_error_without_a_code_gets_one_from_its_status() -> None: + client, _ = mock_client(lambda p, q: httpx.Response(429, json={"error": "Too many requests"})) + + with pytest.raises(OxArchiveError) as caught: + client.hyperliquid.orderbook.get("BTC") + + assert caught.value.error_code == "rate_limited" and caught.value.message == "Too many requests" + + +def test_network_errors_have_status_zero_and_no_code() -> None: + def fail(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("unreachable", request=request) + + client = oxarchive.Client(api_key="0xa_test", base_url="https://api.example.test") + client._http._client = httpx.Client(base_url=client._http.base_url, transport=httpx.MockTransport(fail)) + + with pytest.raises(OxArchiveError) as caught: + client.hyperliquid.orderbook.get("BTC") + + assert caught.value.status == 0 and caught.value.error_code is None + + +def test_core_recent_trades_are_refused_with_the_api_code() -> None: + client, api = mock_client(lambda p, q: envelope([])) + + for call in ( + lambda: client.hyperliquid.trades.recent("BTC"), + lambda: asyncio.run(client.hyperliquid.trades.arecent("BTC")), + ): + with pytest.raises(OxArchiveError) as caught: + call() + assert caught.value.error_code == "unsupported_for_venue" and caught.value.status == 404 + assert "history" in caught.value.message + + assert api.requests == [] + + +@pytest.mark.parametrize( + "frame", + [ + {"type": "error", "message": "Unknown channel 'x'.", "error_code": "invalid_parameter"}, + {"type": "error", "message": "Dropped ~40 live lighter_trades messages for BTC", "error_code": "slow_consumer"}, + {"type": "error", "message": "Use wss://api.0xarchive.io/ws for lighter_orderbook", "error_code": "endpoint_unsupported"}, + {"type": "error", "message": "replay.seek is not supported for L4 channels", "error_code": "unsupported_for_venue"}, + ], +) +def test_websocket_errors_expose_error_code(frame: dict[str, Any]) -> None: + ws = OxArchiveWs(WsOptions(api_key="k")) + seen: list[Any] = [] + ws.on_message(seen.append) + + ws._handle_message(json.dumps(frame)) + + assert isinstance(seen[0], WsError) + assert seen[0].error_code == frame["error_code"] and seen[0].message == frame["message"] + + +def test_a_websocket_error_without_a_code_still_parses() -> None: + assert WsError.model_validate({"type": "error", "message": "x"}).error_code is None + + +# =========================================================================== +# 3. Pagination +# =========================================================================== + + +def test_has_more_comes_from_meta() -> None: + client, _ = mock_client(lambda p, q: _page([], has_more=True, next_cursor="c2")) + page = client.hyperliquid.funding.history("BTC", start=T_START, end=T_END) + assert page.has_more is True and page.next_cursor == "c2" and page.meta.has_more is True + + client, _ = mock_client(lambda p, q: _page([], has_more=False)) + page = client.hyperliquid.funding.history("BTC", start=T_START, end=T_END) + assert page.has_more is False and page.next_cursor is None + + +def test_has_more_follows_next_cursor_where_the_route_does_not_send_it() -> None: + client, _ = mock_client(lambda p, q: envelope([], next_cursor="1790640060000")) + assert client.hyperliquid.orders.flow("BTC", start=T_START, end=T_END).has_more is True + + client, _ = mock_client(lambda p, q: envelope([])) + assert client.hyperliquid.orders.flow("BTC", start=T_START, end=T_END).has_more is False + + +def test_cursor_response_derives_its_state() -> None: + assert CursorResponse(data=[], next_cursor="x").has_more is True + assert CursorResponse(data=[]).has_more is False + meta = ResponseMeta(has_more=True, next_cursor="n") + page = CursorResponse(data=[], meta=meta) + assert page.has_more is True and page.next_cursor == "n" + assert CursorResponse(data=[], next_cursor="x", has_more=False).has_more is False + + +def test_an_empty_final_page_after_a_full_one_stops_the_iterator() -> None: + pages = [ + _page([CVD_V, CVD_V], has_more=True, next_cursor="c2"), + _page([], has_more=False), + ] + client, api = mock_client(lambda p, q: pages[len(api.requests) - 1]) + + buckets = list(client.hyperliquid.cvd.iterate("BTC", start=T_START, end=T_END, limit=2)) + + assert len(buckets) == 2 and len(api.requests) == 2 + assert api.calls[1][1]["cursor"] == "c2" + + +def test_iterators_stop_when_has_more_is_false_even_with_a_cursor() -> None: + client, api = mock_client(lambda p, q: _page([CVD_V], has_more=False, next_cursor="stale")) + assert len(list(client.hyperliquid.cvd.iterate("BTC", start=T_START))) == 1 + assert len(api.requests) == 1 + + async def collect() -> list[Any]: + return [b async for b in client.hyperliquid.cvd.aiterate("BTC", start=T_START)] + + assert len(asyncio.run(collect())) == 1 and len(api.requests) == 2 + + +def test_positions_iterators_stop_on_has_more() -> None: + row = { + "symbol": "BTC", + "coin": "BTC", + "size": "1", + "side": "long", + "entry_price": "1", + "leverage": {"type": "cross", "value": "1"}, + "cum_funding": {"all_time": None, "since_open": None, "since_change": None}, + "liquidation_price_status": "exact", + "quality": "complete", + } + pages = [ + _page([row], has_more=True, next_cursor="p2"), + _page([row], has_more=False, next_cursor="ignored"), + ] + client, api = mock_client(lambda p, q: pages[len(api.requests) - 1]) + + rows = list(client.hyperliquid.positions.iterate_history("0xabc", start=T_START, end=T_END)) + + assert len(rows) == 2 and len(api.requests) == 2 and api.calls[1][1]["cursor"] == "p2" + + +# Every paged method returns meta (symbol, venue and has_more included). +PAGED_CALLS: list[tuple[str, Callable[[oxarchive.Client], CursorResponse[Any]]]] = [ + ("orderbook.history", lambda c: c.hyperliquid.orderbook.history("BTC", start=T_START, end=T_END)), + ("trades.history", lambda c: c.hyperliquid.trades.history("BTC", start=T_START, end=T_END)), + ("funding.history", lambda c: c.hyperliquid.funding.history("BTC", start=T_START, end=T_END)), + ("oi.history", lambda c: c.hyperliquid.open_interest.history("BTC", start=T_START, end=T_END)), + ("candles.history", lambda c: c.hyperliquid.candles.history("BTC", start=T_START, end=T_END)), + ("liquidations.history", lambda c: c.hyperliquid.liquidations.history("BTC", start=T_START, end=T_END)), + ("liquidations.volume", lambda c: c.hyperliquid.liquidations.volume("BTC", start=T_START, end=T_END)), + ("orders.history", lambda c: c.hyperliquid.orders.history("BTC", start=T_START, end=T_END)), + ("orders.tpsl", lambda c: c.hyperliquid.orders.tpsl("BTC", start=T_START, end=T_END)), + ("l4.diffs", lambda c: c.hyperliquid.l4_orderbook.diffs("BTC", start=T_START, end=T_END)), + ("l4.history", lambda c: c.hyperliquid.l4_orderbook.history("BTC", start=T_START, end=T_END)), + ("l2.history", lambda c: c.hyperliquid.l2_orderbook.history("BTC", start=T_START, end=T_END)), + ("l2.diffs", lambda c: c.hyperliquid.l2_orderbook.diffs("BTC", start=T_START, end=T_END)), + ("l3.history", lambda c: c.lighter.l3_orderbook.history("BTC", start=T_START, end=T_END)), + ("prices", lambda c: c.hyperliquid.get_price_history("BTC", start=T_START, end=T_END)), + ("spot.twap", lambda c: c.spot.twap.history("HYPE-USDC", start=T_START, end=T_END)), + ("spot.orders", lambda c: c.spot.orders.history("HYPE-USDC", start=T_START, end=T_END)), + ("hip4.outcomes", lambda c: c.hyperliquid.hip4.outcomes.list()), + ("hip4.questions", lambda c: c.hyperliquid.hip4.questions.list()), + ("breadth.history", lambda c: c.hyperliquid.hip3.breadth.history()), +] + + +@pytest.mark.parametrize("name, call", PAGED_CALLS, ids=[n for n, _ in PAGED_CALLS]) +def test_every_paged_method_returns_meta_and_has_more( + name: str, call: Callable[[oxarchive.Client], CursorResponse[Any]] +) -> None: + client, _ = mock_client( + lambda p, q: _page([], has_more=True, next_cursor="n", symbol="BTC", venue="hyperliquid") + ) + + page = call(client) + + assert page.has_more is True and page.next_cursor == "n" + assert isinstance(page.meta, ResponseMeta) + assert (page.meta.symbol, page.meta.venue, page.meta.request_id) == ("BTC", "hyperliquid", "req-test") + + +# =========================================================================== +# 4. meta.symbol / meta.venue and response_meta +# =========================================================================== + + +def test_venue_set() -> None: + assert VENUES == get_args(Venue) == ("hyperliquid", "hip3", "hip4", "spot", "lighter", "rh-lighter") + + +BOOK = {"coin": "xyz:TSLA", "timestamp": "2026-09-29T00:00:00Z", "bids": [], "asks": []} + +RECORD_CALLS: list[tuple[str, Any, Callable[[oxarchive.Client], Any]]] = [ + ("orderbook", BOOK, lambda c: c.hyperliquid.hip3.orderbook.get("xyz:TSLA")), + ("funding", {"coin": "BTC", "timestamp": "2026-09-29T00:00:00Z", "funding_rate": "0.0001"}, lambda c: c.hyperliquid.funding.current("BTC")), + ("oi", {"coin": "BTC", "timestamp": "2026-09-29T00:00:00Z", "open_interest": "1"}, lambda c: c.hyperliquid.open_interest.current("BTC")), + ("freshness", {"coin": "BTC", "exchange": "hyperliquid", "measured_at": "2026-09-29T00:00:00Z", "orderbook": {}, "trades": {}, "funding": {}, "open_interest": {}}, lambda c: c.hyperliquid.get_freshness("BTC")), + ("oracle", ORACLE_V, lambda c: c.hyperliquid.hip3.oracle.external_price("km:US500")), +] + + +@pytest.mark.parametrize("name, data, call", RECORD_CALLS, ids=[n for n, _, _ in RECORD_CALLS]) +def test_records_returned_on_their_own_carry_the_response_meta( + name: str, data: Any, call: Callable[[oxarchive.Client], Any] +) -> None: + client, _ = mock_client(lambda p, q: _subject(data, "xyz:TSLA", "hip3")) + + record = call(client) + + assert isinstance(record.response_meta, ResponseMeta) + assert (record.response_meta.symbol, record.response_meta.venue) == ("xyz:TSLA", "hip3") + assert record.response_meta.request_id == "req-test" + # The meta is not part of the record's data. + assert "response_meta" not in record.model_dump() and "_response_meta" not in record.model_dump() + + +def test_async_records_carry_the_response_meta() -> None: + client, _ = mock_client(lambda p, q: _subject(BOOK, "xyz:TSLA", "hip3")) + book = asyncio.run(client.hyperliquid.hip3.orderbook.aget("xyz:TSLA")) + assert book.response_meta.venue == "hip3" + + +def test_rows_inside_a_list_have_no_response_meta() -> None: + client, _ = mock_client(lambda p, q: _page([BOOK], has_more=False, symbol="xyz:TSLA", venue="hip3")) + page = client.hyperliquid.hip3.orderbook.history("xyz:TSLA", start=T_START, end=T_END) + assert page.data[0].response_meta is None and page.meta.venue == "hip3" + assert OrderBook.model_validate(BOOK).response_meta is None + + +# =========================================================================== +# 5. Capabilities +# =========================================================================== + +CAPABILITY_ROWS = [ + { + "venue": "spot", + "datatype": "l4_diffs", + "rest_routes": ["/v1/hyperliquid/spot/orderbook/{symbol}/l4/diffs"], + "ws_channels": ["spot_l4_diffs"], + "live": True, + "replay": True, + "available_from": "2026-03-11T01:03:00.000Z", + "cadence": "event", + "page_limit": 10000, + "intervals": [], + "notes": "WebSocket replay is bulk (speed is ignored) and single-channel only.", + }, + { + "venue": "hyperliquid", + "datatype": "summary", + "rest_routes": ["/v1/hyperliquid/summary/{symbol}"], + "ws_channels": [], + "live": False, + "replay": False, + "available_from": None, + "cadence": "snapshot", + "page_limit": None, + "intervals": [], + "notes": None, + }, +] + + +def test_capabilities_returns_typed_rows() -> None: + client, api = mock_client(lambda p, q: envelope(CAPABILITY_ROWS)) + + rows = client.capabilities() + + assert api.calls == [("/v1/capabilities", {})] + assert all(isinstance(r, Capability) for r in rows) + spot = rows[0] + assert (spot.venue, spot.datatype, spot.live, spot.replay) == ("spot", "l4_diffs", True, True) + assert spot.available_from == datetime(2026, 3, 11, 1, 3, tzinfo=timezone.utc) + assert spot.page_limit == 10000 and spot.ws_channels == ["spot_l4_diffs"] + assert rows[1].available_from is None and rows[1].page_limit is None + assert [r.datatype for r in asyncio.run(client.acapabilities())] == ["l4_diffs", "summary"] + + +# =========================================================================== +# 6. Parameters the API applies +# =========================================================================== + +TRADE = {"coin": "BTC", "side": "B", "price": "1", "size": "1", "timestamp": "2026-09-29T00:00:00Z"} + +VENUE_TRADES: list[tuple[str, Callable[[oxarchive.Client], TradesResource], str]] = [ + ("hyperliquid", lambda c: c.hyperliquid.trades, "/v1/hyperliquid/trades/BTC"), + ("hip3", lambda c: c.hyperliquid.hip3.trades, "/v1/hyperliquid/hip3/trades/xyz:TSLA"), + ("hip4", lambda c: c.hyperliquid.hip4.trades, "/v1/hyperliquid/hip4/trades/0"), + ("spot", lambda c: c.spot.trades, "/v1/hyperliquid/spot/trades/HYPE-USDC"), + ("lighter", lambda c: c.lighter.trades, "/v1/lighter/trades/BTC"), + ("rh-lighter", lambda c: c.rh_lighter.trades, "/v1/rh-lighter/trades/BTC"), +] +SYMBOLS = {"hyperliquid": "BTC", "hip3": "xyz:TSLA", "hip4": "#0", "spot": "HYPE-USDC", "lighter": "BTC", "rh-lighter": "BTC"} + + +@pytest.mark.parametrize("venue, resource, path", VENUE_TRADES, ids=[v for v, _, _ in VENUE_TRADES]) +def test_trades_side_is_sent_on_every_venue( + venue: str, resource: Callable[[oxarchive.Client], TradesResource], path: str +) -> None: + client, api = mock_client(lambda p, q: envelope([TRADE])) + trades = resource(client) + symbol = SYMBOLS[venue] + + trades.list(symbol, start=T_START, end=T_END, side="buy") + trades.history(symbol, start=T_START, end=T_END, side="sell", limit=5) + asyncio.run(trades.alist(symbol, start=T_START, end=T_END, side="buy")) + asyncio.run(trades.ahistory(symbol, start=T_START, end=T_END, side="sell")) + + assert [c[0] for c in api.calls] == [path] * 4 + assert [c[1]["side"] for c in api.calls] == ["buy", "sell", "buy", "sell"] + assert api.calls[1][1] == {"start": str(T_START), "end": str(T_END), "limit": "5", "side": "sell"} + + if venue != "hyperliquid": + trades.recent(symbol, limit=10, side="sell") + asyncio.run(trades.arecent(symbol, side="buy")) + assert api.calls[-2] == (f"{path}/recent", {"limit": "10", "side": "sell"}) + assert api.calls[-1] == (f"{path}/recent", {"side": "buy"}) + + +def test_trades_without_side_send_no_side() -> None: + client, api = mock_client(lambda p, q: envelope([TRADE])) + client.hyperliquid.trades.history("BTC", start=T_START, end=T_END) + client.lighter.trades.recent("BTC") + assert all("side" not in q for _, q in api.calls) + + +def test_hip4_flat_trade_helpers_pass_side_through() -> None: + client, api = mock_client(lambda p, q: envelope([TRADE])) + client.hyperliquid.hip4.get_trades("0", start=T_START, end=T_END, side="buy") + client.hyperliquid.hip4.get_trades_recent("0", limit=3, side="sell") + assert api.calls[0][1]["side"] == "buy" + assert api.calls[1] == ("/v1/hyperliquid/hip4/trades/0/recent", {"limit": "3", "side": "sell"}) + + +@pytest.mark.parametrize( + "orders, path", + [ + (lambda c: c.hyperliquid.orders, "/v1/hyperliquid/orders/BTC/history"), + (lambda c: c.hyperliquid.hip3.orders, "/v1/hyperliquid/hip3/orders/BTC/history"), + (lambda c: c.hyperliquid.hip4.orders, "/v1/hyperliquid/hip4/orders/BTC/history"), + ], + ids=["hyperliquid", "hip3", "hip4"], +) +def test_order_history_sends_triggered(orders: Callable[[oxarchive.Client], Any], path: str) -> None: + client, api = mock_client(lambda p, q: envelope([])) + + orders(client).history("BTC", start=T_START, end=T_END, triggered=True) + asyncio.run(orders(client).ahistory("BTC", start=T_START, end=T_END, triggered=False)) + orders(client).history("BTC", start=T_START, end=T_END) + + assert [c[0] for c in api.calls] == [path] * 3 + assert api.calls[0][1]["triggered"] == "true" + assert api.calls[1][1]["triggered"] == "false" + assert "triggered" not in api.calls[2][1] + + +@pytest.mark.parametrize( + "book, path", + [ + (lambda c: c.hyperliquid.l2_orderbook, "/v1/hyperliquid/orderbook/BTC/l2/history"), + (lambda c: c.hyperliquid.hip3.l2_orderbook, "/v1/hyperliquid/hip3/orderbook/BTC/l2/history"), + ], + ids=["hyperliquid", "hip3"], +) +def test_full_depth_history_sends_depth(book: Callable[[oxarchive.Client], Any], path: str) -> None: + client, api = mock_client(lambda p, q: envelope([])) + + book(client).history("BTC", start=T_START, end=T_END, depth=50) + asyncio.run(book(client).ahistory("BTC", start=T_START, end=T_END, depth=3)) + + assert api.calls == [ + (path, {"start": str(T_START), "end": str(T_END), "depth": "50"}), + (path, {"start": str(T_START), "end": str(T_END), "depth": "3"}), + ] + + +@pytest.mark.parametrize( + "book, path", + [ + (lambda c: c.hyperliquid.hip3.orderbook, "/v1/hyperliquid/hip3/orderbook/xyz:TSLA/history"), + (lambda c: c.hyperliquid.hip4.orderbook, "/v1/hyperliquid/hip4/orderbook/67200/history"), + (lambda c: c.spot.orderbook, "/v1/hyperliquid/spot/orderbook/HYPE-USDC/history"), + ], + ids=["hip3", "hip4", "spot"], +) +def test_order_book_history_sends_depth_on_hip3_hip4_and_spot( + book: Callable[[oxarchive.Client], Any], path: str +) -> None: + client, api = mock_client(lambda p, q: envelope([])) + symbol = path.split("/orderbook/")[1].split("/")[0] + if symbol == "67200": + symbol = "#67200" + + book(client).history(symbol, start=T_START, end=T_END, depth=5) + + assert api.calls == [(path, {"start": str(T_START), "end": str(T_END), "depth": "5"})] + + +def test_l3_history_granularity_stays_refused() -> None: + client, api = mock_client(lambda p, q: envelope([])) + + with pytest.raises(TypeError, match="does not take 'granularity'"): + client.lighter.l3_orderbook.history("BTC", start=T_START, end=T_END, granularity="10s") + with pytest.raises(TypeError, match="does not take 'granularity'"): + asyncio.run(client.lighter.l3_orderbook.ahistory("BTC", start=T_START, end=T_END, granularity="1s")) + + assert api.requests == [] + + +# =========================================================================== +# 7. WebSocket replay rules mirror capabilities +# =========================================================================== + +# ``ws_channels``, ``live`` and ``replay`` of every row of GET /v1/capabilities +# that names a channel, as served on 2026-09-29. +CAPABILITY_CHANNELS: dict[str, tuple[str, bool, bool]] = { + "orderbook": ("hyperliquid", True, True), + "orderbook_full": ("hyperliquid", True, True), + "l4_diffs": ("hyperliquid", True, True), + "l4_orders": ("hyperliquid", True, True), + "trades": ("hyperliquid", True, True), + "candles": ("hyperliquid", False, True), + "funding": ("hyperliquid", True, True), + "open_interest": ("hyperliquid", True, True), + "liquidations": ("hyperliquid", True, True), + "ticker": ("hyperliquid", True, False), + "all_tickers": ("hyperliquid", True, False), + "hip3_orderbook": ("hip3", True, True), + "hip3_orderbook_full": ("hip3", True, True), + "hip3_l4_diffs": ("hip3", True, True), + "hip3_l4_orders": ("hip3", True, True), + "hip3_trades": ("hip3", True, True), + "hip3_candles": ("hip3", False, True), + "hip3_funding": ("hip3", True, True), + "hip3_open_interest": ("hip3", True, True), + "hip3_liquidations": ("hip3", True, True), + "hip4_orderbook": ("hip4", True, True), + "hip4_l4_diffs": ("hip4", True, True), + "hip4_l4_orders": ("hip4", True, True), + "hip4_trades": ("hip4", True, True), + "hip4_open_interest": ("hip4", True, True), + "spot_orderbook": ("spot", True, False), + "spot_l4_diffs": ("spot", True, True), + "spot_l4_orders": ("spot", True, True), + "spot_trades": ("spot", True, False), + "spot_twap": ("spot", True, False), + "lighter_orderbook": ("lighter", True, True), + "lighter_l3_orderbook": ("lighter", False, True), + "lighter_trades": ("lighter", True, True), + "lighter_candles": ("lighter", False, True), + "lighter_funding": ("lighter", True, True), + "lighter_open_interest": ("lighter", True, True), + "rh_lighter_orderbook": ("rh-lighter", True, True), + "rh_lighter_trades": ("rh-lighter", True, True), + "rh_lighter_candles": ("rh-lighter", False, True), + "rh_lighter_funding": ("rh-lighter", True, True), + "rh_lighter_open_interest": ("rh-lighter", True, True), +} + + +def test_channel_table_mirrors_capabilities() -> None: + assert set(WS_CHANNELS) == set(CAPABILITY_CHANNELS) == set(get_args(WsChannel)) + for channel, (venue, live, replay) in CAPABILITY_CHANNELS.items(): + spec = WS_CHANNELS[channel] + assert (spec.venue, spec.live, spec.replay) == (venue, live, replay), channel + + +def test_bulk_replay_channels_are_every_l4_and_full_depth_channel() -> None: + assert L4_REPLAY_CHANNELS == {c for c in WS_CHANNELS if "_l4_" in c or c.startswith("l4_")} + assert BULK_REPLAY_CHANNELS == L4_REPLAY_CHANNELS | {"orderbook_full", "hip3_orderbook_full"} + assert L4_REPLAY_CHANNELS <= REPLAY_CHANNELS and L4_REPLAY_CHANNELS <= LIVE_CHANNELS + assert L4_LIVE_ONLY_CHANNELS == frozenset() + + +class _Recorder: + def __init__(self) -> None: + self.sent: list[dict[str, Any]] = [] + + async def __call__(self, message: dict[str, Any]) -> None: + self.sent.append(message) + + +def _offline() -> tuple[OxArchiveWs, _Recorder]: + ws = OxArchiveWs(WsOptions(api_key="k")) + recorder = _Recorder() + setattr(ws, "_send", recorder) + return ws, recorder + + +@pytest.mark.parametrize("channel", sorted(BULK_REPLAY_CHANNELS)) +def test_every_bulk_channel_replays_on_its_own(channel: str) -> None: + ws, recorder = _offline() + + asyncio.run(ws.replay(cast(WsChannel, channel), "BTC", start=T_START, end=T_END, speed=5)) + + assert recorder.sent == [ + {"op": "replay", "channel": channel, "symbol": "BTC", "start": T_START, "speed": 5, "end": T_END} + ] + with pytest.raises(ValueError, match="single-channel only"): + asyncio.run(ws.multi_replay(["trades", cast(WsChannel, channel)], "BTC", start=T_START)) + assert len(recorder.sent) == 1 + + +@pytest.mark.parametrize("channel", sorted(c for c, s in WS_CHANNELS.items() if not s.replay)) +def test_channels_without_replay_are_refused_before_sending(channel: str) -> None: + ws, recorder = _offline() + + with pytest.raises(ValueError, match="does not support historical replay"): + asyncio.run(ws.replay(cast(WsChannel, channel), "BTC", start=T_START)) + with pytest.raises(ValueError, match="does not support historical replay"): + asyncio.run(ws.multi_replay([cast(WsChannel, channel)], "BTC", start=T_START)) + + assert recorder.sent == [] + + +@pytest.mark.parametrize("channel", ["candles", "hip3_candles"]) +def test_replay_only_candle_channels_are_refused_for_live_subscription(channel: str) -> None: + ws = OxArchiveWs(WsOptions(api_key="k")) + + with pytest.raises(ValueError, match="supports replay, not live subscriptions"): + ws.subscribe(cast(WsChannel, channel), "BTC") + with pytest.raises(ValueError, match="supports replay, not live subscriptions"): + asyncio.run(ws.subscribe_async(cast(WsChannel, channel), "BTC")) + + assert ws._subscriptions == set() + + +def test_live_channels_subscribe_and_replayable_channels_multi_replay() -> None: + ws, recorder = _offline() + + asyncio.run(ws.multi_replay(["hip3_orderbook", "hip3_trades", "hip3_funding", "hip3_open_interest"], "xyz:TSLA", start=T_START)) + + assert recorder.sent[0]["channels"] == ["hip3_orderbook", "hip3_trades", "hip3_funding", "hip3_open_interest"] + for channel in sorted(LIVE_CHANNELS): + ws.subscribe(cast(WsChannel, channel), None if channel == "all_tickers" else "BTC") + assert len(ws._subscriptions) == len(LIVE_CHANNELS) + + +def test_a_channel_missing_from_the_table_is_left_to_the_server() -> None: + ws, recorder = _offline() + asyncio.run(ws.replay(cast(WsChannel, "future_channel"), "BTC", start=T_START)) + assert recorder.sent[0]["channel"] == "future_channel" + + +def test_bulk_replay_frames_reach_the_l4_handlers() -> None: + ws = OxArchiveWs(WsOptions(api_key="k")) + snapshots: list[tuple[str, str]] = [] + batches: list[tuple[str, int]] = [] + ws.on_l4_snapshot(lambda channel, coin, data: snapshots.append((channel, coin))) + ws.on_l4_batch(lambda channel, coin, events: batches.append((channel, len(events)))) + + ws._handle_message(json.dumps({"type": "l4_snapshot", "channel": "spot_l4_diffs", "coin": "PURR-USDC", "symbol": "PURR-USDC", "last_block_number": 1165417484, "timestamp": 1790686949346, "data": {"bids": [], "asks": []}})) + ws._handle_message(json.dumps({"type": "l4_batch", "channel": "spot_l4_diffs", "coin": "PURR-USDC", "symbol": "PURR-USDC", "data": [{"block_number": 1165417487, "seq": 573, "diff_type": "remove"}]})) + ws._handle_message(json.dumps({"type": "l4_batch", "channel": "hip3_orderbook_full", "coin": "xyz:TSLA", "symbol": "xyz:TSLA", "data": [{"side": "B", "px": 354.4, "sz": 0.0, "n": 0, "bn": 1}]})) + + assert snapshots == [("spot_l4_diffs", "PURR-USDC")] + assert batches == [("spot_l4_diffs", 1), ("hip3_orderbook_full", 1)] + + +# =========================================================================== +# 8. Verbs +# =========================================================================== + + +def test_history_aliases_call_the_same_routes() -> None: + assert TradesResource.history is TradesResource.list + assert TradesResource.ahistory is TradesResource.alist + client, api = mock_client(lambda p, q: envelope([])) + + client.spot.twap.history("HYPE-USDC", start=T_START, end=T_END) + client.spot.twap.by_symbol("HYPE-USDC", start=T_START, end=T_END) + asyncio.run(client.spot.twap.ahistory("HYPE-USDC")) + client.hyperliquid.hip4.get_price_history("0", start=T_START, end=T_END) + client.hyperliquid.hip4.get_prices("0", start=T_START, end=T_END) + asyncio.run(client.hyperliquid.hip4.aget_price_history("0")) + + paths = [c[0] for c in api.calls] + assert paths[:3] == ["/v1/hyperliquid/spot/twap/HYPE-USDC"] * 3 + assert paths[3:] == ["/v1/hyperliquid/hip4/prices/0"] * 3 + + +def test_existing_names_keep_working() -> None: + client, api = mock_client(lambda p, q: envelope([])) + client.hyperliquid.trades.list("BTC", start=T_START, end=T_END) + asyncio.run(client.lighter.trades.alist("BTC", start=T_START, end=T_END)) + client.trades.list("BTC", start=T_START, end=T_END) # legacy namespace + assert [c[0] for c in api.calls] == [ + "/v1/hyperliquid/trades/BTC", + "/v1/lighter/trades/BTC", + "/v1/hyperliquid/trades/BTC", + ] + + +# =========================================================================== +# 9. Venue name +# =========================================================================== + + +def test_the_venue_is_named_lighter_everywhere_shipped() -> None: + shipped = [ROOT / "README.md", ROOT / "pyproject.toml", *sorted((ROOT / "oxarchive").rglob("*.py"))] + for path in shipped: + text = path.read_text() + assert "Lighter.xyz" not in text and "lighterxyz" not in text.lower(), path diff --git a/tests/test_api_contract_live.py b/tests/test_api_contract_live.py new file mode 100644 index 0000000..20bf5e4 --- /dev/null +++ b/tests/test_api_contract_live.py @@ -0,0 +1,233 @@ +"""Read-only checks of the 2026-10-01 contract against the live API. + +Skipped unless ``OXARCHIVE_LIVE_TESTS=1`` and ``OXARCHIVE_API_KEY`` are set. +Every call is a GET or a WebSocket subscribe/replay over a short window; they +use a few credits and change nothing. ``OXARCHIVE_BASE_URL`` and +``OXARCHIVE_WS_URL`` point them at another deployment. +""" + +from __future__ import annotations + +import asyncio +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Iterator, cast + +import pytest + +from oxarchive import API_VERSION, API_VERSION_HEADER, Client, OxArchiveError +from oxarchive.types import OrderBook, Trade, WsChannel, WsError, WsL4Batch, WsL4Snapshot, WsSubscribed + +pytestmark = pytest.mark.skipif( + os.getenv("OXARCHIVE_LIVE_TESTS") != "1" or not os.getenv("OXARCHIVE_API_KEY"), + reason="set OXARCHIVE_LIVE_TESTS=1 and OXARCHIVE_API_KEY to run live checks", +) + +BASE_URL = os.getenv("OXARCHIVE_BASE_URL", "https://api.0xarchive.io") +WS_URL = os.getenv("OXARCHIVE_WS_URL", "wss://api.0xarchive.io/ws") + +# Recent, settled windows: an hour ending two hours ago for Hyperliquid, and a +# day ending two days ago for Lighter, whose canonical trades trail by a day. +_NOW = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) +HL_END = _NOW - timedelta(hours=2) +HL_START = HL_END - timedelta(hours=1) +LIGHTER_END = _NOW - timedelta(days=2) +LIGHTER_START = LIGHTER_END - timedelta(hours=1) + + +@pytest.fixture(scope="module") +def client() -> Iterator[Client]: + with Client(base_url=BASE_URL) as c: + yield c + + +def test_requests_select_the_contract_version(client: Client) -> None: + response = client._http.client.get("/v1/hyperliquid/orderbook/BTC", params={"depth": 1}) + assert response.request.headers[API_VERSION_HEADER] == API_VERSION + assert response.headers[API_VERSION_HEADER] == API_VERSION + + +def test_records_and_pages_carry_symbol_and_venue(client: Client) -> None: + book = client.hyperliquid.hip3.orderbook.get("xyz:TSLA", depth=2) + assert (book.response_meta.symbol, book.response_meta.venue) == ("xyz:TSLA", "hip3") + + page = client.spot.trades.history("HYPE-USDC", start=HL_START, end=HL_END, limit=5) + assert (page.meta.symbol, page.meta.venue) == ("HYPE-USDC", "spot") + assert page.has_more is (page.next_cursor is not None) + + +def test_has_more_pages_to_the_end(client: Client) -> None: + page = client.hyperliquid.funding.history("BTC", start=HL_START, end=HL_END, limit=20) + pages = 1 + while page.has_more: + assert page.next_cursor + page = client.hyperliquid.funding.history( + "BTC", start=HL_START, end=HL_END, limit=20, cursor=page.next_cursor + ) + pages += 1 + assert pages < 20 + assert page.has_more is False and page.next_cursor is None and pages > 1 + + +def test_capabilities_match_the_websocket_channel_table(client: Client) -> None: + from oxarchive.websocket import WS_CHANNELS + + rows = client.capabilities() + assert rows and all(row.venue and row.datatype for row in rows) + served = {} + for row in rows: + for channel in row.ws_channels: + served[channel] = (row.venue, row.live, row.replay) + assert served, "no row names a WebSocket channel" + for channel, (venue, live, replay) in served.items(): + spec = WS_CHANNELS[channel] + assert (spec.venue, spec.live, spec.replay) == (venue, live, replay), channel + + +@pytest.mark.parametrize( + "venue, symbol, recent", + [("hyperliquid", "BTC", False), ("hip3", "xyz:TSLA", True), ("spot", "HYPE-USDC", True)], +) +def test_trades_side_filters_on_hyperliquid_venues( + client: Client, venue: str, symbol: str, recent: bool +) -> None: + trades = {"hyperliquid": client.hyperliquid.trades, "hip3": client.hyperliquid.hip3.trades, "spot": client.spot.trades}[venue] + buys = trades.history(symbol, start=HL_START, end=HL_END, limit=50, side="buy") + sells = trades.history(symbol, start=HL_START, end=HL_END, limit=50, side="sell") + assert buys.data and {t.side for t in buys.data} == {"B"} + assert sells.data and {t.side for t in sells.data} == {"A"} + if recent: + assert {t.side for t in trades.recent(symbol, limit=20, side="sell")} <= {"A"} + + +@pytest.mark.xfail(reason="the API answered side filters on Lighter with 500 on 2026-09-29", strict=False) +@pytest.mark.parametrize("deployment", ["lighter", "rh_lighter"]) +def test_trades_side_filters_on_lighter(client: Client, deployment: str) -> None: + trades = getattr(client, deployment).trades + buys = trades.history("BTC", start=LIGHTER_START, end=LIGHTER_END, limit=20, side="buy") + assert buys.data and {t.side for t in buys.data} == {"B"} + + +def test_order_history_triggered_filter(client: Client) -> None: + fired = client.hyperliquid.orders.history("BTC", start=HL_START, end=HL_END, limit=50, triggered=True) + others = client.hyperliquid.orders.history("BTC", start=HL_START, end=HL_END, limit=50, triggered=False) + assert {row["status"] for row in fired.data} <= {"triggered"} + assert others.data and "triggered" not in {row["status"] for row in others.data} + + +def test_depth_on_full_depth_and_order_book_history(client: Client) -> None: + full = client.hyperliquid.l2_orderbook.history("BTC", start=HL_START, end=HL_END, limit=1, depth=3) + assert len(full.data[0]["bids"]) == 3 and len(full.data[0]["asks"]) == 3 + spot = client.spot.orderbook.history("HYPE-USDC", start=HL_START, end=HL_END, limit=1, depth=2) + assert len(spot.data[0].bids) == 2 + + +def test_time_fields_are_datetimes_with_ms(client: Client) -> None: + bucket = client.hyperliquid.cvd.history("BTC", interval="1h", limit=1).data[0] + assert bucket.timestamp.tzinfo is not None + assert bucket.timestamp_ms == int(bucket.timestamp.timestamp() * 1000) + price = client.hyperliquid.hip3.oracle.external_price("xyz:TSLA") + assert price.timestamp_ms == int(price.timestamp.timestamp() * 1000) + levels = client.hyperliquid.liquidations.levels("BTC", buckets=10) + assert levels.snapshot_ts_ms and levels.snapshot_ts.endswith("Z") + + +def test_data_quality_and_symbols_parse(client: Client) -> None: + status = client.data_quality.status() + assert status.status and status.exchanges + assert client.data_quality.status_coverage().exchanges + symbols = client.symbols.list() + assert any(s.exchange == "hip3" for s in symbols) + + +def test_errors_carry_the_contract_fields(client: Client) -> None: + with pytest.raises(OxArchiveError) as caught: + client.hyperliquid.trades.history("BTC", start=HL_START, end=HL_END, side=cast(Any, "both")) + error = caught.value + assert (error.status, error.error_code, error.param) == (400, "invalid_parameter", "side") + assert error.valid_values == ["buy", "sell"] and error.request_id + + with pytest.raises(OxArchiveError) as caught: + client._http.get("/v1/hyperliquid/spot/orders/HYPE-USDC/flow") + assert caught.value.error_code == "unsupported_for_venue" + assert caught.value.details["available_on"] + + with pytest.raises(OxArchiveError) as caught: + client._http.get("/v1/hyperliquid/no-such-route/BTC") + assert caught.value.error_code == "route_not_found" + + +# --------------------------------------------------------------------------- +# WebSocket +# --------------------------------------------------------------------------- + + +async def _session(run: Any, until: Any, timeout: float = 45.0) -> list[Any]: + from oxarchive.websocket import OxArchiveWs, WsOptions + + ws = OxArchiveWs( + WsOptions(api_key=os.environ["OXARCHIVE_API_KEY"], ws_url=WS_URL, auto_reconnect=False) + ) + seen: list[Any] = [] + done = asyncio.Event() + + def record(item: Any) -> None: + seen.append(item) + if until(seen): + done.set() + + ws.on_message(record) + ws.on_replay_data(lambda channel, coin, ts, rec: record(("replay", channel, rec))) + ws.on_replay_complete(lambda channel, coin, sent: record(("complete", channel, sent))) + await ws.connect() + try: + await run(ws) + await asyncio.wait_for(done.wait(), timeout) + finally: + await ws.disconnect() + return seen + + +def test_websocket_acks_carry_the_version_and_errors_carry_codes() -> None: + async def run(ws: Any) -> None: + await ws.subscribe_async("trades", "BTC") + await ws._send({"op": "subscribe", "channel": "no_such_channel", "symbol": "BTC"}) + + seen = asyncio.run( + _session(run, lambda s: any(isinstance(m, WsError) for m in s) and any(isinstance(m, WsSubscribed) for m in s)) + ) + ack = next(m for m in seen if isinstance(m, WsSubscribed)) + assert ack.version == API_VERSION + error = next(m for m in seen if isinstance(m, WsError)) + assert error.error_code == "invalid_parameter" + + +def test_spot_l4_replay_is_bulk_and_typed() -> None: + start = int((HL_END - timedelta(minutes=30)).timestamp() * 1000) + + async def run(ws: Any) -> None: + await ws.replay(cast(WsChannel, "spot_l4_diffs"), "PURR-USDC", start=start, end=start + 20_000) + + seen = asyncio.run(_session(run, lambda s: any(isinstance(m, tuple) and m[0] == "complete" for m in s))) + assert any(isinstance(m, WsL4Snapshot) and m.channel == "spot_l4_diffs" for m in seen) + assert all(m.channel == "spot_l4_diffs" for m in seen if isinstance(m, WsL4Batch)) + + +def test_lighter_replay_uses_the_live_shapes() -> None: + start = int(LIGHTER_START.timestamp() * 1000) + + async def run(ws: Any) -> None: + await ws.replay(cast(WsChannel, "lighter_trades"), "BTC", start=start, end=start + 60_000, speed=1000) + + seen = asyncio.run(_session(run, lambda s: any(isinstance(m, tuple) and m[0] == "complete" for m in s))) + records = [m[2] for m in seen if isinstance(m, tuple) and m[0] == "replay"] + assert records, "no Lighter trades in the window" + legs = records[0] + assert isinstance(legs, list) and isinstance(legs[0], Trade) and legs[0].account_index + + async def run_book(ws: Any) -> None: + await ws.replay(cast(WsChannel, "lighter_orderbook"), "BTC", start=start, end=start + 120_000, speed=1000) + + seen = asyncio.run(_session(run_book, lambda s: any(isinstance(m, tuple) and m[0] == "complete" for m in s))) + books = [m[2] for m in seen if isinstance(m, tuple) and m[0] == "replay"] + assert books and isinstance(books[0], OrderBook) and books[0].bids diff --git a/tests/test_coverage_contract.py b/tests/test_coverage_contract.py index 694d26d..da96453 100644 --- a/tests/test_coverage_contract.py +++ b/tests/test_coverage_contract.py @@ -383,7 +383,9 @@ def test_public_copy_keeps_family_specific_coverage() -> None: assert "raw ~1 min" not in readme assert "no funding, no liquidations, and no candles" not in readme assert "no funding / liquidations / candles" not in types - assert "stored replay only; live bridges paused" in types + # HIP-4 order book and open interest stream live and replay (capabilities). + assert "live bridges paused" not in types + assert "``hip4_orderbook``, ``hip4_trades``, ``hip4_open_interest``" in types assert "250 orders per side" in l3_resource assert "price levels per side" not in l3_resource assert "self.candles = CandlesResource" in exchanges @@ -513,7 +515,7 @@ def test_g1_to_g4_public_copy_has_current_contracts() -> None: assert "fractional" in changelog and "non-annualized" in changelog assert "45-minute" not in changelog assert "l4_snapshot" in websocket and "ordered" in websocket - assert "l4_snapshot" in types and "live-only" in types + assert "l4_snapshot" in types and "replay is bulk" in types def _breadth_snapshot(*, value_pct: float | None = 20.93) -> dict[str, Any]: diff --git a/tests/test_rh_lighter.py b/tests/test_rh_lighter.py index 8ecdb67..97ed242 100644 --- a/tests/test_rh_lighter.py +++ b/tests/test_rh_lighter.py @@ -345,7 +345,7 @@ def respond(path: str, q: dict[str, str]) -> dict[str, Any]: ] row = history.data[0] assert isinstance(row, LighterLiquidation) - assert row.timestamp == 1782602083534 and row.trade_id == 5501 + assert row.timestamp_ms == 1782602083534 and row.trade_id == 5501 assert row.source == "bucket" and row.raw_json == "" live = history.data[1] assert live.source == "ws" and live.raw_json and live.raw_json.startswith("{") diff --git a/tests/test_spec_fixes.py b/tests/test_spec_fixes.py index d10b597..d44d57c 100644 --- a/tests/test_spec_fixes.py +++ b/tests/test_spec_fixes.py @@ -2,7 +2,7 @@ A trades cursor is an opaque string and goes back to the API unchanged; HIP-4 freshness has no funding entry; methods for routes the API does not serve are -gone; parameters it never applied are refused; symbol coverage encodes the +gone; parameters it does not apply are refused; symbol coverage encodes the symbol; spot freshness parses its datasets; L3 takes account. """ @@ -132,18 +132,7 @@ def test_hip4_flow_and_tpsl_still_reach_their_routes() -> None: # --------------------------------------------------------------------------- -def test_trades_side_is_refused_before_sending() -> None: - client, api = mock_client(lambda p, q: envelope([])) - - with pytest.raises(TypeError, match="does not take 'side'.*Trade.side"): - client.hyperliquid.trades.list("BTC", start=T_START, end=T_END, side="buy") - with pytest.raises(TypeError, match="does not take 'side'"): - asyncio.run(client.lighter.trades.alist("BTC", start=T_START, end=T_END, side="sell")) - - assert api.requests == [] - - -@pytest.mark.parametrize("name", ["user", "status", "order_type"]) +@pytest.mark.parametrize("name", ["user", "status", "order_type", "triggered"]) def test_spot_order_history_filters_are_refused_before_sending(name: str) -> None: client, api = mock_client(lambda p, q: envelope([])) @@ -190,13 +179,11 @@ def test_core_order_history_keeps_its_filters() -> None: @pytest.mark.parametrize( "resource", [ - lambda c: c.hyperliquid.l2_orderbook, - lambda c: c.hyperliquid.hip3.l2_orderbook, lambda c: c.hyperliquid.l4_orderbook, lambda c: c.spot.l4_orderbook, lambda c: c.lighter.l3_orderbook, ], - ids=["l2", "hip3-l2", "l4", "spot-l4", "l3"], + ids=["l4", "spot-l4", "l3"], ) def test_depth_on_history_is_refused_before_sending(resource: Any) -> None: client, api = mock_client(lambda p, q: envelope([])) diff --git a/tests/test_surface_parity.py b/tests/test_surface_parity.py index 2167862..eeb9801 100644 --- a/tests/test_surface_parity.py +++ b/tests/test_surface_parity.py @@ -79,7 +79,7 @@ def test_core_cvd_sends_the_window_and_parses_buckets_and_meta() -> None: ] assert isinstance(page, CursorResponse) assert all(isinstance(b, CvdBucket) for b in page.data) - assert page.data[0].timestamp == T_START + assert page.data[0].timestamp_ms == T_START assert page.data[1].delta == -50.5 and page.data[1].cumulative_delta == 736087.85 assert page.next_cursor == str(T_START + 60000) assert isinstance(page.meta, ResponseMeta) and page.meta.notice == PAGE_NOTICE @@ -166,7 +166,7 @@ def test_cvd_iterate_follows_cursors_with_unchanged_arguments() -> None: client.hyperliquid.cvd.iterate("BTC", start=T_START, end=T_END, interval="1m", limit=2) ) - assert [b.timestamp for b in buckets] == [T_START + i * 60000 for i in range(4)] + assert [b.timestamp_ms for b in buckets] == [T_START + i * 60000 for i in range(4)] assert sum(b.delta for b in buckets) == 11.0 base = {"start": str(T_START), "end": str(T_END), "interval": "1m", "limit": "2"} assert api.calls == [ @@ -233,7 +233,7 @@ def test_discovery_bounds_path_and_parsing() -> None: assert bounds.upper_bound == pytest.approx( bounds.reference_price * (1 + bounds.bound_fraction) ) - assert bounds.block_number == 1164897235 and bounds.timestamp == 1790649576004 + assert bounds.block_number == 1164897235 and bounds.timestamp_ms == 1790649576004 def test_external_price_path_and_parsing_including_nulls() -> None: diff --git a/tests/test_websocket_capabilities.py b/tests/test_websocket_capabilities.py index e06edb1..2b4df25 100644 --- a/tests/test_websocket_capabilities.py +++ b/tests/test_websocket_capabilities.py @@ -581,7 +581,7 @@ def test_hyperliquid_live_subscription_remains_allowed() -> None: "spot_l4_orders", ], ) -def test_non_core_l4_channels_remain_live_only_for_replay(channel: str) -> None: +def test_non_core_l4_channels_replay(channel: str) -> None: channel = cast(WsChannel, channel) ws = OxArchiveWs(WsOptions(api_key="test-key")) sent: list[dict[str, Any]] = [] @@ -591,14 +591,22 @@ async def fake_send(message: dict[str, Any]) -> None: setattr(ws, "_send", fake_send) - with pytest.raises(ValueError, match="live subscriptions only"): - asyncio.run(ws.replay(channel, "BTC", start=1_757_000_000_000)) + asyncio.run(ws.replay(channel, "BTC", start=1_757_000_000_000, end=1_757_003_600_000)) - assert sent == [] + assert sent == [ + { + "op": "replay", + "channel": channel, + "symbol": "BTC", + "start": 1_757_000_000_000, + "speed": 1.0, + "end": 1_757_003_600_000, + } + ] -@pytest.mark.parametrize("channel", ["hip3_l4_diffs", "hip4_l4_orders", "spot_l4_diffs"]) -def test_non_core_l4_channels_remain_live_only_for_multi_replay(channel: str) -> None: +@pytest.mark.parametrize("channel", ["l4_diffs", "hip3_l4_diffs", "hip4_l4_orders", "spot_l4_diffs"]) +def test_l4_channels_replay_on_their_own_not_in_multi_replay(channel: str) -> None: channel = cast(WsChannel, channel) ws = OxArchiveWs(WsOptions(api_key="test-key")) sent: list[dict[str, Any]] = [] @@ -608,7 +616,7 @@ async def fake_send(message: dict[str, Any]) -> None: setattr(ws, "_send", fake_send) - with pytest.raises(ValueError, match="live subscriptions only"): + with pytest.raises(ValueError, match="single-channel only"): asyncio.run(ws.multi_replay(["orderbook", channel], "BTC", start=1_757_000_000_000)) assert sent == [] diff --git a/tests/test_websocket_full_depth.py b/tests/test_websocket_full_depth.py index 0667ba1..2553096 100644 --- a/tests/test_websocket_full_depth.py +++ b/tests/test_websocket_full_depth.py @@ -16,7 +16,6 @@ from oxarchive.types import WsChannel, WsL4Batch, WsL4Snapshot, WsSubscribed from oxarchive.websocket import ( FULL_DEPTH_L2_CHANNELS, - FULL_DEPTH_LIVE_ONLY_ERROR, OxArchiveWs, WsOptions, ) @@ -172,24 +171,23 @@ async def fake_send(message: dict[str, Any]) -> None: @pytest.mark.parametrize("channel", sorted(FULL_DEPTH_L2_CHANNELS)) -def test_full_depth_channels_are_live_only_for_replay(channel: str) -> None: +def test_full_depth_channels_replay(channel: str) -> None: channel = cast(WsChannel, channel) ws, sent = _offline_client() - with pytest.raises(ValueError) as caught: - asyncio.run(ws.replay(channel, "BTC", start=1_757_000_000_000, speed=10)) + asyncio.run(ws.replay(channel, "BTC", start=1_757_000_000_000, speed=10)) - assert str(caught.value) == FULL_DEPTH_LIVE_ONLY_ERROR - assert "l2_orderbook.history()" in FULL_DEPTH_LIVE_ONLY_ERROR - assert sent == [] + assert sent == [ + {"op": "replay", "channel": channel, "symbol": "BTC", "start": 1_757_000_000_000, "speed": 10} + ] @pytest.mark.parametrize("channel", sorted(FULL_DEPTH_L2_CHANNELS)) -def test_full_depth_channels_are_live_only_for_multi_replay(channel: str) -> None: +def test_full_depth_channels_replay_on_their_own_not_in_multi_replay(channel: str) -> None: channel = cast(WsChannel, channel) ws, sent = _offline_client() - with pytest.raises(ValueError, match="live subscriptions only"): + with pytest.raises(ValueError, match="single-channel only"): asyncio.run(ws.multi_replay(["orderbook", channel], "BTC", start=1_757_000_000_000)) assert sent == [] From d354de69638caf7ed84967c0ac4c8efed50c457c Mon Sep 17 00:00:00 2001 From: 0xFantomMenace <93462858+0xFantomMenace@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:38:35 +0000 Subject: [PATCH 2/4] Import on Python 3.9; ship py.typed; plain pytest collects every test - import oxarchive raised TypeError on Python 3.9, which the package declares as supported: two SymbolDataTypeCoverage fields used the X | None annotation syntax, which pydantic evaluates at import time. They use Optional now. The tests use typing constructs too, and the suite passes on Python 3.9, 3.12 and 3.13. - The package ships a py.typed marker in the wheel and the sdist, as the Typing :: Typed classifier already declared. - tests/test_slug_encoding.py imports the shared mock the way the other tests do, so plain pytest collects it against an installed package. --- CHANGELOG.md | 7 +++++++ oxarchive/exchanges.py | 4 ++-- oxarchive/py.typed | 0 oxarchive/types.py | 4 ++-- pyproject.toml | 3 ++- tests/test_api_contract.py | 4 ++-- tests/test_coverage_contract.py | 20 ++++++++++---------- tests/test_slug_encoding.py | 2 +- 8 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 oxarchive/py.typed diff --git a/CHANGELOG.md b/CHANGELOG.md index ce66f03..029375e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,8 @@ full-depth channel. `on_l4_batch()`. Stored full-depth history is also on REST `l2_orderbook.history()` and `diffs()`. - `client.lighter.l3_orderbook.get()` and `history()` take `account`. +- The package ships a `py.typed` marker (PEP 561), so type checkers read its + annotations. The `Typing :: Typed` classifier was already declared. ### Changed - The venue is named "Lighter" throughout docstrings, messages and the @@ -322,6 +324,11 @@ full-depth channel. `OrdersResource`, and Hyperliquid core keeps `LiquidationsResource`. ### Fixed +- `import oxarchive` raised `TypeError` on Python 3.9, which the package + declares as supported. Two fields of `SymbolDataTypeCoverage` + (`historical_coverage` and `cadence`) used the `X | None` annotation + syntax, which Python 3.9 cannot evaluate. They use `Optional` now, and the + test suite passes on Python 3.9. - `hip4.outcomes.get_by_slug()` URL-encodes the slug. Slugs with spaces, colons, `#` or `/` reached the wrong route before. - `trades.list()` and `alist()` send `cursor` back exactly as the API diff --git a/oxarchive/exchanges.py b/oxarchive/exchanges.py index 84faa67..ffc01ee 100644 --- a/oxarchive/exchanges.py +++ b/oxarchive/exchanges.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings -from typing import Optional +from typing import Optional, Union from urllib.parse import quote from ._time import to_unix_ms @@ -527,7 +527,7 @@ async def aget_price_history( ) -def _hip4_encode(symbol: str | int) -> str: +def _hip4_encode(symbol: Union[str, int]) -> str: """Normalize a HIP-4 coin symbol for REST paths. The backend now accepts the bare numeric form (``/hip4/orderbook/0``) and diff --git a/oxarchive/py.typed b/oxarchive/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/oxarchive/types.py b/oxarchive/types.py index 704c013..f843848 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -2987,13 +2987,13 @@ class SymbolDataTypeCoverage(BaseModel): completeness: float """24-hour completeness percentage (0-100).""" - historical_coverage: float | None = None + historical_coverage: Optional[float] = None """Historical coverage percentage (0-100) based on hours with data / total hours.""" gaps: list[CoverageGap] """Detected data gaps within the requested time window.""" - cadence: DataCadence | None = None + cadence: Optional[DataCadence] = None """Empirical data cadence (present when sufficient data exists).""" diff --git a/pyproject.toml b/pyproject.toml index 5f8215c..48bdd44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ Issues = "https://github.com/0xArchiveIO/sdk-python/issues" [tool.hatch.build.targets.wheel] packages = ["oxarchive"] -include = ["/oxarchive/**/*.py"] +include = ["/oxarchive/**/*.py", "/oxarchive/py.typed"] # The sdist needs its own list: without one, hatchling packages every # non-ignored file in the directory. List what ships instead of trusting the @@ -62,6 +62,7 @@ include = ["/oxarchive/**/*.py"] [tool.hatch.build.targets.sdist] include = [ "/oxarchive/**/*.py", + "/oxarchive/py.typed", "/README.md", "/CHANGELOG.md", "/LICENSE", diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 0bc1dbc..a9cde83 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -12,7 +12,7 @@ import json from datetime import datetime, timezone from pathlib import Path -from typing import Any, Callable, cast, get_args +from typing import Any, Callable, Optional, cast, get_args import httpx import pytest @@ -66,7 +66,7 @@ ROOT = Path(__file__).resolve().parents[1] -def _page(data: Any, *, has_more: bool, next_cursor: str | None = None, **meta: Any) -> dict[str, Any]: +def _page(data: Any, *, has_more: bool, next_cursor: Optional[str] = None, **meta: Any) -> dict[str, Any]: body = envelope(data, next_cursor=next_cursor, **meta) body["meta"]["has_more"] = has_more return body diff --git a/tests/test_coverage_contract.py b/tests/test_coverage_contract.py index da96453..66f021e 100644 --- a/tests/test_coverage_contract.py +++ b/tests/test_coverage_contract.py @@ -1,6 +1,6 @@ import asyncio from pathlib import Path -from typing import Any, cast +from typing import Any, Optional, cast import pytest @@ -22,8 +22,8 @@ class FakeHttp: - def __init__(self, response: dict[str, Any] | None = None) -> None: - self.calls: list[tuple[str, dict[str, Any] | None]] = [] + def __init__(self, response: Optional[dict[str, Any]] = None) -> None: + self.calls: list[tuple[str, Optional[dict[str, Any]]]] = [] self.response = response or { "data": [ { @@ -38,11 +38,11 @@ def __init__(self, response: dict[str, Any] | None = None) -> None: "meta": {"next_cursor": "1777708800000"}, } - def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: self.calls.append((path, params)) return self.response - async def aget(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + async def aget(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: return self.get(path, params) @@ -52,13 +52,13 @@ class PathReached(RuntimeError): class PathOnlyHttp: def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, Any] | None]] = [] + self.calls: list[tuple[str, Optional[dict[str, Any]]]] = [] - def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: self.calls.append((path, params)) raise PathReached(path) - async def aget(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + async def aget(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: self.calls.append((path, params)) raise PathReached(path) @@ -518,7 +518,7 @@ def test_g1_to_g4_public_copy_has_current_contracts() -> None: assert "l4_snapshot" in types and "replay is bulk" in types -def _breadth_snapshot(*, value_pct: float | None = 20.93) -> dict[str, Any]: +def _breadth_snapshot(*, value_pct: Optional[float] = 20.93) -> dict[str, Any]: return { "session_date": "2026-08-28", "calculated_at": "2026-08-28T20:54:00Z", @@ -650,7 +650,7 @@ def test_hip3_breadth_supports_async_methods_and_null_is_not_zero() -> None: assert http.calls[-1][1]["cursor"] == "1788036840000" -def _flow_page(next_cursor: str | None) -> dict[str, Any]: +def _flow_page(next_cursor: Optional[str]) -> dict[str, Any]: return { "data": [{"timestamp": "2026-07-13T16:39:00Z", "limit_orders_placed": 3}], "meta": {"count": 1, "request_id": "flow", "next_cursor": next_cursor}, diff --git a/tests/test_slug_encoding.py b/tests/test_slug_encoding.py index 0fe5b06..cd13205 100644 --- a/tests/test_slug_encoding.py +++ b/tests/test_slug_encoding.py @@ -2,7 +2,7 @@ import asyncio -from tests._mock_api import envelope, mock_client +from _mock_api import envelope, mock_client SLUG = "june-fed-rate-change-no change: #2/3" ENCODED = "/v1/hyperliquid/hip4/outcomes/by-slug/june-fed-rate-change-no%20change%3A%20%232%2F3" From 948fde0789a565501f309d2f5115796941db41be Mon Sep 17 00:00:00 2001 From: 0xFantomMenace <93462858+0xFantomMenace@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:44:10 +0000 Subject: [PATCH 3/4] WebSocket channel table matches /v1/capabilities: HIP-4 book and OI replay only, spot TWAP over REST /v1/capabilities lists hip4_orderbook and hip4_open_interest as replay only, and spot TWAP as REST only. The server accepts those subscriptions but sends nothing on them, so the SDK now says so instead of waiting: - WS_CHANNELS marks hip4_orderbook and hip4_open_interest live=False. subscribe() and the subscribe_hip4_* helpers raise ValueError before sending; replay() is unchanged. - spot_twap is listed with neither live nor replay. subscribe_spot_twap() and replay() raise ValueError with REST_ONLY_ERROR, which points to client.spot.twap. - The WsChannel docstring, the helper docstrings, the README channel tables and the CHANGELOG list each channel as capabilities does. The HIP-4 WebSocket example finds an open outcome instead of pinning one. - Tests: the expected table matches the live endpoint, with spot_twap checked against the spot TWAP row. The Lighter side-filter tests are no longer expected to fail; the API filters side on every venue. --- CHANGELOG.md | 20 ++++++--- README.md | 23 +++++++---- oxarchive/types.py | 24 ++++++----- oxarchive/websocket.py | 73 +++++++++++++++++++++++---------- tests/test_api_contract.py | 45 +++++++++++++++++--- tests/test_api_contract_live.py | 8 +++- tests/test_coverage_contract.py | 4 +- 7 files changed, 144 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 029375e..4cdfe4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,10 +91,13 @@ full-depth channel. datatype, `live`, `replay`, `bulk_replay`), mirroring `/v1/capabilities`, with `LIVE_CHANNELS`, `REPLAY_CHANNELS`, `BULK_REPLAY_CHANNELS` and `L4_REPLAY_CHANNELS`. `subscribe()` refuses a channel without live data - (now also `candles` and `hip3_candles`), `replay()` a channel without + (now also `candles`, `hip3_candles`, `hip4_orderbook`, + `hip4_open_interest` and `spot_twap`), `replay()` a channel without replay (`ticker`, `all_tickers`, `spot_orderbook`, `spot_trades`, `spot_twap`), and `multi_replay()` also the bulk channels, each with - `ValueError` before anything is sent. + `ValueError` before anything is sent. `spot_twap` is listed with neither + live nor replay, since `/v1/capabilities` lists spot TWAP as REST only; + its error (`REST_ONLY_ERROR`) says so. - `OxArchiveWs.on_replay_data()`: every replayed record with its channel, `(channel, coin, timestamp, record)`. Lighter and Robinhood Chain records are decoded with the live parsers (`OrderBook`, `list[Trade]`, @@ -276,9 +279,16 @@ full-depth channel. `L4_LIVE_ONLY_CHANNELS` is empty and `L4_LIVE_ONLY_ERROR` is no longer raised; both are deprecated and kept so imports keep working. - Docstrings and the README list live and replay per channel as - `/v1/capabilities` does: `hip3_open_interest`, `hip3_funding`, - `hip4_orderbook` and `hip4_open_interest` stream live as well as replay, - and `hip3_candles` is replay-only. + `/v1/capabilities` does: `hip3_open_interest` and `hip3_funding` stream + live as well as replay; `hip3_candles`, `hip4_orderbook` and + `hip4_open_interest` are replay-only; `spot_twap` neither streams nor + replays. +- `subscribe_hip4_orderbook()`, `subscribe_hip4_open_interest()` and + `subscribe_spot_twap()` raise `ValueError` before anything is sent. The + server accepts these subscriptions but sends no data on them: HIP-4 order + book and open interest replay only (follow the live HIP-4 book on + `hip4_l4_diffs`), and spot TWAP statuses are served over REST only + (`client.spot.twap`). - `WsChannel` includes the five `rh_lighter_*` channels, `orderbook_full` and `hip3_orderbook_full`. Before, the acknowledgement, snapshot and batch messages of the two full-depth channels failed to parse. diff --git a/README.md b/README.md index 4811fa0..4f4069f 100644 --- a/README.md +++ b/README.md @@ -2335,22 +2335,31 @@ Bulk replays open with an `l4_snapshot` and continue with ordered `l4_batch` mes | Channel | Description | Requires Coin | Live Subscription | Historical Replay | |---------|-------------|---------------|-------------------|-------------------| -| `hip4_orderbook` | HIP-4 L2 order book snapshots | Yes | Yes | Yes | +| `hip4_orderbook` | HIP-4 L2 order book snapshots | Yes | No | Yes | | `hip4_trades` | HIP-4 trade/fill updates | Yes | Yes | Yes | -| `hip4_open_interest` | HIP-4 per-side OI ticks | Yes | Yes | Yes | +| `hip4_open_interest` | HIP-4 per-side OI ticks | Yes | No | Yes | | `hip4_l4_diffs` | HIP-4 L4 orderbook diffs | Yes | Yes | Yes (bulk) | | `hip4_l4_orders` | HIP-4 order lifecycle events | Yes | Yes | Yes (bulk) | -HIP-4 has no funding or liquidation channels, and no candle channel: HIP-4 candles are served over REST. Subscribe with the raw ``#N`` coin form (e.g. ``"#0"``); the SDK passes it through unmodified in the JSON body. When a market settles, the server pushes a single ``outcome_settled`` frame and proactively unsubscribes the client from every ``hip4_*`` channel for that coin. Use :py:meth:`OxArchiveWs.on_outcome_settled` to handle the event: +`hip4_orderbook` and `hip4_open_interest` replay stored history but do not stream live, so `subscribe()` raises `ValueError` for them before sending. For the live book, subscribe to `hip4_l4_diffs`; for the current book and open interest, use `client.hyperliquid.hip4.orderbook` and `client.hyperliquid.hip4.open_interest` over REST. + +HIP-4 has no funding or liquidation channels, and no candle channel: HIP-4 candles are served over REST. Subscribe with the raw `#N` coin form (`N = 10 * outcome_id + side`, where side 0 is Yes and 1 is No); the SDK passes it through unmodified in the JSON body. When a market settles, the server pushes a single `outcome_settled` frame and proactively unsubscribes the client from every `hip4_*` channel for that coin. Use `OxArchiveWs.on_outcome_settled()` to handle the event: ```python +# The Yes side of an open BTC price outcome +coin = next( + s.symbol for s in client.symbols.list() + if s.exchange == "hip4" and not s.is_settled + and (s.slug or "").startswith("btc-above-") and "-yes-" in s.slug +) + def on_settled(msg): print(f"Outcome {msg.outcome_id} side {msg.side} settled at {msg.settlement_value}") # Server has already auto-unsubscribed; the SDK mirrors that locally. ws.on_outcome_settled(on_settled) -ws.subscribe_hip4_orderbook("#0") -ws.subscribe_hip4_trades("#0") +ws.subscribe_hip4_trades(coin) +ws.subscribe_hip4_l4_diffs(coin) ``` #### Hyperliquid Spot Channels @@ -2359,11 +2368,11 @@ ws.subscribe_hip4_trades("#0") |---------|-------------|---------------|-------------------|-------------------| | `spot_orderbook` | Spot L2 order book snapshots | Yes | Yes | No | | `spot_trades` | Spot trade/fill updates | Yes | Yes | No | -| `spot_twap` | Spot TWAP status updates | Yes | Yes | No | +| `spot_twap` | Spot TWAP status updates (served over REST only: `client.spot.twap`) | Yes | No | No | | `spot_l4_diffs` | Spot L4 orderbook diffs | Yes | Yes | Yes (bulk) | | `spot_l4_orders` | Spot L4 order lifecycle events | Yes | Yes | Yes (bulk) | -> **Note:** Spot symbols are dashed canonical (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format internally. The existing `on_orderbook` and `on_trades` typed callbacks fire for `spot_orderbook` and `spot_trades`. +> **Note:** Spot symbols are dashed canonical (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format internally. The existing `on_orderbook` and `on_trades` typed callbacks fire for `spot_orderbook` and `spot_trades`. Spot TWAP statuses do not stream: `subscribe_spot_twap()` raises `ValueError` before sending; read them with `client.spot.twap.history()` or `client.spot.twap.by_user()`. ```python ws = OxArchiveWs(WsOptions(api_key="ox_...")) diff --git a/oxarchive/types.py b/oxarchive/types.py index f843848..a10cfa5 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -295,7 +295,7 @@ class Trade(BaseModel): """HIP-3 deployer fee share on this fill (in quote currency). Negative for the maker side (rebate), positive for the taker side. Present only on HIP-3 fills.""" priority_gas: Optional[float] = None - """Priority fee burned in HYPE (not USDC) for write priority on the Hyperliquid validator queue. Independent of builder_fee and deployer_fee — paid to the network, not to a builder or deployer. Present only when the order paid for priority.""" + """Priority fee burned in HYPE (not USDC) for write priority on the Hyperliquid validator queue. Independent of builder_fee and deployer_fee: paid to the network, not to a builder or deployer. Present only when the order paid for priority.""" cloid: Optional[str] = None """Client order ID.""" @@ -386,7 +386,7 @@ class Hip4SideSpec(BaseModel): """Public asset id: 100_000_000 + 10*outcome_id + side.""" display_title: Optional[str] = None - """Human-readable per-side title (e.g. 'BTC above 78,213 on May 4 at 06:00 UTC? — Yes').""" + """Human-readable per-side title, as the API returns it: the outcome's question followed by the side name.""" slug: Optional[str] = None """Per-side URL slug mirroring HL's URL pattern (e.g. 'btc-above-78213-yes-may-04-0600').""" @@ -1280,15 +1280,17 @@ class Candle(BaseModel): - Live and replay: ``orderbook``, ``trades``, ``liquidations``, ``open_interest``, ``funding`` and their ``hip3_*`` counterparts; - ``hip4_orderbook``, ``hip4_trades``, ``hip4_open_interest``; the four live - ``lighter_*`` and ``rh_lighter_*`` channels; every L4 channel - (``l4_diffs``, ``l4_orders`` and the ``hip3_``, ``hip4_`` and ``spot_`` - versions); and the full-depth L2 channels ``orderbook_full`` and - ``hip3_orderbook_full``. -- Replay only: ``candles``, ``hip3_candles``, ``lighter_candles``, - ``lighter_l3_orderbook`` and ``rh_lighter_candles``. -- Live only: ``ticker``, ``all_tickers``, ``spot_orderbook``, ``spot_trades`` - and ``spot_twap``. + ``hip4_trades``; the four live ``lighter_*`` and ``rh_lighter_*`` channels; + every L4 channel (``l4_diffs``, ``l4_orders`` and the ``hip3_``, ``hip4_`` + and ``spot_`` versions); and the full-depth L2 channels ``orderbook_full`` + and ``hip3_orderbook_full``. +- Replay only: ``candles``, ``hip3_candles``, ``hip4_orderbook``, + ``hip4_open_interest``, ``lighter_candles``, ``lighter_l3_orderbook`` and + ``rh_lighter_candles``. +- Live only: ``ticker``, ``all_tickers``, ``spot_orderbook`` and + ``spot_trades``. +- Neither: ``spot_twap``. Spot TWAP statuses are served over REST only + (``client.spot.twap``); subscribing raises ``ValueError``. L4 and full-depth replay is bulk: it is single-channel only, ``speed`` is ignored and ``replay.seek`` is refused. It opens with one ``l4_snapshot`` from diff --git a/oxarchive/websocket.py b/oxarchive/websocket.py index 4071eb3..235e11d 100644 --- a/oxarchive/websocket.py +++ b/oxarchive/websocket.py @@ -146,6 +146,10 @@ def _replay_only(venue: str, datatype: str) -> WsChannelSpec: return WsChannelSpec(venue, datatype, live=False, replay=True) +def _rest_only(venue: str, datatype: str) -> WsChannelSpec: + return WsChannelSpec(venue, datatype, live=False, replay=False) + + WS_CHANNELS: Mapping[str, WsChannelSpec] = MappingProxyType( { # Hyperliquid core @@ -171,17 +175,17 @@ def _replay_only(venue: str, datatype: str) -> WsChannelSpec: "hip3_open_interest": _both("hip3", "oi"), "hip3_liquidations": _both("hip3", "liquidations"), # HIP-4 - "hip4_orderbook": _both("hip4", "l2_orderbook"), + "hip4_orderbook": _replay_only("hip4", "l2_orderbook"), "hip4_l4_diffs": _both("hip4", "l4_diffs", bulk=True), "hip4_l4_orders": _both("hip4", "l4_orders", bulk=True), "hip4_trades": _both("hip4", "trades"), - "hip4_open_interest": _both("hip4", "oi"), + "hip4_open_interest": _replay_only("hip4", "oi"), # Hyperliquid spot "spot_orderbook": _live_only("spot", "l2_orderbook"), "spot_l4_diffs": _both("spot", "l4_diffs", bulk=True), "spot_l4_orders": _both("spot", "l4_orders", bulk=True), "spot_trades": _live_only("spot", "trades"), - "spot_twap": _live_only("spot", "twap"), + "spot_twap": _rest_only("spot", "twap"), # Lighter "lighter_orderbook": _both("lighter", "l2_orderbook"), "lighter_l3_orderbook": _replay_only("lighter", "l3_orderbook"), @@ -202,7 +206,12 @@ def _replay_only(venue: str, datatype: str) -> WsChannelSpec: each row). The SDK refuses, before sending, a live subscription to a channel whose ``live`` is false, a replay of a channel whose ``replay`` is false, and a bulk-replay channel inside :meth:`OxArchiveWs.multi_replay`. A channel missing -from this table is sent as given and the server decides.""" +from this table is sent as given and the server decides. + +``hip4_orderbook`` and ``hip4_open_interest`` replay but do not stream live. +``spot_twap`` neither streams nor replays: capabilities lists spot TWAP as REST +only (``client.spot.twap``), and it is kept here so that subscribing to it +fails with that explanation instead of waiting for messages that never come.""" LIVE_CHANNELS: frozenset[str] = frozenset(c for c, spec in WS_CHANNELS.items() if spec.live) """Channels a subscription streams live.""" @@ -334,6 +343,13 @@ def _replay_only(venue: str, datatype: str) -> WsChannelSpec: ) """Raised by :meth:`OxArchiveWs.subscribe` for a replay-only channel.""" +REST_ONLY_ERROR = ( + "{channel} does not support historical replay or live subscriptions; its data " + "is served over REST. client.capabilities() lists what each channel offers." +) +"""Raised by :meth:`OxArchiveWs.subscribe` and :meth:`OxArchiveWs.replay` for a +channel that neither streams nor replays (``spot_twap``).""" + # Large dataset downloads: the S3 Parquet bulk export. _BULK_EXPORT_URL = "https://0xarchive.io/data" @@ -359,7 +375,8 @@ def _validate_replay_channel(channel: WsChannel, *, multi: bool = False) -> None if spec is None: return if not spec.replay: - raise ValueError(NOT_REPLAYABLE_ERROR.format(channel=channel)) + error = NOT_REPLAYABLE_ERROR if spec.live else REST_ONLY_ERROR + raise ValueError(error.format(channel=channel)) if multi and spec.bulk_replay: raise ValueError(BULK_REPLAY_MULTI_CHANNEL_ERROR.format(channel=channel)) @@ -367,9 +384,10 @@ def _validate_replay_channel(channel: WsChannel, *, multi: bool = False) -> None def _validate_live_subscription(channel: WsChannel, interval_ms: Optional[int] = None) -> None: """Reject live subscriptions the server would refuse, before any state changes. - Replay-only channels (:data:`WS_CHANNELS` entries whose ``live`` is false: - ``candles``, ``hip3_candles``, ``lighter_candles``, - ``lighter_l3_orderbook`` and ``rh_lighter_candles``) are refused, and + Channels without live data (:data:`WS_CHANNELS` entries whose ``live`` is + false: ``candles``, ``hip3_candles``, ``hip4_orderbook``, + ``hip4_open_interest``, ``lighter_candles``, ``lighter_l3_orderbook``, + ``rh_lighter_candles`` and ``spot_twap``) are refused, and ``interval_ms`` is accepted only on ``lighter_orderbook`` and ``rh_lighter_orderbook``, as an integer between ``LIGHTER_BOOK_INTERVAL_MIN_MS`` and ``LIGHTER_BOOK_INTERVAL_MAX_MS``. @@ -380,7 +398,8 @@ def _validate_live_subscription(channel: WsChannel, interval_ms: Optional[int] = raise ValueError(RH_LIGHTER_SUBSCRIPTION_ERROR) spec = WS_CHANNELS.get(channel) if spec is not None and not spec.live: - raise ValueError(NOT_LIVE_ERROR.format(channel=channel)) + error = NOT_LIVE_ERROR if spec.replay else REST_ONLY_ERROR + raise ValueError(error.format(channel=channel)) if interval_ms is None: return if channel not in LIGHTER_BOOK_CHANNELS: @@ -883,9 +902,11 @@ def subscribe( second. Each book sent is one metered message. Raises: - ValueError: If ``channel`` is replay-only in :data:`WS_CHANNELS` - (``candles``, ``hip3_candles``, ``lighter_candles``, - ``lighter_l3_orderbook`` or ``rh_lighter_candles``), or if + ValueError: If ``channel`` has no live data in :data:`WS_CHANNELS` + (``candles``, ``hip3_candles``, ``hip4_orderbook``, + ``hip4_open_interest``, ``lighter_candles``, + ``lighter_l3_orderbook``, ``rh_lighter_candles`` or + ``spot_twap``), or if ``interval_ms`` is passed for another channel than ``lighter_orderbook`` or ``rh_lighter_orderbook``, is not an integer, or is out of range. @@ -906,7 +927,7 @@ async def subscribe_async( """Subscribe asynchronously to a supported live channel. Takes the same arguments as :meth:`subscribe` and refuses the same - replay-only channels. + channels. """ _validate_live_subscription(channel, interval_ms) self._remember_subscription(channel, coin, interval_ms) @@ -1025,9 +1046,11 @@ def unsubscribe_hip3_orderbook_full(self, coin: str) -> None: def subscribe_hip4_orderbook(self, coin: str) -> None: """Subscribe to the live HIP-4 L2 book for a per-side coin. - ``coin`` should be the on-chain ``#N`` form (e.g. ``"#0"``). The raw - ``#`` is sent in the JSON body; only the REST path strips it. Stored - history replays on the same channel. + The channel does not stream live (``/v1/capabilities``), so this + raises ``ValueError`` before anything is sent. Replay its stored + history with ``replay("hip4_orderbook", coin, ...)``, read the current + book with ``client.hyperliquid.hip4.orderbook.get()``, or follow the + book live on ``hip4_l4_diffs``. """ self.subscribe("hip4_orderbook", coin) @@ -1046,7 +1069,10 @@ def unsubscribe_hip4_trades(self, coin: str) -> None: def subscribe_hip4_open_interest(self, coin: str) -> None: """Subscribe to live HIP-4 per-side open interest for a coin. - Stored history replays on the same channel. + The channel does not stream live (``/v1/capabilities``), so this + raises ``ValueError`` before anything is sent. Replay its stored + history with ``replay("hip4_open_interest", coin, ...)`` or read it + with ``client.hyperliquid.hip4.open_interest``. """ self.subscribe("hip4_open_interest", coin) @@ -1117,7 +1143,12 @@ def unsubscribe_spot_l4_orders(self, coin: str) -> None: self.unsubscribe("spot_l4_orders", coin) def subscribe_spot_twap(self, coin: str) -> None: - """Subscribe to live spot TWAP status updates for a pair.""" + """Subscribe to live spot TWAP status updates for a pair. + + Spot TWAP statuses are served over REST only (``/v1/capabilities``), + so this raises ``ValueError`` before anything is sent. Read them with + ``client.spot.twap.history()`` or ``client.spot.twap.by_user()``. + """ self.subscribe("spot_twap", coin) def unsubscribe_spot_twap(self, coin: str) -> None: @@ -1274,9 +1305,9 @@ async def replay( """Start historical replay with timing preserved. Every channel whose :data:`WS_CHANNELS` entry has ``replay`` replays; - the live-only channels (``ticker``, ``all_tickers``, - ``spot_orderbook``, ``spot_trades``, ``spot_twap``) are refused here - with ``ValueError`` before anything is sent. + the others (``ticker``, ``all_tickers``, ``spot_orderbook``, + ``spot_trades`` and ``spot_twap``) are refused here with + ``ValueError`` before anything is sent. L4 channels (``l4_diffs``, ``l4_orders`` and the ``hip3_``, ``hip4_`` and ``spot_`` versions) and the full-depth L2 channels diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index a9cde83..c981c53 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -1080,7 +1080,7 @@ def test_l3_history_granularity_stays_refused() -> None: # =========================================================================== # ``ws_channels``, ``live`` and ``replay`` of every row of GET /v1/capabilities -# that names a channel, as served on 2026-09-29. +# that names a channel, as served on 2026-10-04. CAPABILITY_CHANNELS: dict[str, tuple[str, bool, bool]] = { "orderbook": ("hyperliquid", True, True), "orderbook_full": ("hyperliquid", True, True), @@ -1102,16 +1102,15 @@ def test_l3_history_granularity_stays_refused() -> None: "hip3_funding": ("hip3", True, True), "hip3_open_interest": ("hip3", True, True), "hip3_liquidations": ("hip3", True, True), - "hip4_orderbook": ("hip4", True, True), + "hip4_orderbook": ("hip4", False, True), "hip4_l4_diffs": ("hip4", True, True), "hip4_l4_orders": ("hip4", True, True), "hip4_trades": ("hip4", True, True), - "hip4_open_interest": ("hip4", True, True), + "hip4_open_interest": ("hip4", False, True), "spot_orderbook": ("spot", True, False), "spot_l4_diffs": ("spot", True, True), "spot_l4_orders": ("spot", True, True), "spot_trades": ("spot", True, False), - "spot_twap": ("spot", True, False), "lighter_orderbook": ("lighter", True, True), "lighter_l3_orderbook": ("lighter", False, True), "lighter_trades": ("lighter", True, True), @@ -1126,9 +1125,17 @@ def test_l3_history_granularity_stays_refused() -> None: } +# Channels the SDK names that no capabilities row lists, with the ``live`` and +# ``replay`` of their datatype's row: spot TWAP is served over REST only. +REST_ONLY_CHANNELS: dict[str, tuple[str, bool, bool]] = { + "spot_twap": ("spot", False, False), +} + + def test_channel_table_mirrors_capabilities() -> None: - assert set(WS_CHANNELS) == set(CAPABILITY_CHANNELS) == set(get_args(WsChannel)) - for channel, (venue, live, replay) in CAPABILITY_CHANNELS.items(): + assert set(WS_CHANNELS) == set(CAPABILITY_CHANNELS) | set(REST_ONLY_CHANNELS) + assert set(WS_CHANNELS) == set(get_args(WsChannel)) + for channel, (venue, live, replay) in {**CAPABILITY_CHANNELS, **REST_ONLY_CHANNELS}.items(): spec = WS_CHANNELS[channel] assert (spec.venue, spec.live, spec.replay) == (venue, live, replay), channel @@ -1204,6 +1211,32 @@ def test_live_channels_subscribe_and_replayable_channels_multi_replay() -> None: assert len(ws._subscriptions) == len(LIVE_CHANNELS) +@pytest.mark.parametrize("channel", ["hip4_orderbook", "hip4_open_interest"]) +def test_hip4_book_and_open_interest_replay_but_do_not_stream(channel: str) -> None: + ws, recorder = _offline() + + with pytest.raises(ValueError, match="supports replay, not live subscriptions"): + ws.subscribe(cast(WsChannel, channel), "#82260") + asyncio.run(ws.replay(cast(WsChannel, channel), "#82260", start=T_START, end=T_END)) + + assert ws._subscriptions == set() + assert [m["channel"] for m in recorder.sent] == [channel] + + +def test_spot_twap_neither_streams_nor_replays() -> None: + ws, recorder = _offline() + + for call in ( + lambda: ws.subscribe_spot_twap("HYPE-USDC"), + lambda: asyncio.run(ws.subscribe_async("spot_twap", "HYPE-USDC")), + lambda: asyncio.run(ws.replay("spot_twap", "HYPE-USDC", start=T_START)), + ): + with pytest.raises(ValueError, match="served over REST"): + call() + + assert ws._subscriptions == set() and recorder.sent == [] + + def test_a_channel_missing_from_the_table_is_left_to_the_server() -> None: ws, recorder = _offline() asyncio.run(ws.replay(cast(WsChannel, "future_channel"), "BTC", start=T_START)) diff --git a/tests/test_api_contract_live.py b/tests/test_api_contract_live.py index 20bf5e4..b26cf1e 100644 --- a/tests/test_api_contract_live.py +++ b/tests/test_api_contract_live.py @@ -75,6 +75,7 @@ def test_capabilities_match_the_websocket_channel_table(client: Client) -> None: rows = client.capabilities() assert rows and all(row.venue and row.datatype for row in rows) served = {} + by_datatype = {(row.venue, row.datatype): row for row in rows} for row in rows: for channel in row.ws_channels: served[channel] = (row.venue, row.live, row.replay) @@ -82,6 +83,12 @@ def test_capabilities_match_the_websocket_channel_table(client: Client) -> None: for channel, (venue, live, replay) in served.items(): spec = WS_CHANNELS[channel] assert (spec.venue, spec.live, spec.replay) == (venue, live, replay), channel + # A channel no row names must be one its datatype's row says neither + # streams nor replays (spot TWAP is served over REST only). + for channel, spec in WS_CHANNELS.items(): + if channel not in served: + row = by_datatype[(spec.venue, spec.datatype)] + assert not (spec.live or spec.replay or row.live or row.replay), channel @pytest.mark.parametrize( @@ -100,7 +107,6 @@ def test_trades_side_filters_on_hyperliquid_venues( assert {t.side for t in trades.recent(symbol, limit=20, side="sell")} <= {"A"} -@pytest.mark.xfail(reason="the API answered side filters on Lighter with 500 on 2026-09-29", strict=False) @pytest.mark.parametrize("deployment", ["lighter", "rh_lighter"]) def test_trades_side_filters_on_lighter(client: Client, deployment: str) -> None: trades = getattr(client, deployment).trades diff --git a/tests/test_coverage_contract.py b/tests/test_coverage_contract.py index 66f021e..2846161 100644 --- a/tests/test_coverage_contract.py +++ b/tests/test_coverage_contract.py @@ -383,9 +383,9 @@ def test_public_copy_keeps_family_specific_coverage() -> None: assert "raw ~1 min" not in readme assert "no funding, no liquidations, and no candles" not in readme assert "no funding / liquidations / candles" not in types - # HIP-4 order book and open interest stream live and replay (capabilities). + # HIP-4 order book and open interest replay but do not stream live (capabilities). assert "live bridges paused" not in types - assert "``hip4_orderbook``, ``hip4_trades``, ``hip4_open_interest``" in types + assert "Replay only: ``candles``, ``hip3_candles``, ``hip4_orderbook``, ``hip4_open_interest``" in " ".join(types.split()) assert "250 orders per side" in l3_resource assert "price levels per side" not in l3_resource assert "self.candles = CandlesResource" in exchanges From bdcedb4d00761b1bbe43de177f3f228675a34c10 Mon Sep 17 00:00:00 2001 From: 0xFantomMenace <93462858+0xFantomMenace@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:57:49 +0000 Subject: [PATCH 4/4] README and docstrings: a Quick Start that runs as pasted, exact coverage dates, examples inside coverage - Quick Start: no placeholder wallet (it finds one from the BTC position listing), an active HIP-3 market (xyz:TSLA) instead of an empty one, and order book history for the last hour instead of a fixed 2024 window. README and docstring examples use the same HIP-3 market. - Coverage table and docstrings give each dataset's first served instant as /v1/capabilities reports it, replacing month-only dates and stale starts: spot L4 from 2026-05-05 22:57 UTC (PURR-USDC 2026-03-11 01:03 UTC), spot candles from 2025-03-22 10:50 UTC, Hyperliquid liquidations from 2025-12-22, Robinhood Chain candles from 2026-06-26 20:10 UTC (served, no longer "once enabled"). Market counts are not pinned; the listing calls return the current sets. - Examples read the last hour, day or week, which every plan can reach, instead of fixed dates that fell before coverage (candles, the Lighter order book, L2, L3, L4, order history, liquidations) or outside the Free plan's 30-day history. HIP-4 examples find an open outcome. - The Lighter tick-level example uses orderbook.history_tick(); orderbook.history() returns snapshots, and its docstring now says so. - The positions examples no longer reuse the name now for a response. - CHANGELOG: an "Upgrading from 1.7" note at the top of 1.12.0 lists the changes that can break code written against the last PyPI release. --- CHANGELOG.md | 63 ++- README.md | 520 +++++++++++--------- oxarchive/__init__.py | 17 +- oxarchive/client.py | 34 +- oxarchive/exchanges.py | 45 +- oxarchive/resources/candles.py | 2 +- oxarchive/resources/cvd.py | 2 +- oxarchive/resources/data_quality.py | 4 +- oxarchive/resources/funding.py | 5 +- oxarchive/resources/instruments.py | 6 +- oxarchive/resources/l2_orderbook.py | 8 +- oxarchive/resources/l4_orderbook.py | 10 +- oxarchive/resources/lighter_liquidations.py | 5 +- oxarchive/resources/liquidations.py | 24 +- oxarchive/resources/openinterest.py | 5 +- oxarchive/resources/oracle.py | 6 +- oxarchive/resources/orderbook.py | 6 +- oxarchive/resources/orders.py | 13 +- oxarchive/resources/positions.py | 5 +- oxarchive/resources/spot.py | 6 +- oxarchive/resources/trades.py | 8 +- oxarchive/types.py | 13 +- oxarchive/websocket.py | 2 +- tests/test_api_contract.py | 4 +- tests/test_coverage_contract.py | 9 +- 25 files changed, 464 insertions(+), 358 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdfe4d..bc01c05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,35 @@ codes, `has_more` on every paged response, the symbol and venue of every per-symbol response, `client.capabilities()`, and replay for every L4 and full-depth channel. +### Upgrading from 1.7 + +The last release on PyPI before this one is 1.7.0; versions 1.7.1 to 1.11.0 +were not published, and their changes are listed below. These are the +changes that can break code written against 1.7.0: + +- Lighter `funding_rate` is a fractional, non-annualized rate, no longer a + percent (1.10.0, Breaking). Remove any division by 100. +- Times without a time zone are read as UTC on every method, not as the + machine's local time (this release, Fixed). +- Methods that called routes the API does not serve are removed, with their + async versions: `client.hyperliquid.hip4.l2_orderbook`, + `client.spot.orders.flow()` and `tpsl()`, and + `client.hyperliquid.hip3.liquidations.by_user()` (this release, Removed). +- Parameters the API never applied raise `TypeError` instead of being + ignored, such as `user` or `status` on `client.spot.orders.history()` + (this release, Changed). +- WebSocket: `subscribe()` raises `ValueError` for a channel without live + data (`candles`, `hip3_candles`, `hip4_orderbook`, `hip4_open_interest`, + `spot_twap`), `replay()` raises it for a channel without replay, and + Lighter replay messages have the live shapes (this release). `stream()`, + `multi_stream()` and `stream_stop()` are deprecated because the server no + longer streams bulk data (1.11.0, Deprecated). +- In an L4 snapshot from `l4_orderbook.get()`, each resting order's + `timestamp` is an RFC 3339 string, with `timestamp_ms` beside it (this + release, Added). +- `SpotPair` and `SpotTwapStatus` have the fields the API returns (1.8.0, + Fixed). + ### Added - API version 2026-10-01. Every REST request, sync and async, sends `0xArchive-Version: 2026-10-01` (`oxarchive.API_VERSION`, @@ -112,9 +141,8 @@ full-depth channel. `positions`, and `get_freshness()`, `get_summary()`, `get_price_history()`. Markets are USDG-quoted: perps use uppercase symbols (`BTC`), spot markets dashed symbols (`AAPL-USDG`). Trades and liquidations start at the venue - launch, 2026-06-26 20:10:26 UTC; order book, open interest and funding - start 2026-08-22 18:43 UTC. Candles are served from 2026-06-26 once enabled - for this deployment. + launch, 2026-06-26 20:10:26 UTC; candles start 2026-06-26 20:10 UTC; order + book, open interest and funding start 2026-08-22 18:43 UTC. - Lighter liquidations on both deployments: `client.lighter.liquidations` and `client.rh_lighter.liquidations` with `history()` and `volume()` (and async `ahistory()` / `avolume()`), typed as `LighterLiquidation` and @@ -376,15 +404,26 @@ full-depth channel. metadata, capabilities and error codes; the trades, orders, L2, L3, L4 and WebSocket replay sections describe the filters and replays above. The channel tables mirror `/v1/capabilities`. -- The README's spot coverage row lists each dataset's first date: L4 and - order history from 2026-03-10 (L4 replay from 2026-03-11 01:03 UTC), order - book from 2026-05-05, TWAP from May 2026. It said all of them started in - May 2026. -- The README's HIP-3 coverage row lists each dataset's first date, replacing - "February 2026+": trades and oracle prices from 2025-10-13, candles and - liquidations from 2025-12-22, order book, funding and open interest from - 2026-02-16, L4 and order history from 2026-03-10. Hyperliquid liquidations - are listed from 2025-12-22 (was "May 2025+"). +- The README's coverage table gives each dataset's first served instant on + every venue, as `/v1/capabilities` reports it, and the docstrings give the + same dates. It replaces month-only dates (Hyperliquid "April 2023+", HIP-3 + "February 2026+", HIP-4 "May 2026+", spot TWAP "May 2026"), lists + Hyperliquid liquidations from 2025-12-22 (was "May 2025+"), and corrects + the spot L4 start (2026-05-05 22:57 UTC; PURR-USDC from 2026-03-11 01:03 + UTC) and the spot candle start (2025-03-22 10:50 UTC). +- The Quick Start runs as pasted on any plan: it reads an active HIP-3 + market (`xyz:TSLA`), finds a real wallet for the positions call instead of + a placeholder address, and reads the last hour of order book history. +- Examples read recent windows (the last hour, day or week), which every + plan can reach, instead of fixed dates. Several of those dates fell before + the dataset's coverage (candles, the Lighter order book, L2, L3 and L4, + order history and liquidations) or outside the Free plan's 30-day history. + HIP-4 examples find an open outcome instead of a settled one. +- The Lighter tick-level example uses `orderbook.history_tick()`. + `orderbook.history()` returns snapshots and cannot parse tick data, and its + docstring now says so. +- Market counts are no longer pinned; `client.rh_lighter.instruments.list()` + and `client.spot.pairs.list()` return the current sets. - The HIP-3 coin table is removed, since builders list and delist markets; call `client.hyperliquid.hip3.instruments.list()` for the current set. - Documentation links point at docs.0xarchive.io. diff --git a/README.md b/README.md index 4f4069f..b0c6165 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ pip install oxarchive[websocket] ## Quick Start ```python +from datetime import datetime, timedelta, timezone + from oxarchive import Client client = Client(api_key="0xa_your_api_key") @@ -38,31 +40,37 @@ print(f"Lighter BTC mid price: {lighter_orderbook.mid_price}") # Lighter on Robinhood Chain: the second Lighter deployment (USDG-quoted) rh_orderbook = client.rh_lighter.orderbook.get("AAPL-USDG") +print(f"Robinhood Chain AAPL-USDG mid price: {rh_orderbook.mid_price}") -# Account positions: what a wallet holds now -positions = client.hyperliquid.positions.get("0xabc...") -for p in positions.data.positions: - print(p.symbol, p.side, p.size, p.unrealized_pnl) - -# Hyperliquid HIP-3 builder perps stay under client.hyperliquid.hip3 +# Hyperliquid HIP-3 builder perps stay under client.hyperliquid.hip3. +# Coins are case-sensitive and carry the builder prefix. hip3_instruments = client.hyperliquid.hip3.instruments.list() -hip3_orderbook = client.hyperliquid.hip3.orderbook.get("km:US500") -hip3_trades = client.hyperliquid.hip3.trades.recent("km:US500") -hip3_funding = client.hyperliquid.hip3.funding.current("xyz:XYZ100") -hip3_oi = client.hyperliquid.hip3.open_interest.current("xyz:XYZ100") +hip3_orderbook = client.hyperliquid.hip3.orderbook.get("xyz:TSLA") +hip3_trades = client.hyperliquid.hip3.trades.recent("xyz:TSLA", limit=10) +hip3_funding = client.hyperliquid.hip3.funding.current("xyz:TSLA") +hip3_oi = client.hyperliquid.hip3.open_interest.current("xyz:TSLA") +print(f"xyz:TSLA mid: {hip3_orderbook.mid_price}, funding: {hip3_funding.funding_rate}") # Hyperliquid spot pairs live under client.spot. Symbols are dashed canonical. spot_pairs = client.spot.pairs.list() spot_orderbook = client.spot.orderbook.get("HYPE-USDC") print(f"HYPE-USDC mid: {spot_orderbook.mid_price}") -# Get historical order book snapshots +# Account positions: the largest open BTC position, then everything its wallet holds +largest = client.hyperliquid.positions.market("BTC", limit=1).data[0] +positions = client.hyperliquid.positions.get(largest.user_address) +for p in positions.data.positions: + print(p.symbol, p.side, p.size, p.unrealized_pnl) + +# Order book history for the last hour (every plan reads the most recent 30 days) +now = datetime.now(timezone.utc) history = client.hyperliquid.orderbook.history( "ETH", - start="2024-01-01", - end="2024-01-02", - limit=100 + start=now - timedelta(hours=1), + end=now, + limit=100, ) +print(f"{len(history.data)} ETH snapshots; more pages: {history.has_more}") ``` ## Choose Your Next Path @@ -78,17 +86,19 @@ history = client.hyperliquid.orderbook.history( ## Data Coverage +First served instant of each dataset, as `client.capabilities()` reports it (times are UTC): + | Venue | Coverage | Notes | | --- | --- | --- | -| Hyperliquid | April 2023+ | Core perpetuals; coverage varies by schema and route. | -| Hyperliquid HIP-3 | Trades and oracle prices from 2025-10-13; candles and liquidations from 2025-12-22; order book, funding, and OI from 2026-02-16; L4 and order history from 2026-03-10 | Builder perps; funding and OI update at roughly 10 seconds. | -| Hyperliquid HIP-4 | May 2026+ | Outcome markets. Candles and outcome-side OI are served from 2026-05-02; OI updates at ~10s. No funding or liquidations. | -| Hyperliquid Spot | Trades and candles from 2025-03-22; candle coverage starts exactly 2025-03-22T10:50:22Z; L4 and order history from 2026-03-10 (L4 replay from 2026-03-11 01:03 UTC); order book from 2026-05-05; TWAP from 2026-05 | Dashed canonical symbols (`HYPE-USDC`, `PURR-USDC`); `client.spot.pairs.list()` lists the pairs. Candle intervals are 1m/5m/15m/30m/1h/4h/1d/1w with a 1,000-row page cap and numeric timestamp-string cursors; pass each `next_cursor` back unchanged. No funding/OI/liquidations. | -| Lighter | Observed global per-fill trade floor January 17, 2025; exact starts vary by market. L3 from March 5, 2026+ | Maker/taker trade context; L3 caps at 250 orders per side; funding/OI update at ~10s. | -| Lighter on Robinhood Chain | Trades and liquidations from 2026-06-26 20:10:26 UTC (venue launch); order book, OI, and funding from 2026-08-22 18:43 UTC | The second Lighter deployment. 84 USDG-quoted markets: 57 perps (`BTC`) and 27 spot (`AAPL-USDG`). Candles from 2026-06-26 once enabled. No L3. | +| Hyperliquid | Order book from 2023-04-15; trades from 2023-04-15 03:31; funding, open interest and price history from 2023-05-20 02:50; candles from 2025-03-22 12:00; liquidations from 2025-12-22; L4, order history and full-depth L2 from 2026-03-11 01:03 | Core perpetuals. | +| Hyperliquid HIP-3 | Price history from 2025-10-13 12:12; trades from 2025-10-13 12:24; candles and liquidations from 2025-12-22; order book from 2026-02-16 16:57; funding and OI from 2026-02-16 17:03; L4, order history and full-depth L2 from 2026-03-11 01:03 | Builder perps; funding and OI update at roughly 10 seconds. | +| Hyperliquid HIP-4 | L4 and order history from 2026-05-02 07:47; trades and candles from 2026-05-02 08:00; order book, outcome-side OI and price history from 2026-05-02 16:51 | Outcome markets; OI updates at ~10s. No funding or liquidations. | +| Hyperliquid Spot | Candles from 2025-03-22 10:50; trades from 2025-03-22 10:50:22; TWAP from 2026-05-05 13:05; order book from 2026-05-05 19:56; L4 from 2026-05-05 22:57 (PURR-USDC from 2026-03-11 01:03), with REST order history reaching back further | Dashed canonical symbols (`HYPE-USDC`, `PURR-USDC`); `client.spot.pairs.list()` lists the pairs. Candle intervals are 1m/5m/15m/30m/1h/4h/1d/1w with a 1,000-row page cap and numeric timestamp-string cursors; pass each `next_cursor` back unchanged. No funding/OI/liquidations. | +| Lighter | Trades from 2025-01-17 08:43; candles from 2025-08-01; funding, OI and price history from 2025-08-25 15:28; order book from 2026-01-29 02:13; L3 from 2026-03-05 03:33; liquidations from 2026-06-10 | Exact starts vary by market. Maker/taker trade context; L3 caps at 250 orders per side; funding/OI update at ~10s. | +| Lighter on Robinhood Chain | Trades and liquidations from 2026-06-26 20:10:26 UTC (venue launch); candles from 2026-06-26 20:10; order book, OI, funding and price history from 2026-08-22 18:43 | The second Lighter deployment: USDG-quoted perps (`BTC`) and spot markets (`AAPL-USDG`); `client.rh_lighter.instruments.list()` lists them. No L3. | | Account positions | Hyperliquid change log from 2025-05-25, HIP-3 from 2025-10-13, hourly history from 2026-06-07; Lighter mainnet from 2025-01-17, Robinhood Chain from 2026-06-26 | Live snapshots every 5 minutes (Hyperliquid, HIP-3) or 2 minutes (Lighter). See [Account Positions](#account-positions). | -`client.capabilities()` returns the first served instant of every datatype on every venue, and `client.symbols.list()` the coverage of each symbol. See [Capabilities](#capabilities). +`client.capabilities()` returns the first served instant of every datatype on every venue, and `client.symbols.list()` the coverage of each symbol, so a market listed later starts later. See [Capabilities](#capabilities). Free plans read the most recent 30 days of these datasets. ## Async Support @@ -148,11 +158,16 @@ Every request sends `0xArchive-Version: 2026-10-01` (`oxarchive.API_VERSION`), a Paged methods return a `CursorResponse` with `data`, `has_more`, `next_cursor` and `meta`. While `has_more` is `True`, pass `next_cursor` back unchanged as `cursor`, with the other arguments unchanged. Stop when `has_more` is `False`: a page can be short, or even empty, and still not be the last, so stop on `has_more` rather than on the page size. ```python -page = client.hyperliquid.trades.history("BTC", start="2026-09-01", end="2026-09-02", limit=1000) +from datetime import datetime, timedelta, timezone + +end = datetime.now(timezone.utc) +start = end - timedelta(hours=1) + +page = client.hyperliquid.trades.history("BTC", start=start, end=end, limit=1000) trades = list(page.data) while page.has_more: page = client.hyperliquid.trades.history( - "BTC", start="2026-09-01", end="2026-09-02", limit=1000, cursor=page.next_cursor + "BTC", start=start, end=end, limit=1000, cursor=page.next_cursor ) trades.extend(page.data) ``` @@ -167,7 +182,7 @@ The iterators (`cvd.iterate()`, `positions.iterate_history()` and the other `ite book = client.hyperliquid.hip3.orderbook.get("xyz:TSLA") print(book.response_meta.venue, book.response_meta.symbol) # hip3 xyz:TSLA -page = client.spot.trades.history("HYPE-USDC", start="2026-09-01", end="2026-09-02") +page = client.spot.trades.history("HYPE-USDC", start=start, end=end) print(page.meta.venue, page.meta.symbol, page.meta.request_id) ``` @@ -195,6 +210,17 @@ Per-symbol coverage is on `client.symbols.list()`. The WebSocket client checks s All examples use `client.hyperliquid.*` but the same methods are available on `client.lighter.*` for Lighter data. +The examples read these recent windows, which every plan can reach (Free reads the most recent 30 days, at most 30 days per request): + +```python +from datetime import datetime, timedelta, timezone + +now = datetime.now(timezone.utc) +hour_ago = now - timedelta(hours=1) +day_ago = now - timedelta(days=1) +week_ago = now - timedelta(days=7) +``` + ### Order Book ```python @@ -205,7 +231,7 @@ orderbook = client.hyperliquid.orderbook.get("BTC") orderbook = client.lighter.orderbook.get("BTC") # Get order book at specific timestamp -historical = client.hyperliquid.orderbook.get("BTC", timestamp=1704067200000) +historical = client.hyperliquid.orderbook.get("BTC", timestamp=day_ago) # Get with limited depth shallow = client.hyperliquid.orderbook.get("BTC", depth=10) @@ -213,20 +239,20 @@ shallow = client.hyperliquid.orderbook.get("BTC", depth=10) # Get historical snapshots (start and end are required) history = client.hyperliquid.orderbook.history( "BTC", - start="2024-01-01", - end="2024-01-02", + start=hour_ago, + end=now, limit=1000, depth=20 # Price levels per side ) # HIP-3 order book (case-sensitive coins) -hip3_ob = client.hyperliquid.hip3.orderbook.get("km:US500") -hip3_history = client.hyperliquid.hip3.orderbook.history("km:US500", start="2026-09-01", end="2026-09-02") +hip3_ob = client.hyperliquid.hip3.orderbook.get("xyz:TSLA") +hip3_history = client.hyperliquid.hip3.orderbook.history("xyz:TSLA", start=hour_ago, end=now) # Async versions orderbook = await client.hyperliquid.orderbook.aget("BTC") history = await client.hyperliquid.orderbook.ahistory("BTC", start=..., end=...) -hip3_ob = await client.hyperliquid.hip3.orderbook.aget("km:US500") +hip3_ob = await client.hyperliquid.hip3.orderbook.aget("xyz:TSLA") ``` #### Orderbook Depth @@ -237,7 +263,7 @@ The `depth` parameter is route-specific. Hyperliquid-family native L2 is capped #### Lighter Orderbook Granularity -Lighter order book history (mainnet and Robinhood Chain) supports a `granularity` parameter for different data resolutions. +Lighter order book history (mainnet and Robinhood Chain) supports a `granularity` parameter for different data resolutions. `history()` returns snapshots at `checkpoint`, `30s`, `10s` and `1s`; tick-level data (a checkpoint plus raw deltas) comes from `history_tick()` and the other methods under [Orderbook Reconstruction](#orderbook-reconstruction). | Granularity | Interval | Credit Multiplier | |-------------|----------|-------------------| @@ -251,25 +277,24 @@ Lighter order book history (mainnet and Robinhood Chain) supports a `granularity # Get Lighter orderbook history with 10s resolution history = client.lighter.orderbook.history( "BTC", - start="2024-01-01", - end="2024-01-02", + start=hour_ago, + end=now, granularity="10s" ) # Get 1-second resolution history = client.lighter.orderbook.history( "BTC", - start="2024-01-01", - end="2024-01-02", + start=hour_ago, + end=now, granularity="1s" ) -# Tick-level data - returns checkpoint + raw deltas -history = client.lighter.orderbook.history( +# Tick-level data: a checkpoint plus raw deltas +tick_data = client.lighter.orderbook.history_tick( "BTC", - start="2024-01-01", - end="2024-01-02", - granularity="tick" + start=hour_ago, + end=now, ) ``` @@ -364,15 +389,15 @@ async for snapshot in client.lighter.orderbook.aiterate_tick_history("BTC", star ```python # Get trade history with cursor-based pagination -result = client.hyperliquid.trades.history("ETH", start="2024-01-01", end="2024-01-02", limit=1000) +result = client.hyperliquid.trades.history("ETH", start=hour_ago, end=now, limit=1000) trades = list(result.data) # Paginate through all results while result.has_more: result = client.hyperliquid.trades.history( "ETH", - start="2024-01-01", - end="2024-01-02", + start=hour_ago, + end=now, cursor=result.next_cursor, limit=1000 ) @@ -380,22 +405,22 @@ while result.has_more: # One side of the tape: side="buy" (side == "B") or side="sell" (side == "A"). # The API filters before paging, so a full page holds `limit` matching trades. -buys = client.hyperliquid.trades.history("ETH", start="2024-01-01", end="2024-01-02", side="buy") +buys = client.hyperliquid.trades.history("ETH", start=hour_ago, end=now, side="buy") # Get recent trades (HIP-3, HIP-4, spot and Lighter), optionally one side recent = client.lighter.trades.recent("BTC", limit=100) recent_sells = client.spot.trades.recent("HYPE-USDC", limit=100, side="sell") # HIP-3 recent trades (case-sensitive coins) -hip3_recent = client.hyperliquid.hip3.trades.recent("km:US500", limit=100) +hip3_recent = client.hyperliquid.hip3.trades.recent("xyz:TSLA", limit=100) # HIP-3 trade history -hip3_trades = client.hyperliquid.hip3.trades.history("km:US500", start="2026-09-01", end="2026-09-02") +hip3_trades = client.hyperliquid.hip3.trades.history("xyz:TSLA", start=day_ago, end=now) # Async versions result = await client.hyperliquid.trades.ahistory("ETH", start=..., end=...) recent = await client.lighter.trades.arecent("BTC", limit=100) -hip3_recent = await client.hyperliquid.hip3.trades.arecent("km:US500", limit=100) +hip3_recent = await client.hyperliquid.hip3.trades.arecent("xyz:TSLA", limit=100) ``` **Note:** `recent()` is available on HIP-3, HIP-4, spot and both Lighter deployments. The API does not serve it for Hyperliquid core, so `client.hyperliquid.trades.recent()` raises `OxArchiveError` with `error_code == "unsupported_for_venue"` before sending; use `history()` with a time range there. @@ -455,15 +480,15 @@ for inst in hip3_instruments: print(f"{inst.coin} ({inst.namespace}:{inst.ticker}): mark={inst.mark_price}, OI={inst.open_interest}") # Get specific HIP-3 instrument (case-sensitive) -us500 = client.hyperliquid.hip3.instruments.get("km:US500") -print(f"Mark price: {us500.mark_price}") +tsla = client.hyperliquid.hip3.instruments.get("xyz:TSLA") +print(f"Mark price: {tsla.mark_price}") # Async versions hip3_instruments = await client.hyperliquid.hip3.instruments.alist() -us500 = await client.hyperliquid.hip3.instruments.aget("km:US500") +tsla = await client.hyperliquid.hip3.instruments.aget("xyz:TSLA") ``` -**HIP-3 coins:** builders list and delist markets over time, so this README does not pin a list. Call `client.hyperliquid.hip3.instruments.list()` for the current set. Coin names are case-sensitive and carry the builder prefix (`xyz:XYZ100`, `km:US500`). +**HIP-3 coins:** builders list and delist markets over time, so this README does not pin a list. Call `client.hyperliquid.hip3.instruments.list()` for the current set. Coin names are case-sensitive and carry the builder prefix (`xyz:TSLA`, `xyz:XYZ100`). #### HIP-3 Market Breadth @@ -474,15 +499,15 @@ current = client.hyperliquid.hip3.breadth.current() print(f"{current.value_pct}% above VWAP ({current.counts.eligible} eligible)") history = client.hyperliquid.hip3.breadth.history( - start="2026-08-28T00:00:00Z", - end="2026-08-29T00:00:00Z", + start=day_ago, + end=now, interval="5m", # 1m, 5m, 15m, 30m, 1h, 4h, 1d limit=1000, ) while history.has_more: history = client.hyperliquid.hip3.breadth.history( - start="2026-08-28T00:00:00Z", - end="2026-08-29T00:00:00Z", + start=day_ago, + end=now, interval="5m", cursor=history.next_cursor, limit=1000, @@ -495,7 +520,7 @@ The same breadth is served for Hyperliquid core perpetuals on `client.hyperliqui ```python current = client.hyperliquid.breadth.current() -history = client.hyperliquid.breadth.history(start="2026-08-24T00:00:00Z", interval="1h") +history = client.hyperliquid.breadth.history(start=day_ago, interval="1h") ``` #### HIP-3 Oracle @@ -503,22 +528,22 @@ history = client.hyperliquid.breadth.history(start="2026-08-24T00:00:00Z", inter The deployer-pushed external price of a HIP-3 market, and its instantaneous discovery bounds: ```python -price = client.hyperliquid.hip3.oracle.external_price("km:US500") +price = client.hyperliquid.hip3.oracle.external_price("xyz:TSLA") print(price.external_price, price.mark_price, price.block_number) -bounds = client.hyperliquid.hip3.oracle.discovery_bounds("km:US500") +bounds = client.hyperliquid.hip3.oracle.discovery_bounds("xyz:TSLA") print(bounds.reference_source, bounds.lower_bound, bounds.upper_bound) # Async versions -price = await client.hyperliquid.hip3.oracle.aexternal_price("km:US500") -bounds = await client.hyperliquid.hip3.oracle.adiscovery_bounds("km:US500") +price = await client.hyperliquid.hip3.oracle.aexternal_price("xyz:TSLA") +bounds = await client.hyperliquid.hip3.oracle.adiscovery_bounds("xyz:TSLA") ``` `external_price` and `mark_price` are `None` when the market has none. The discovery bounds are `reference_price` times one minus and one plus `bound_fraction`, where the reference is the external price when available and the mark price otherwise (`reference_source`) and the fraction follows from the market's max leverage. The full ratcheted range can be wider when a deployer's reset configuration applies. `timestamp` is a UTC `datetime`, and `timestamp_ms` the same instant in Unix milliseconds. #### HIP-4 Outcome Markets -HIP-4 binary-outcome markets resolve to ``Yes`` (side 0) or ``No`` (side 1) at expiry. Each outcome has two per-side coins (``#N``, where ``N = 10*outcome_id + side``). The SDK accepts both the bare numeric (``"0"``) and ``#``-prefixed (``"#0"``) forms. On REST paths it sends the bare form (the backend routes both to the same record). HIP-4 serves candles and outcome-side OI from 2026-05-02, with raw OI updates at ~10s. HIP-3 and Lighter candle pages accept up to 10,000 rows; HIP-4 candle pages are capped at 1,000 rows. HIP-4 has **no funding and no liquidations**. The ``mark_price`` field on HIP-4 OI/summary responses is an **implied probability in [0, 1]**, not a USD price. +HIP-4 binary-outcome markets resolve to ``Yes`` (side 0) or ``No`` (side 1) at expiry. Each outcome has two per-side coins (``#N``, where ``N = 10*outcome_id + side``). The SDK accepts both the bare numeric (``"0"``) and ``#``-prefixed (``"#0"``) forms. On REST paths it sends the bare form (the backend routes both to the same record). HIP-4 serves L4 and order history from 2026-05-02 07:47 UTC, trades and candles from 2026-05-02 08:00 UTC, and the order book, outcome-side OI and prices from 2026-05-02 16:51 UTC, with raw OI updates at ~10s. HIP-3 and Lighter candle pages accept up to 10,000 rows; HIP-4 candle pages are capped at 1,000 rows. HIP-4 has **no funding and no liquidations**. The ``mark_price`` field on HIP-4 OI/summary responses is an **implied probability in [0, 1]**, not a USD price. ```python # Outcome-level metadata (one row per outcome_id; sides folded into side_specs). @@ -526,48 +551,53 @@ result = client.hyperliquid.hip4.list_outcomes(is_settled=False, limit=50) for o in result.data: print(f"#{o.outcome_id}: {o.underlying} {o.class_} expiry={o.expiry}") +# Outcomes settle and new ones list every day, so find one rather than pin it. +# Here: the Yes side of an open BTC price outcome, from the symbol universe. +yes = next( + s for s in client.symbols.list() + if s.exchange == "hip4" and not s.is_settled + and (s.slug or "").startswith("btc-above-") and "-yes-" in s.slug +) +coin = yes.symbol # "#N" +outcome_id = int(coin[1:]) // 10 # N = 10 * outcome_id + side + # Single-outcome detail. Includes aggregated_oi (paired both-sides snapshot). -outcome = client.hyperliquid.hip4.get_outcome(0) +outcome = client.hyperliquid.hip4.get_outcome(outcome_id) agg = outcome.aggregated_oi print(f"Display OI: {agg.outcome_display_open_interest_contracts} {agg.currency}") print(f"Side parity: {agg.side_supply_parity}") # Look up by slug (per-outcome OR per-side). Returns aggregated_oi too. -outcome = client.hyperliquid.hip4.get_outcome_by_slug("btc-above-78213-may-04-0600") +outcome = client.hyperliquid.hip4.get_outcome_by_slug(outcome.slug) # Filter the list endpoint by slug. Short-circuits to a one-item response. -result = client.hyperliquid.hip4.list_outcomes(slug="btc-above-78213-yes-may-04-0600") +result = client.hyperliquid.hip4.list_outcomes(slug=yes.slug) # Questions group several binary outcomes under one multi-choice resolver: # one named outcome per choice, plus a fallback outcome that resolves Yes when # no named choice does. Page with next_cursor, passed back unchanged. page = client.hyperliquid.hip4.list_questions(limit=100) -question = client.hyperliquid.hip4.get_question(0) +question = client.hyperliquid.hip4.get_question(page.data[0].question_id) print(question.named_outcome_ids, question.fallback_outcome_id, question.settled_named_outcomes) # Also: client.hyperliquid.hip4.questions.list() / .get(), and alist() / aget() # Per-side instruments. Either bare or "#"-prefixed works. -yes = client.hyperliquid.hip4.instruments.get("0") # bare, recommended -no_ = client.hyperliquid.hip4.instruments.get("#1") # also works +yes_side = client.hyperliquid.hip4.instruments.get(coin[1:]) # bare, recommended +no_side = client.hyperliquid.hip4.instruments.get(f"#{10 * outcome_id + 1}") # also works # Market data. -ob = client.hyperliquid.hip4.get_orderbook("0") -trades = client.hyperliquid.hip4.get_trades_recent("0", limit=50) -candles = client.hyperliquid.hip4.candles.history( - "0", - start="2026-05-02T00:00:00Z", - end="2026-05-03T00:00:00Z", - interval="1h", -) -oi = client.hyperliquid.hip4.get_open_interest_current("0") # mark_price is in [0, 1] -summary = client.hyperliquid.hip4.get_summary("0") # mark_price is in [0, 1] +ob = client.hyperliquid.hip4.get_orderbook(coin) +trades = client.hyperliquid.hip4.get_trades_recent(coin, limit=50) +candles = client.hyperliquid.hip4.candles.history(coin, start=day_ago, end=now, interval="1h") +oi = client.hyperliquid.hip4.get_open_interest_current(coin) # mark_price is in [0, 1] +summary = client.hyperliquid.hip4.get_summary(coin) # mark_price is in [0, 1] ``` #### Hyperliquid Spot -Hyperliquid spot pairs live at `/v1/hyperliquid/spot` and are accessible via `client.spot`. Symbols use dashed canonical form (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format (`PURR/USDC` or `@107`) internally. Spot has **no funding, no open interest, or liquidations**. Candle history is served at `/v1/hyperliquid/spot/candles/{symbol}` from exactly `2025-03-22T10:50:22Z`, supports `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, and accepts a maximum of 1,000 rows per page with numeric timestamp-string cursors; pass each `next_cursor` back unchanged. +Hyperliquid spot pairs live at `/v1/hyperliquid/spot` and are accessible via `client.spot`. Symbols use dashed canonical form (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format (`PURR/USDC` or `@107`) internally. Spot has **no funding, no open interest, or liquidations**. Candle history is served at `/v1/hyperliquid/spot/candles/{symbol}` from 2025-03-22 10:50 UTC, supports `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, and accepts a maximum of 1,000 rows per page with numeric timestamp-string cursors; pass each `next_cursor` back unchanged. -Trade history goes back to 2025-03-22. L4 and the order lifecycle are served from 2026-03-10, the order book from 2026-05-05 and TWAP statuses from May 2026; `client.symbols.list()` has each pair's exact starts. +Trades are served from 2025-03-22 10:50:22 UTC, TWAP statuses from 2026-05-05 13:05 UTC, the order book from 2026-05-05 19:56 UTC, and L4 from 2026-05-05 22:57 UTC (PURR-USDC from 2026-03-11 01:03 UTC); REST order history reaches back further. Pairs listed later start later; `client.symbols.list()` has each pair's starts. ```python # Pair discovery @@ -583,16 +613,16 @@ ob = client.spot.orderbook.get("HYPE-USDC") print(f"HYPE-USDC mid: {ob.mid_price}, spread bps: {ob.spread_bps}") # Orderbook history -history = client.spot.orderbook.history("HYPE-USDC", start="2026-05-05", end="2026-05-06") +history = client.spot.orderbook.history("HYPE-USDC", start=hour_ago, end=now) # Trades by time window -trades = client.spot.trades.list("HYPE-USDC", start="2025-04-01", end="2025-04-02", limit=1000) +trades = client.spot.trades.list("HYPE-USDC", start=hour_ago, end=now, limit=1000) -# Candle history (coverage starts at 2025-03-22T10:50:22Z) +# Candle history (coverage starts at 2025-03-22 10:50 UTC) spot_candles = client.spot.candles.history( "HYPE-USDC", - start="2025-03-22T10:50:22Z", - end="2025-03-23T00:00:00Z", + start=day_ago, + end=now, interval="1h", limit=1000, ) @@ -627,7 +657,7 @@ fresh = await client.spot.aget_freshness("HYPE-USDC") Lighter has two deployments: mainnet (`client.lighter`) and Robinhood Chain (`client.rh_lighter`, REST root `/v1/rh-lighter`). The Robinhood Chain deployment has the same resources as `client.lighter` except the L3 order book, which is not captured there, and the L1 account resolver. Markets are quoted in USDG. Perps use uppercase symbols (`BTC`); spot markets use dashed symbols (`AAPL-USDG`). Symbols are case-insensitive. Market symbols and ids belong to each deployment, so `BTC` on `client.rh_lighter` is a different market from `BTC` on `client.lighter`. -Coverage: trades and liquidations from the venue launch, 2026-06-26 20:10:26 UTC; order book, open interest, and funding from 2026-08-22 18:43 UTC; account positions from 2026-06-26. A request that starts before a data type's first date is refused with the API's coverage error. Liquidations from before live capture were backfilled from the venue's finalized export: those rows have `source == "bucket"` and an empty `raw_json`, while rows captured live have `source == "ws"` and the venue's raw JSON. Candles are served from 2026-06-26 once they are enabled for this deployment; until then `candles.history()` raises `OxArchiveError` with the server's message. +Coverage: trades and liquidations from the venue launch, 2026-06-26 20:10:26 UTC; order book, open interest, and funding from 2026-08-22 18:43 UTC; account positions from 2026-06-26. A request that starts before a data type's first date is refused with the API's coverage error. Liquidations from before live capture were backfilled from the venue's finalized export: those rows have `source == "bucket"` and an empty `raw_json`, while rows captured live have `source == "ws"` and the venue's raw JSON. Candles are served from 2026-06-26 20:10 UTC. Trades follow the same finalization contract as mainnet Lighter. `trades.list()` returns canonical trades only: `end` is clamped to the finalization watermark, about a day behind, reported as `result.meta.finalized_through`, with `meta.requested_end` and `meta.clamped_to` set when the clamp applied. `trades.recent()` serves the preliminary tier. @@ -640,25 +670,25 @@ aapl = rh.instruments.get("AAPL-USDG") # Order book (from 2026-08-22 18:43 UTC) book = rh.orderbook.get("AAPL-USDG") -books = rh.orderbook.history("BTC", start="2026-09-01", end="2026-09-01T01:00:00Z") +books = rh.orderbook.history("BTC", start=hour_ago, end=now) -# Trades (from 2026-06-26 20:10:26 UTC) -page = rh.trades.list("BTC", start="2026-09-20", end="2026-09-21", limit=1000) +# Trades (from 2026-06-26 20:10:26 UTC). end is clamped to the finalization watermark. +page = rh.trades.list("BTC", start=day_ago, end=now, limit=1000) print(page.meta.finalized_through, page.meta.clamped_to) recent = rh.trades.recent("BTC") # preliminary tier # Open interest and funding (perps) oi = rh.open_interest.current("BTC") -funding = rh.funding.history("BTC", start="2026-09-01", end="2026-09-02") +funding = rh.funding.history("BTC", start=day_ago, end=now) # Liquidations and liquidation volume (from 2026-06-26 20:10:26 UTC) -liqs = rh.liquidations.history("BTC", start="2026-09-01", end="2026-09-08") -volume = rh.liquidations.volume("BTC", start="2026-09-01", end="2026-09-08", interval="1d") +liqs = rh.liquidations.history("BTC", start=week_ago, end=now) +volume = rh.liquidations.volume("BTC", start=week_ago, end=now, interval="1d") # Freshness, summary, price history fresh = rh.get_freshness("BTC") summary = rh.get_summary("BTC") -prices = rh.get_price_history("BTC", start="2026-09-01", end="2026-09-02", interval="1h") +prices = rh.get_price_history("BTC", start=day_ago, end=now, interval="1h") # Account positions by Lighter account index (perp markets) positions = rh.positions.get(4521) @@ -676,26 +706,26 @@ current = client.hyperliquid.funding.current("BTC") # Get funding rate history (start is required) history = client.hyperliquid.funding.history( "ETH", - start="2024-01-01", - end="2024-01-07" + start=week_ago, + end=now ) # Get funding rate history with aggregation interval history = client.hyperliquid.funding.history( "BTC", - start="2024-01-01", - end="2024-01-07", + start=week_ago, + end=now, interval="1h" ) # HIP-3 funding (case-sensitive coins) -hip3_current = client.hyperliquid.hip3.funding.current("km:US500") -hip3_history = client.hyperliquid.hip3.funding.history("km:US500", start="2026-09-01", end="2026-09-07") +hip3_current = client.hyperliquid.hip3.funding.current("xyz:TSLA") +hip3_history = client.hyperliquid.hip3.funding.history("xyz:TSLA", start=week_ago, end=now) # Async versions current = await client.hyperliquid.funding.acurrent("BTC") history = await client.hyperliquid.funding.ahistory("ETH", start=..., end=...) -hip3_current = await client.hyperliquid.hip3.funding.acurrent("km:US500") +hip3_current = await client.hyperliquid.hip3.funding.acurrent("xyz:TSLA") ``` **Unit note:** `funding_rate` is a fractional, non-annualized rate. For example, `0.0001` means `0.01%` for the funding interval. This is a breaking normalization for Lighter consumers that previously compensated for percent units; do not apply a second percent conversion. @@ -720,26 +750,26 @@ current = client.hyperliquid.open_interest.current("BTC") # Get open interest history (start is required) history = client.hyperliquid.open_interest.history( "ETH", - start="2024-01-01", - end="2024-01-07" + start=week_ago, + end=now ) # Get open interest history with aggregation interval oi = client.hyperliquid.open_interest.history( "BTC", - start="2024-01-01", - end="2024-01-07", + start=week_ago, + end=now, interval="1h" ) # HIP-3 open interest (case-sensitive coins) -hip3_current = client.hyperliquid.hip3.open_interest.current("km:US500") -hip3_history = client.hyperliquid.hip3.open_interest.history("km:US500", start="2026-09-01", end="2026-09-07") +hip3_current = client.hyperliquid.hip3.open_interest.current("xyz:TSLA") +hip3_history = client.hyperliquid.hip3.open_interest.history("xyz:TSLA", start=week_ago, end=now) # Async versions current = await client.hyperliquid.open_interest.acurrent("BTC") history = await client.hyperliquid.open_interest.ahistory("ETH", start=..., end=...) -hip3_current = await client.hyperliquid.hip3.open_interest.acurrent("km:US500") +hip3_current = await client.hyperliquid.hip3.open_interest.acurrent("xyz:TSLA") ``` #### Open Interest History Parameters @@ -755,14 +785,14 @@ hip3_current = await client.hyperliquid.hip3.open_interest.acurrent("km:US500") ### Liquidations -Get historical liquidation events. Available for Hyperliquid and HIP-3 (from 2025-12-22) and both Lighter deployments (`client.lighter.liquidations`, `client.rh_lighter.liquidations`). The projected forced-liquidation price-level endpoints refresh about every five minutes. This is a measured cadence, not an exact five-minute guarantee. +Get historical liquidation events. Available for Hyperliquid and HIP-3 (from 2025-12-22), Lighter (`client.lighter.liquidations`, from 2026-06-10) and Lighter on Robinhood Chain (`client.rh_lighter.liquidations`, from 2026-06-26 20:10:26 UTC). The projected forced-liquidation price-level endpoints refresh about every five minutes. This is a measured cadence, not an exact five-minute guarantee. ```python # Get liquidation history for a coin (Hyperliquid) liquidations = client.hyperliquid.liquidations.history( "BTC", - start="2025-06-01", - end="2025-06-02", + start=day_ago, + end=now, limit=100 ) @@ -771,8 +801,8 @@ all_liquidations = list(liquidations.data) while liquidations.has_more: liquidations = client.hyperliquid.liquidations.history( "BTC", - start="2025-06-01", - end="2025-06-02", + start=day_ago, + end=now, cursor=liquidations.next_cursor, limit=1000 ) @@ -781,24 +811,24 @@ while liquidations.has_more: # Get liquidations for a specific user (Hyperliquid core; HIP-3 has no per-user route) user_liquidations = client.hyperliquid.liquidations.by_user( "0x1234...", - start="2025-06-01", - end="2025-06-07", + start=week_ago, + end=now, symbol="BTC" # optional filter ) # HIP-3 liquidations (case-sensitive coins) hip3_liquidations = client.hyperliquid.hip3.liquidations.history( - "km:US500", - start="2026-09-01", - end="2026-09-02", + "xyz:TSLA", + start=week_ago, + end=now, limit=100 ) # HIP-3 liquidation volume hip3_volume = client.hyperliquid.hip3.liquidations.volume( - "km:US500", - start="2026-09-01", - end="2026-09-08", + "xyz:TSLA", + start=week_ago, + end=now, interval="1h" ) @@ -806,8 +836,8 @@ hip3_volume = client.hyperliquid.hip3.liquidations.volume( # account fields; see LighterLiquidation. Pass next_cursor back unchanged. lighter_liquidations = client.lighter.liquidations.history( "BTC", - start="2026-09-01", - end="2026-09-02", + start=day_ago, + end=now, limit=1000 ) for liq in lighter_liquidations.data: @@ -816,14 +846,14 @@ for liq in lighter_liquidations.data: # Robinhood Chain liquidations start at the venue launch, 2026-06-26 20:10:26 UTC. # Rows from before live capture have source == "bucket" and an empty raw_json. -rh_liquidations = client.rh_lighter.liquidations.history("BTC", start="2026-07-01", end="2026-07-02") +rh_liquidations = client.rh_lighter.liquidations.history("BTC", start=week_ago, end=now) # Async versions liquidations = await client.hyperliquid.liquidations.ahistory("BTC", start=..., end=...) lighter_liquidations = await client.lighter.liquidations.ahistory("BTC", start=..., end=...) user_liquidations = await client.hyperliquid.liquidations.aby_user("0x...", start=..., end=...) -hip3_liquidations = await client.hyperliquid.hip3.liquidations.ahistory("km:US500", start=..., end=...) -hip3_volume = await client.hyperliquid.hip3.liquidations.avolume("km:US500", start=..., end=...) +hip3_liquidations = await client.hyperliquid.hip3.liquidations.ahistory("xyz:TSLA", start=..., end=...) +hip3_volume = await client.hyperliquid.hip3.liquidations.avolume("xyz:TSLA", start=..., end=...) ``` ### Liquidation Volume @@ -834,8 +864,8 @@ Get pre-aggregated liquidation volume in time-bucketed intervals. Returns total, # Get hourly liquidation volume for the last week (Hyperliquid) volume = client.hyperliquid.liquidations.volume( "BTC", - start="2026-01-01", - end="2026-01-08", + start=week_ago, + end=now, interval="1h" # 1m, 5m, 15m, 30m, 1h, 4h, 1d ) @@ -844,9 +874,9 @@ for bucket in volume.data: # HIP-3 liquidation volume hip3_volume = client.hyperliquid.hip3.liquidations.volume( - "km:US500", - start="2026-09-01", - end="2026-09-08", + "xyz:TSLA", + start=week_ago, + end=now, interval="1d" ) @@ -859,7 +889,7 @@ volume = client.hyperliquid.get_liquidation_volume("BTC", start=..., end=..., in # Async versions volume = await client.hyperliquid.liquidations.avolume("BTC", start=..., end=..., interval="1h") -hip3_volume = await client.hyperliquid.hip3.liquidations.avolume("km:US500", start=..., end=..., interval="1d") +hip3_volume = await client.hyperliquid.hip3.liquidations.avolume("xyz:TSLA", start=..., end=..., interval="1d") ``` ### Freshness @@ -881,12 +911,12 @@ lighter_freshness = client.lighter.get_freshness("BTC") rh_freshness = client.rh_lighter.get_freshness("BTC") # HIP-3 (case-sensitive coins) -hip3_freshness = client.hyperliquid.hip3.get_freshness("km:US500") +hip3_freshness = client.hyperliquid.hip3.get_freshness("xyz:TSLA") # Async versions freshness = await client.hyperliquid.aget_freshness("BTC") lighter_freshness = await client.lighter.aget_freshness("BTC") -hip3_freshness = await client.hyperliquid.hip3.aget_freshness("km:US500") +hip3_freshness = await client.hyperliquid.hip3.aget_freshness("xyz:TSLA") ``` ### Summary @@ -912,13 +942,13 @@ lighter_summary = client.lighter.get_summary("BTC") rh_summary = client.rh_lighter.get_summary("BTC") # HIP-3 (includes mid_price; case-sensitive coins) -hip3_summary = client.hyperliquid.hip3.get_summary("km:US500") +hip3_summary = client.hyperliquid.hip3.get_summary("xyz:TSLA") print(f"Mid price: {hip3_summary.mid_price}") # Async versions summary = await client.hyperliquid.aget_summary("BTC") lighter_summary = await client.lighter.aget_summary("BTC") -hip3_summary = await client.hyperliquid.hip3.aget_summary("km:US500") +hip3_summary = await client.hyperliquid.hip3.aget_summary("xyz:TSLA") ``` ### Price History @@ -926,11 +956,11 @@ hip3_summary = await client.hyperliquid.hip3.aget_summary("km:US500") Get mark, oracle, and mid price history over time. Supports aggregation intervals. Data projected from open interest records. ```python -# Hyperliquid: available from April 2023 +# Hyperliquid: from 2023-05-20 02:50 UTC prices = client.hyperliquid.get_price_history( "BTC", - start="2026-01-01", - end="2026-01-02", + start=day_ago, + end=now, interval="1h" # 1m, 5m, 15m, 30m, 1h, 4h, 1d ) @@ -938,13 +968,13 @@ for snapshot in prices.data: print(f"{snapshot.timestamp}: mark={snapshot.mark_price}, oracle={snapshot.oracle_price}, mid={snapshot.mid_price}") # Lighter -lighter_prices = client.lighter.get_price_history("BTC", start="2026-01-01", end="2026-01-02", interval="1h") +lighter_prices = client.lighter.get_price_history("BTC", start=day_ago, end=now, interval="1h") # Lighter on Robinhood Chain -rh_prices = client.rh_lighter.get_price_history("BTC", start="2026-09-01", end="2026-09-02", interval="1h") +rh_prices = client.rh_lighter.get_price_history("BTC", start=day_ago, end=now, interval="1h") # HIP-3 (case-sensitive coins) -hip3_prices = client.hyperliquid.hip3.get_price_history("km:US500", start="2026-09-01", end="2026-09-02", interval="1d") +hip3_prices = client.hyperliquid.hip3.get_price_history("xyz:TSLA", start=week_ago, end=now, interval="1d") # Paginate for larger ranges result = client.hyperliquid.get_price_history("BTC", start=..., end=..., interval="4h", limit=1000) @@ -957,19 +987,19 @@ while result.has_more: # Async versions prices = await client.hyperliquid.aget_price_history("BTC", start=..., end=..., interval="1h") lighter_prices = await client.lighter.aget_price_history("BTC", start=..., end=..., interval="1h") -hip3_prices = await client.hyperliquid.hip3.aget_price_history("km:US500", start=..., end=..., interval="1d") +hip3_prices = await client.hyperliquid.hip3.aget_price_history("xyz:TSLA", start=..., end=..., interval="1d") ``` ### Candles (OHLCV) -Get historical OHLCV candle data aggregated from trades. Core Hyperliquid, HIP-3, and Lighter candle pages accept up to 10,000 rows; HIP-4 and Hyperliquid Spot candle pages accept up to 1,000 rows. Hyperliquid Spot candle coverage starts exactly at `2025-03-22T10:50:22Z`. Candle pagination cursors are numeric timestamp strings returned as `next_cursor`; pass each one back unchanged. +Get historical OHLCV candle data aggregated from trades. Core Hyperliquid, HIP-3, and Lighter candle pages accept up to 10,000 rows; HIP-4 and Hyperliquid Spot candle pages accept up to 1,000 rows. Candles are served from 2025-03-22 12:00 UTC on Hyperliquid core, 2025-12-22 on HIP-3, 2026-05-02 08:00 UTC on HIP-4, 2025-03-22 10:50 UTC on Hyperliquid Spot, 2025-08-01 on Lighter and 2026-06-26 20:10 UTC on Robinhood Chain. Candle pagination cursors are numeric timestamp strings returned as `next_cursor`; pass each one back unchanged. ```python # Get candle history (start is required) candles = client.hyperliquid.candles.history( "BTC", - start="2024-01-01", - end="2024-01-02", + start=day_ago, + end=now, interval="1h", # 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w limit=100 ) @@ -989,38 +1019,35 @@ while result.has_more: # Lighter candles lighter_candles = client.lighter.candles.history( "BTC", - start="2024-01-01", - end="2024-01-02", + start=day_ago, + end=now, interval="15m" ) -# Lighter on Robinhood Chain candles (from 2026-06-26, once enabled for this -# deployment; until then the call raises OxArchiveError) -rh_candles = client.rh_lighter.candles.history("BTC", start="2026-09-01", end="2026-09-02", interval="1h") +# Lighter on Robinhood Chain candles (from 2026-06-26 20:10 UTC) +rh_candles = client.rh_lighter.candles.history("BTC", start=day_ago, end=now, interval="1h") # HIP-3 candles (case-sensitive coins) hip3_candles = client.hyperliquid.hip3.candles.history( - "km:US500", - start="2026-09-01", - end="2026-09-02", + "xyz:TSLA", + start=day_ago, + end=now, interval="1h" ) # Hyperliquid Spot candles (dashed canonical symbols; max 1,000 rows) spot_candles = client.spot.candles.history( "HYPE-USDC", - start="2025-03-22T10:50:22Z", - end="2025-03-23T00:00:00Z", + start=day_ago, + end=now, interval="1h", limit=1000, ) # Async versions candles = await client.hyperliquid.candles.ahistory("BTC", start=..., end=..., interval="1h") -hip3_candles = await client.hyperliquid.hip3.candles.ahistory("km:US500", start=..., end=..., interval="1h") -spot_candles = await client.spot.candles.ahistory( - "HYPE-USDC", start="2025-03-22T10:50:22Z", end="2025-03-23T00:00:00Z", interval="1h" -) +hip3_candles = await client.hyperliquid.hip3.candles.ahistory("xyz:TSLA", start=..., end=..., interval="1h") +spot_candles = await client.spot.candles.ahistory("HYPE-USDC", start=day_ago, end=now, interval="1h") ``` #### Available Intervals @@ -1045,8 +1072,8 @@ Taker buy and sell notional per bucket, their difference, and a running total, f # `limit` buckets of the 24 hours before `end`. page = client.hyperliquid.cvd.history( "BTC", - start="2026-09-01T00:00:00Z", - end="2026-09-02T00:00:00Z", + start=day_ago, + end=now, interval="1m", # 1m, 5m, 15m, 30m, 1h (default), 4h, 1d, 1w limit=500, # buckets per page (default 500, max 10000) ) @@ -1059,25 +1086,23 @@ for bucket in page.data: while page.has_more: page = client.hyperliquid.cvd.history( "BTC", - start="2026-09-01T00:00:00Z", - end="2026-09-02T00:00:00Z", + start=day_ago, + end=now, interval="1m", cursor=page.next_cursor, ) # Or let the iterator follow the cursor running = 0.0 -for bucket in client.hyperliquid.cvd.iterate( - "BTC", start="2026-09-01T00:00:00Z", end="2026-09-02T00:00:00Z", interval="1m" -): +for bucket in client.hyperliquid.cvd.iterate("BTC", start=day_ago, end=now, interval="1m"): running += bucket.delta # HIP-3 (case-sensitive coins) -hip3 = client.hyperliquid.hip3.cvd.history("km:US500", start="2026-09-01T00:00:00Z", interval="1h") +hip3 = client.hyperliquid.hip3.cvd.history("xyz:TSLA", start=day_ago, interval="1h") # Async versions -page = await client.hyperliquid.cvd.ahistory("BTC", start="2026-09-01T00:00:00Z") -async for bucket in client.hyperliquid.hip3.cvd.aiterate("km:US500", start="2026-09-01T00:00:00Z"): +page = await client.hyperliquid.cvd.ahistory("BTC", start=day_ago) +async for bucket in client.hyperliquid.hip3.cvd.aiterate("xyz:TSLA", start=day_ago): ... ``` @@ -1085,7 +1110,7 @@ Buckets are labelled by their open time (`timestamp`, a UTC `datetime`; `timesta ### L4 Orderbook (Order-Level) -Get L4 order-level orderbook data with user attribution. Available for Hyperliquid core, HIP-3, HIP-4 (`client.hyperliquid.hip4.l4_orderbook`) and spot (`client.spot.l4_orderbook`). +Get L4 order-level orderbook data with user attribution. Available for Hyperliquid core and HIP-3 (from 2026-03-11 01:03 UTC), HIP-4 (`client.hyperliquid.hip4.l4_orderbook`, from 2026-05-02 07:47 UTC) and spot (`client.spot.l4_orderbook`, from 2026-05-05 22:57 UTC; PURR-USDC from 2026-03-11 01:03 UTC). In a snapshot from `get()`, each resting order's `timestamp` is the time it joined the queue, as an RFC 3339 string, with `timestamp_ms` the same instant in Unix milliseconds. When the queue time is unknown, `timestamp` is `None` and `timestamp_ms` is `0`. @@ -1095,34 +1120,34 @@ snapshot = client.hyperliquid.l4_orderbook.get("BTC") snapshot = client.hyperliquid.l4_orderbook.get("BTC", depth=10) # Get L4 orderbook at a specific timestamp -historical = client.hyperliquid.l4_orderbook.get("BTC", timestamp=1704067200000) +historical = client.hyperliquid.l4_orderbook.get("BTC", timestamp=hour_ago) # Get L4 orderbook diffs (order-level changes) diffs = client.hyperliquid.l4_orderbook.diffs( "BTC", - start="2024-01-01", - end="2024-01-02", + start=hour_ago, + end=now, limit=1000 ) # Get L4 orderbook history (full checkpoints over time) history = client.hyperliquid.l4_orderbook.history( "BTC", - start="2024-01-01", - end="2024-01-02", - limit=100 + start=hour_ago, + end=now, + limit=10 ) # HIP-3 L4 orderbook (case-sensitive coins) -hip3_snapshot = client.hyperliquid.hip3.l4_orderbook.get("km:US500") -hip3_diffs = client.hyperliquid.hip3.l4_orderbook.diffs("km:US500", start=..., end=...) -hip3_history = client.hyperliquid.hip3.l4_orderbook.history("km:US500", start=..., end=...) +hip3_snapshot = client.hyperliquid.hip3.l4_orderbook.get("xyz:TSLA") +hip3_diffs = client.hyperliquid.hip3.l4_orderbook.diffs("xyz:TSLA", start=..., end=...) +hip3_history = client.hyperliquid.hip3.l4_orderbook.history("xyz:TSLA", start=..., end=...) # Async versions snapshot = await client.hyperliquid.l4_orderbook.aget("BTC") diffs = await client.hyperliquid.l4_orderbook.adiffs("BTC", start=..., end=...) history = await client.hyperliquid.l4_orderbook.ahistory("BTC", start=..., end=...) -hip3_snapshot = await client.hyperliquid.hip3.l4_orderbook.aget("km:US500") +hip3_snapshot = await client.hyperliquid.hip3.l4_orderbook.aget("xyz:TSLA") ``` **Methods:** @@ -1137,7 +1162,7 @@ History replays over the WebSocket too, on every product; see [L4 and Full-Depth ### L3 Orderbook (Lighter Only) -Get Lighter L3 individual order-level snapshots from March 5, 2026, capped at 250 orders per side. +Get Lighter L3 individual order-level snapshots from 2026-03-05 03:33 UTC, capped at 250 orders per side. ```python # Get current L3 orderbook snapshot @@ -1145,7 +1170,7 @@ snapshot = client.lighter.l3_orderbook.get("BTC") snapshot = client.lighter.l3_orderbook.get("BTC", depth=20) # Get L3 orderbook at a specific timestamp -historical = client.lighter.l3_orderbook.get("BTC", timestamp=1704067200000) +historical = client.lighter.l3_orderbook.get("BTC", timestamp=hour_ago) # Only one account's resting orders mine = client.lighter.l3_orderbook.get("BTC", account=281474976710654) @@ -1153,8 +1178,8 @@ mine = client.lighter.l3_orderbook.get("BTC", account=281474976710654) # Get L3 orderbook history history = client.lighter.l3_orderbook.history( "BTC", - start="2026-03-05", - end="2026-03-06", + start=hour_ago, + end=now, account=281474976710654, # optional: one account's orders limit=100 ) @@ -1163,8 +1188,8 @@ history = client.lighter.l3_orderbook.history( while history.has_more: history = client.lighter.l3_orderbook.history( "BTC", - start="2026-03-05", - end="2026-03-06", + start=hour_ago, + end=now, cursor=history.next_cursor, limit=100 ) @@ -1179,26 +1204,26 @@ history = await client.lighter.l3_orderbook.ahistory("BTC", start=..., end=...) | Method | Description | |--------|-------------| | `get(symbol, *, timestamp, depth, account)` | Get an L3 snapshot, up to 250 orders per side | -| `history(symbol, *, start, end, cursor, limit, account)` | Get L3 history from March 5, 2026, up to 250 orders per side (no `depth` or `granularity`) | +| `history(symbol, *, start, end, cursor, limit, account)` | Get L3 history from 2026-03-05 03:33 UTC, up to 250 orders per side (no `depth` or `granularity`) | ### L2 Orderbook (Full-Depth) -Get L2 full-depth orderbook derived from L4 data. Available for Hyperliquid and HIP-3. +Get L2 full-depth orderbook derived from L4 data. Available for Hyperliquid and HIP-3, from 2026-03-11 01:03 UTC. ```python # L2 full-depth orderbook l2 = client.hyperliquid.l2_orderbook.get("BTC") -l2_historical = client.hyperliquid.l2_orderbook.get("BTC", timestamp=1711900800000) +l2_historical = client.hyperliquid.l2_orderbook.get("BTC", timestamp=hour_ago) # L2 orderbook history, optionally limited to the top levels of each snapshot -l2_history = client.hyperliquid.l2_orderbook.history("BTC", start=start, end=end) -l2_top = client.hyperliquid.l2_orderbook.history("BTC", start=start, end=end, depth=50) +l2_history = client.hyperliquid.l2_orderbook.history("BTC", start=hour_ago, end=now) +l2_top = client.hyperliquid.l2_orderbook.history("BTC", start=hour_ago, end=now, depth=50) # L2 tick-level diffs -l2_diffs = client.hyperliquid.l2_orderbook.diffs("BTC", start=start, end=end) +l2_diffs = client.hyperliquid.l2_orderbook.diffs("BTC", start=hour_ago, end=now) # HIP-3 L2 orderbook -hip3_l2 = client.hyperliquid.hip3.l2_orderbook.get("km:US500") +hip3_l2 = client.hyperliquid.hip3.l2_orderbook.get("xyz:TSLA") # Async versions l2 = await client.hyperliquid.l2_orderbook.aget("BTC") @@ -1222,31 +1247,29 @@ Get L4 order history, order flow aggregation, and TP/SL data. Available for Hype # Get order history result = client.hyperliquid.orders.history( "BTC", - start="2024-01-01", - end="2024-01-02", + start=hour_ago, + end=now, limit=1000 ) # Filter by user, status, or order type result = client.hyperliquid.orders.history( "BTC", - start="2024-01-01", - end="2024-01-02", + start=hour_ago, + end=now, user="0x1234...", status="filled", order_type="limit" ) # Only orders whose trigger fired (triggered=False excludes them) -fired = client.hyperliquid.orders.history( - "BTC", start="2026-09-01", end="2026-09-02", triggered=True -) +fired = client.hyperliquid.orders.history("BTC", start=day_ago, end=now, triggered=True) # Get order flow aggregation: one page of time buckets flow = client.hyperliquid.orders.flow( "BTC", - start="2026-07-13T00:00:00Z", - end="2026-07-14T00:00:00Z", + start=day_ago, + end=now, interval="1m", # 1m (default), 5m, 15m, 1h ) buckets = list(flow.data) @@ -1255,8 +1278,8 @@ buckets = list(flow.data) while flow.has_more: flow = client.hyperliquid.orders.flow( "BTC", - start="2026-07-13T00:00:00Z", - end="2026-07-14T00:00:00Z", + start=day_ago, + end=now, interval="1m", cursor=flow.next_cursor, ) @@ -1265,22 +1288,22 @@ while flow.has_more: # Get TP/SL history tpsl = client.hyperliquid.orders.tpsl( "BTC", - start="2024-01-01", - end="2024-01-02", + start=day_ago, + end=now, user="0x1234...", # optional triggered=True # optional filter ) # HIP-3 orders (case-sensitive coins) -hip3_orders = client.hyperliquid.hip3.orders.history("km:US500", start=..., end=...) -hip3_flow = client.hyperliquid.hip3.orders.flow("km:US500", start=..., end=..., interval="1h") -hip3_tpsl = client.hyperliquid.hip3.orders.tpsl("km:US500", start=..., end=...) +hip3_orders = client.hyperliquid.hip3.orders.history("xyz:TSLA", start=..., end=...) +hip3_flow = client.hyperliquid.hip3.orders.flow("xyz:TSLA", start=..., end=..., interval="1h") +hip3_tpsl = client.hyperliquid.hip3.orders.tpsl("xyz:TSLA", start=..., end=...) # Async versions result = await client.hyperliquid.orders.ahistory("BTC", start=..., end=...) flow = await client.hyperliquid.orders.aflow("BTC", start=..., end=...) tpsl = await client.hyperliquid.orders.atpsl("BTC", start=..., end=...) -hip3_orders = await client.hyperliquid.hip3.orders.ahistory("km:US500", start=..., end=...) +hip3_orders = await client.hyperliquid.hip3.orders.ahistory("xyz:TSLA", start=..., end=...) ``` **Methods:** @@ -1308,30 +1331,30 @@ Lighter positions cover perp markets. On Lighter mainnet, `client.lighter.accoun ```python # Now: the latest live snapshot -now = client.hyperliquid.positions.get("0xabc...") -for p in now.data.positions: +current = client.hyperliquid.positions.get("0xabc...") +for p in current.data.positions: print(p.symbol, p.side, p.size, p.entry_price, p.unrealized_pnl, p.quality) -print(now.data.account) # AccountSummary on the first page of a snapshot, or None -print(now.meta.as_of, now.meta.quality, now.meta.stale) +print(current.data.account) # AccountSummary on the first page of a snapshot, or None +print(current.meta.as_of, current.meta.quality, current.meta.stale) # As of an instant: the state after every event before it. An exact UTC hour # serves the hourly snapshot; any other instant is reconstructed from the # change log (meta.source == "reconstructed"; mark fields are at the instant). -then = client.hyperliquid.positions.get("0xabc...", timestamp="2026-09-01T12:34:56Z") +then = client.hyperliquid.positions.get("0xabc...", timestamp=day_ago) print(then.meta.source, then.meta.built_through, then.meta.clamped_to) # Hourly history and the change log in [start, end), following cursors -for row in client.hyperliquid.positions.iterate_history("0xabc...", start="2026-09-01", end="2026-09-02"): +for row in client.hyperliquid.positions.iterate_history("0xabc...", start=day_ago, end=now): print(row.snapshot_ts, row.symbol, row.size) -for leg in client.hyperliquid.positions.iterate_changes("0xabc...", start="2026-09-01", end="2026-09-02", symbol="BTC"): +for leg in client.hyperliquid.positions.iterate_changes("0xabc...", start=day_ago, end=now, symbol="BTC"): print(leg.timestamp, leg.event_type, leg.start_position, "->", leg.end_position, leg.closed_pnl) # Account summaries: the clearinghouse summary on Hyperliquid and HIP-3, # position aggregates on Lighter and Robinhood Chain summary = client.hyperliquid.positions.account("0xabc...") -hourly = client.hyperliquid.positions.account_history("0xabc...", start="2026-09-01", end="2026-09-02") +hourly = client.hyperliquid.positions.account_history("0xabc...", start=day_ago, end=now) lighter_summary = client.lighter.positions.account(4521) -lighter_hourly = client.rh_lighter.positions.account_history(4521, start="2026-09-01", end="2026-09-02") +lighter_hourly = client.rh_lighter.positions.account_history(4521, start=day_ago, end=now) # HIP-3: dex narrows a wallet to one dex; symbols are case-sensitive hip3 = client.hyperliquid.hip3.positions.get("0xabc...", dex="xyz") @@ -1339,14 +1362,15 @@ hip3 = client.hyperliquid.hip3.positions.get("0xabc...", dex="xyz") # Every open position in a market, largest value first; totals on the first page page = client.hyperliquid.positions.market("BTC", side="long", min_value=1_000_000) print(page.meta.totals.long_count, page.meta.totals.top10_value_share) -at_hour = client.hyperliquid.positions.market("BTC", hour="2026-09-25T12:00:00Z") +last_hour = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) # an exact UTC hour +at_hour = client.hyperliquid.positions.market("BTC", hour=last_hour) # Long/short aggregates: now, or one per hour over [start, end) now_summary = client.hyperliquid.positions.market_summary("BTC") -series = client.hyperliquid.positions.market_summary("BTC", start="2026-09-20", end="2026-09-21") +series = client.hyperliquid.positions.market_summary("BTC", start=day_ago, end=now) # Bulk: every open position across markets at one hourly snapshot -for row in client.hyperliquid.positions.iterate_all("2026-09-25T12:00:00Z"): +for row in client.hyperliquid.positions.iterate_all(last_hour): print(row.user_address, row.symbol, row.size) # Lighter (mainnet and Robinhood Chain): integer account indices @@ -1356,14 +1380,14 @@ for account in owned.data.accounts: # Without a symbol filter, the first page carries position aggregates # (total_position_value, total_unrealized_pnl, long_value, short_value, n_positions). print(lighter_now.data.account) -changes = client.lighter.positions.changes(4521, start="2026-09-01", end="2026-09-02") +changes = client.lighter.positions.changes(4521, start=day_ago, end=now) print(changes.meta.finalized_through) # legs before it are final market = client.lighter.positions.market("BTC", include_system=True) # include system accounts rh_now = client.rh_lighter.positions.get(4521) # Async versions of every method (aget, ahistory, achanges, amarket, ...) # and of every iterator (aiterate_history, aiterate_changes, ...) -now = await client.hyperliquid.positions.aget("0xabc...") +current = await client.hyperliquid.positions.aget("0xabc...") async for leg in client.lighter.positions.aiterate_changes(4521, start=..., end=...): ... ``` @@ -1406,7 +1430,7 @@ page = client.hyperliquid.wallets.classify( order="desc", limit=100, # 1 to 1000 (default 100) offset=0, # at most 100000 - date="2026-09-28", # daily snapshot; default yesterday (UTC) + date=day_ago.date(), # daily snapshot; default yesterday (UTC) ) print(f"{page.total} wallets match on {page.date}") for wallet in page.wallets: @@ -1492,8 +1516,8 @@ for exchange, metrics in latency.exchanges.items(): for venue in client.data_quality.positions_freshness(): print(venue.venue, venue.product, venue.live_age_seconds, venue.stale, venue.finalized_through) -# Get SLA compliance metrics for a specific month -sla = client.data_quality.sla(year=2026, month=1) +# Get SLA compliance metrics for a specific month (both default to the current one) +sla = client.data_quality.sla(year=now.year, month=now.month) print(f"Period: {sla.period}") print(f"Uptime: {sla.actual.uptime}% ({sla.actual.uptime_status})") print(f"API P99: {sla.actual.api_latency_p99_ms}ms ({sla.actual.latency_status})") @@ -1511,7 +1535,7 @@ coverage = await client.data_quality.acoverage() | `coverage()` | Data coverage summary for venue APIs | | `status_coverage()` | The same summary from the public `/v1/status/coverage` route | | `exchange_coverage(exchange)` | Coverage details for a venue scope (`hyperliquid`, `hip3`, `hip4`, `spot`, `lighter`, `rh-lighter`) | -| `symbol_coverage(exchange, symbol, *, from_time, to_time)` | Coverage with gap detection, cadence, and historical coverage. The symbol is sent as given, URL-encoded (`km:US500`, `HYPE-USDC`, `#0`) | +| `symbol_coverage(exchange, symbol, *, from_time, to_time)` | Coverage with gap detection, cadence, and historical coverage. The symbol is sent as given, URL-encoded (`xyz:TSLA`, `HYPE-USDC`, `#0`) | | `list_incidents(...)` | List incidents with filtering and pagination | | `get_incident(incident_id)` | Get specific incident details | | `latency()` | Current latency metrics (WebSocket, REST, data freshness) | @@ -2034,7 +2058,7 @@ def on_batch(channel, coin, levels): ws.on_l4_snapshot(on_snapshot) ws.on_l4_batch(on_batch) ws.subscribe_orderbook_full("BTC") -ws.subscribe_hip3_orderbook_full("km:US500") # case-sensitive +ws.subscribe_hip3_orderbook_full("xyz:TSLA") # case-sensitive ``` Each level is `{"px", "sz", "n"}` with numeric values; a changed level also carries `side` (`"B"` or `"A"`) and `bn`, the block it was applied in. The snapshot's `data` also holds `bid_count`, `ask_count`, `total_bid_size`, `total_ask_size`, `mid_price`, `spread`, `spread_bps` and `is_crossed`, and `last_block_number` on the message is the block the snapshot reflects. @@ -2185,7 +2209,7 @@ async def main(): # Control playback await ws.replay_pause() await ws.replay_resume() - await ws.replay_seek(1704067200000) # Jump to timestamp + await ws.replay_seek(int(time.time() * 1000) - 43_200_000) # Jump to 12 hours ago await ws.replay_stop() asyncio.run(main()) @@ -2329,7 +2353,7 @@ Bulk replays open with an `l4_snapshot` and continue with ordered `l4_batch` mes > **Note:** Stored full-depth history is also served over REST by `l2_orderbook.history()` and `l2_orderbook.diffs()`. -> **Note:** HIP-3 coins are case-sensitive (e.g., `km:US500`, `xyz:XYZ100`). Do not uppercase them. +> **Note:** HIP-3 coins are case-sensitive (e.g., `xyz:TSLA`, `xyz:XYZ100`). Do not uppercase them. #### HIP-4 Outcome Market Channels @@ -2401,7 +2425,7 @@ Live Lighter subscriptions are served at `wss://api.0xarchive.io/ws`. Replay mes |---------|-------------|---------------|-------------------|-------------------| | `rh_lighter_orderbook` | L2 order book (live: full top-20 book per side, 1 per second by default, `interval_ms` 100 to 5000) | Yes | Yes | Yes (from 2026-08-22) | | `rh_lighter_trades` | Trade/fill updates (live: two legs per trade) | Yes | Yes | Yes (from 2026-06-26) | -| `rh_lighter_candles` | OHLCV candle data (once candles are enabled for this deployment) | Yes | No | Yes | +| `rh_lighter_candles` | OHLCV candle data | Yes | No | Yes (from 2026-06-26) | | `rh_lighter_open_interest` | Open interest (live: market context, same message as `rh_lighter_funding`) | Yes | Yes | Yes (from 2026-08-22) | | `rh_lighter_funding` | Funding rates (live: market context, same message as `rh_lighter_open_interest`) | Yes | Yes | Yes (from 2026-08-22) | @@ -2434,7 +2458,7 @@ await ws.replay( ```python # Replay HIP-3 orderbook at 50x speed await ws.replay( - "hip3_orderbook", "km:US500", + "hip3_orderbook", "xyz:TSLA", start=int(time.time() * 1000) - 3600000, end=int(time.time() * 1000), speed=50, @@ -2442,7 +2466,7 @@ await ws.replay( # HIP-3 candles await ws.replay( - "hip3_candles", "km:US500", + "hip3_candles", "xyz:TSLA", start=int(time.time() * 1000) - 86400000, end=int(time.time() * 1000), speed=100, @@ -2473,7 +2497,7 @@ await ws.replay( # HIP-3 funding replay await ws.replay( - "hip3_funding", "km:US500", + "hip3_funding", "xyz:TSLA", start=int(time.time() * 1000) - 86400000, end=int(time.time() * 1000), speed=100, @@ -2566,7 +2590,7 @@ await ws.multi_replay( # HIP-3: orderbook + trades + OI + funding await ws.multi_replay( ["hip3_orderbook", "hip3_trades", "hip3_open_interest", "hip3_funding"], - "km:US500", + "xyz:TSLA", start=start_ms, end=end_ms, speed=10, @@ -2575,22 +2599,25 @@ await ws.multi_replay( ## Timestamp Formats -The SDK accepts timestamps in multiple formats and sends them as Unix milliseconds. A time without a time zone is UTC: a naive `datetime`, an ISO string without an offset (`"2024-01-01T12:00:00"`), and a date alone (`"2024-01-01"`, midnight UTC) mean the same instant on every machine, whatever its local time zone. For the current time, use an aware datetime such as `datetime.now(timezone.utc)`; a naive `datetime.now()` is local wall-clock time and would be read as UTC. +The SDK accepts timestamps in multiple formats and sends them as Unix milliseconds. A time without a time zone is UTC: a naive `datetime`, an ISO string without an offset (`"2026-10-01T12:00:00"`), and a date alone (`"2026-10-01"`, midnight UTC) mean the same instant on every machine, whatever its local time zone. For the current time, use an aware datetime such as `datetime.now(timezone.utc)`; a naive `datetime.now()` is local wall-clock time and would be read as UTC. ```python -from datetime import datetime, timezone +import time +from datetime import datetime, timedelta, timezone -# Unix milliseconds (int) -client.hyperliquid.orderbook.get("BTC", timestamp=1704067200000) +# Unix milliseconds (int): one hour ago +client.hyperliquid.orderbook.get("BTC", timestamp=int(time.time() * 1000) - 3_600_000) # ISO string: a date alone is midnight UTC; a time without an offset is UTC -client.hyperliquid.orderbook.history("BTC", start="2024-01-01", end="2024-01-01T12:00:00") +day = (datetime.now(timezone.utc) - timedelta(days=2)).date().isoformat() # for example "2026-10-01" +client.hyperliquid.orderbook.history("BTC", start=day, end=f"{day}T01:00:00") # datetime object: naive datetimes are UTC, aware ones keep their offset +start = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=2) # naive, read as UTC client.hyperliquid.orderbook.history( "BTC", - start=datetime(2024, 1, 1), - end=datetime(2024, 1, 2, tzinfo=timezone.utc) + start=start, + end=datetime.now(timezone.utc), # aware ) ``` @@ -2599,12 +2626,15 @@ client.hyperliquid.orderbook.history( Every error the API returns raises `OxArchiveError`. Branch on `error_code`, a stable machine-readable code, rather than on the message: ```python +from datetime import datetime, timedelta, timezone + from oxarchive import Client, OxArchiveError client = Client(api_key="0xa_your_api_key") +now = datetime.now(timezone.utc) try: - trades = client.hyperliquid.trades.history("BTC", start="2026-09-01", end="2026-09-02", side="both") + trades = client.hyperliquid.trades.history("BTC", start=now - timedelta(hours=1), end=now, side="both") except OxArchiveError as e: print(e.error_code) # "invalid_parameter" print(e.status) # 400 (also e.code) @@ -2692,8 +2722,8 @@ capabilities: list[Capability] = client.capabilities() recent: list[Trade] = client.lighter.trades.recent("BTC") # Account positions: typed rows plus the response meta -now: CursorResponse[WalletPositions] = client.hyperliquid.positions.get("0xabc...") -meta: ResponseMeta = now.meta +current: CursorResponse[WalletPositions] = client.hyperliquid.positions.get("0xabc...") +meta: ResponseMeta = current.meta legs: CursorResponse[list[PositionChange]] = client.rh_lighter.positions.changes(4521, start=..., end=...) # Cumulative volume delta, wallet classification and webhooks diff --git a/oxarchive/__init__.py b/oxarchive/__init__.py index 1ce97c1..38f25be 100644 --- a/oxarchive/__init__.py +++ b/oxarchive/__init__.py @@ -3,12 +3,11 @@ Historical Market Data API for two venues, Hyperliquid and Lighter. Lighter has two deployments: mainnet and Robinhood Chain. -- Hyperliquid (perpetuals data from April 2023) +- Hyperliquid core perpetuals (order book from 2023-04-15) - Hyperliquid HIP-3 builder perps under the Hyperliquid namespace at /v1/hyperliquid/hip3 and client.hyperliquid.hip3 - Hyperliquid HIP-4 outcome markets under the Hyperliquid namespace at /v1/hyperliquid/hip4 and client.hyperliquid.hip4 - Hyperliquid spot pairs under /v1/hyperliquid/spot and client.spot (trades and candles - from 2025-03-22, candle floor 2025-03-22T10:50:22Z; L4 and order history from - 2026-03-10; order book from 2026-05-05) + from 2025-03-22; order book and L4 from 2026-05-05) - Lighter mainnet at /v1/lighter and client.lighter - Lighter on Robinhood Chain at /v1/rh-lighter and client.rh_lighter (USDG-quoted; trades and liquidations from 2026-06-26; order book, open interest and @@ -16,7 +15,7 @@ - Account positions on client.hyperliquid.positions, client.hyperliquid.hip3.positions, client.lighter.positions and client.rh_lighter.positions - Webhooks on client.webhooks, with WebhookVerifier to verify deliveries -- What each venue serves, per datatype, on client.capabilities() +- What each venue serves, per datatype, and from when, on client.capabilities() Every request selects API version 2026-10-01 (the ``0xArchive-Version`` header, and ``version`` on WebSocket connections). Errors raise @@ -38,13 +37,17 @@ >>> rh_orderbook = client.rh_lighter.orderbook.get("AAPL-USDG") >>> >>> # Hyperliquid HIP-3 data - >>> hip3_orderbook = client.hyperliquid.hip3.orderbook.get("km:US500") + >>> hip3_orderbook = client.hyperliquid.hip3.orderbook.get("xyz:TSLA") >>> >>> # Hyperliquid spot data (dashed canonical symbols) >>> spot_orderbook = client.spot.orderbook.get("HYPE-USDC") >>> - >>> # Get historical snapshots - >>> history = client.hyperliquid.orderbook.history("ETH", start="2024-01-01", end="2024-01-02") + >>> # Order book history for the last hour + >>> from datetime import datetime, timedelta, timezone + >>> now = datetime.now(timezone.utc) + >>> history = client.hyperliquid.orderbook.history( + ... "ETH", start=now - timedelta(hours=1), end=now + ... ) """ from .client import Client diff --git a/oxarchive/client.py b/oxarchive/client.py index c9a18f4..5b7a13e 100644 --- a/oxarchive/client.py +++ b/oxarchive/client.py @@ -32,12 +32,11 @@ class Client: Two venues: Hyperliquid and Lighter. Lighter has two deployments: mainnet and Robinhood Chain. - - `client.hyperliquid` - Hyperliquid perpetuals (April 2023+) + - `client.hyperliquid` - Hyperliquid perpetuals (order book from 2023-04-15) - `client.hyperliquid.hip3` - Hyperliquid HIP-3 builder perps under the Hyperliquid namespace - `client.hyperliquid.hip4` - Hyperliquid HIP-4 outcome markets under the Hyperliquid namespace - `client.spot` - Hyperliquid spot pairs (trades and candles from 2025-03-22, - L4 and order history from 2026-03-10, order book from 2026-05-05; no - funding, OI, or liquidations) + order book and L4 from 2026-05-05; no funding, OI, or liquidations) - `client.lighter` - Lighter mainnet - `client.rh_lighter` - Lighter on Robinhood Chain (USDG-quoted perps and spot; trades and liquidations from 2026-06-26; order book, OI and @@ -76,10 +75,14 @@ class Client: >>> positions = client.hyperliquid.positions.get("0x...") >>> >>> # Hyperliquid HIP-3 data - >>> hip3_orderbook = client.hyperliquid.hip3.orderbook.get("km:US500") + >>> hip3_orderbook = client.hyperliquid.hip3.orderbook.get("xyz:TSLA") >>> - >>> # Get historical snapshots - >>> history = client.hyperliquid.orderbook.history("ETH", start="2024-01-01", end="2024-01-02") + >>> # Order book history for the last hour + >>> from datetime import datetime, timedelta, timezone + >>> now = datetime.now(timezone.utc) + >>> history = client.hyperliquid.orderbook.history( + ... "ETH", start=now - timedelta(hours=1), end=now + ... ) >>> >>> # List all instruments >>> instruments = client.hyperliquid.instruments.list() @@ -133,21 +136,24 @@ def __init__( # Exchange-specific clients (recommended) self.hyperliquid = HyperliquidClient(self._http) - """Hyperliquid exchange data (orderbook, trades, funding, OI from April 2023)""" + """Hyperliquid exchange data. Order book from 2023-04-15, trades from + 2023-04-15 03:31 UTC, funding and open interest from 2023-05-20 02:50 UTC; + ``client.capabilities()`` lists every dataset's first instant.""" self.spot = SpotClient(self._http) - """Hyperliquid spot pairs. Trades and candles from 2025-03-22; L4 and order - history from 2026-03-10; order book from 2026-05-05. No funding, OI, or - liquidations.""" + """Hyperliquid spot pairs. Trades and candles from 2025-03-22; order book + and L4 from 2026-05-05 (PURR-USDC L4 from 2026-03-11). No funding, OI, + or liquidations.""" self.lighter = LighterClient(self._http) - """Lighter mainnet data. Trade history begins January 17, 2025; - exact starts vary by market and data type.""" + """Lighter mainnet data. Trades from 2025-01-17; exact starts vary by + market and data type (``client.symbols.list()``).""" self.rh_lighter = RhLighterClient(self._http) """Lighter on Robinhood Chain data (``/v1/rh-lighter``). Trades and - liquidations from 2026-06-26 20:10:26 UTC; order book, open interest - and funding from 2026-08-22 18:43 UTC. Same resources as ``client.lighter`` + liquidations from 2026-06-26 20:10:26 UTC; candles from 2026-06-26 + 20:10 UTC; order book, open interest and funding from 2026-08-22 + 18:43 UTC. Same resources as ``client.lighter`` except the L3 order book and the L1 account resolver.""" # Data quality monitoring (cross-exchange) diff --git a/oxarchive/exchanges.py b/oxarchive/exchanges.py index ffc01ee..475364e 100644 --- a/oxarchive/exchanges.py +++ b/oxarchive/exchanges.py @@ -91,7 +91,7 @@ def __init__(self, http: HttpClient): base_path = "/v1/hyperliquid" self.orderbook = OrderBookResource(http, base_path) - """Order book data (L2 snapshots from April 2023)""" + """Order book data (L2 snapshots from 2023-04-15)""" # Hyperliquid uses hourly S3 backfill (not live ingestion), so the # backend does not expose ``/v1/hyperliquid/trades/{symbol}/recent``. @@ -143,7 +143,7 @@ def __init__(self, http: HttpClient): """HIP-3 builder-deployed perpetuals (trades from 2025-10-13)""" self.hip4 = Hip4Client(http) - """HIP-4 outcome markets (May 2026+)""" + """HIP-4 outcome markets (from 2026-05-02)""" _convert_timestamp = staticmethod(to_unix_ms) @@ -360,7 +360,8 @@ class Hip3Client: def __init__(self, http: HttpClient): self._http = http base_path = "/v1/hyperliquid/hip3" - coin_transform = lambda c: c # noqa: E731 — HIP-3 coins are case-sensitive (e.g. "xyz:XYZ100") + # HIP-3 coins are case-sensitive (e.g. "xyz:XYZ100"), so they pass through unchanged. + coin_transform = lambda c: c # noqa: E731 self.instruments = Hip3InstrumentsResource(http, base_path, coin_transform=coin_transform) """HIP-3 instruments with latest market data""" @@ -418,7 +419,7 @@ def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness: Get data freshness for a symbol across all data types. Args: - symbol: Symbol (case-sensitive, e.g. 'km:US500') + symbol: Symbol (case-sensitive, e.g. 'xyz:TSLA') Returns: CoinFreshness with per-data-type lag information @@ -438,7 +439,7 @@ def get_summary(self, symbol: str, **kwargs) -> CoinSummary: Get combined market summary for a symbol. Args: - symbol: Symbol (case-sensitive, e.g. 'km:US500') + symbol: Symbol (case-sensitive, e.g. 'xyz:TSLA') Returns: CoinSummary with all market metrics @@ -468,7 +469,7 @@ def get_price_history( Get mark/oracle price history for a symbol. Args: - symbol: Symbol (case-sensitive, e.g. 'km:US500') + symbol: Symbol (case-sensitive, e.g. 'xyz:TSLA') start: Start timestamp (ISO or Unix ms) end: End timestamp (ISO or Unix ms) interval: Aggregation interval (e.g. '1h', '4h', '1d') @@ -536,7 +537,7 @@ def _hip4_encode(symbol: Union[str, int]) -> str: requirement, and because the namespace is already ``/v1/hyperliquid/hip4`` so the ``#`` is redundant. - Note: WebSocket subscribes still use the raw ``#N`` form in the JSON body — + Note: WebSocket subscribes still use the raw ``#N`` form in the JSON body; only the REST path is normalized here. """ symbol_text = str(symbol) @@ -566,8 +567,10 @@ class Hip4Client: bare form is the recommended primary in examples. WebSocket ``subscribe`` payloads still use the raw ``#N`` form (passed through as-is in JSON). - Note: HIP-4 has candles and outcome-side open interest from May 2, 2026. - Raw OI updates arrive at roughly 10-second cadence. HIP-4 has no funding, + Note: HIP-4 serves L4 and order history from 2026-05-02 07:47 UTC, trades + and candles from 2026-05-02 08:00 UTC, and the order book and outcome-side + open interest from 2026-05-02 16:51 UTC. Raw OI updates arrive at roughly + 10-second cadence. HIP-4 has no funding, no liquidations, and no oracle feed for outcomes. Example: @@ -1117,9 +1120,8 @@ class RhLighterClient(_LighterDeploymentClient): Coverage: trades and liquidations from the venue launch, 2026-06-26 20:10:26 UTC; order book, open interest and funding from - 2026-08-22 18:43 UTC; account positions from 2026-06-26. Candles are - served once enabled for this deployment (from 2026-06-26); until then the - candles route answers with an error. Trades follow the same finalization + 2026-08-22 18:43 UTC; candles from 2026-06-26 20:10 UTC; account positions + from 2026-06-26. Trades follow the same finalization contract as mainnet Lighter: ``trades.list()`` is canonical up to ``meta.finalized_through`` and ``trades.recent()`` is the preliminary tier. @@ -1150,10 +1152,11 @@ class SpotClient: ``HYPE-USDC``, ``PURR-USDC``); the server resolves dashed to wire format (``PURR/USDC`` or ``@107``) internally. - Spot has no funding, no open interest, or liquidations. Candle history is - served from ``2025-03-22T10:50:22Z``; L4 and order history from - 2026-03-10; the order book from 2026-05-05. Candle pages are capped at - 1,000 rows. + Spot has no funding, no open interest, or liquidations. Candles are served + from 2025-03-22 10:50 UTC and trades from 2025-03-22 10:50:22 UTC; the + order book from 2026-05-05 19:56 UTC; L4 from 2026-05-05 22:57 UTC + (PURR-USDC from 2026-03-11 01:03 UTC), with REST order history reaching + back further. Candle pages are capped at 1,000 rows. Example: >>> client = oxarchive.Client(api_key="...") @@ -1176,15 +1179,17 @@ def __init__(self, http: HttpClient): """Trade/fill history (from 2025-03-22), including ``recent()``.""" self.candles = SpotCandlesResource(http, base_path) - """OHLCV candle history (from 2025-03-22T10:50:22Z; max 1,000 rows).""" + """OHLCV candle history (from 2025-03-22 10:50 UTC; max 1,000 rows).""" self.orders = SpotOrdersResource(http, base_path) - """L4 order lifecycle history (from 2026-03-10). Spot serves - ``history()`` only: no flow, TP/SL or trigger levels.""" + """L4 order lifecycle history (``client.symbols.list()`` has each + pair's first date). Spot serves ``history()`` only: no flow, TP/SL or + trigger levels.""" self.l4_orderbook = L4OrderBookResource(http, base_path) """L4 order-level orderbook: full reconstruction, raw diffs, - and checkpoint history (from 2026-03-10).""" + and checkpoint history (from 2026-05-05 22:57 UTC; PURR-USDC from + 2026-03-11 01:03 UTC).""" self.twap = SpotTwapResource(http, base_path) """TWAP status records by pair or by user wallet.""" diff --git a/oxarchive/resources/candles.py b/oxarchive/resources/candles.py index 2ba50e5..993a014 100644 --- a/oxarchive/resources/candles.py +++ b/oxarchive/resources/candles.py @@ -183,7 +183,7 @@ def __init__( class SpotCandlesResource(CandlesResource): """Hyperliquid Spot OHLCV candles with a 1,000-row page cap. - Spot candle coverage starts at ``2025-03-22T10:50:22Z``. Supported intervals + Spot candle coverage starts at 2025-03-22 10:50 UTC. Supported intervals are ``1m``, ``5m``, ``15m``, ``30m``, ``1h``, ``4h``, ``1d``, and ``1w``. """ diff --git a/oxarchive/resources/cvd.py b/oxarchive/resources/cvd.py index 603aa5a..6e37595 100644 --- a/oxarchive/resources/cvd.py +++ b/oxarchive/resources/cvd.py @@ -109,7 +109,7 @@ def history( Args: symbol: Symbol (for example ``BTC``; HIP-3 symbols keep their - builder prefix and case, for example ``km:US500``). + builder prefix and case, for example ``xyz:TSLA``). start: Window start. A time without a time zone is UTC. end: Window end (default: now). interval: Bucket width: ``1m``, ``5m``, ``15m``, ``30m``, ``1h`` diff --git a/oxarchive/resources/data_quality.py b/oxarchive/resources/data_quality.py index b2daf8f..e865cbb 100644 --- a/oxarchive/resources/data_quality.py +++ b/oxarchive/resources/data_quality.py @@ -163,7 +163,7 @@ def symbol_coverage( exchange: Venue scope: 'hyperliquid', 'hip3', 'hip4', 'spot', 'lighter' or 'rh-lighter' (Lighter on Robinhood Chain) symbol: Symbol exactly as the venue's routes take it: 'BTC', a - case-sensitive HIP-3 coin such as 'km:US500', a spot pair such as + case-sensitive HIP-3 coin such as 'xyz:TSLA', a spot pair such as 'HYPE-USDC', or a HIP-4 coin such as '#0'. It is sent as one URL-encoded path segment and is not re-cased. from_time: Start of gap detection window (default: now - 30 days). @@ -357,7 +357,7 @@ def sla( SlaResponse with SLA targets, actual metrics, and compliance status. Example: - >>> sla = client.data_quality.sla(year=2026, month=1) + >>> sla = client.data_quality.sla(year=2026, month=9) >>> print(f"Period: {sla.period}") >>> print(f"Uptime: {sla.actual.uptime}% ({sla.actual.uptime_status})") >>> print(f"Completeness: {sla.actual.data_completeness.overall}%") diff --git a/oxarchive/resources/funding.py b/oxarchive/resources/funding.py index 47a29ea..293f30d 100644 --- a/oxarchive/resources/funding.py +++ b/oxarchive/resources/funding.py @@ -17,8 +17,9 @@ class FundingResource: >>> # Get current funding rate >>> current = client.funding.current("BTC") >>> - >>> # Get funding rate history - >>> history = client.funding.history("ETH", start="2024-01-01", end="2024-01-07") + >>> # Get funding rate history for the last week + >>> now = datetime.now(timezone.utc) + >>> history = client.funding.history("ETH", start=now - timedelta(days=7), end=now) """ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str.upper): diff --git a/oxarchive/resources/instruments.py b/oxarchive/resources/instruments.py index 74e24bf..37aa131 100644 --- a/oxarchive/resources/instruments.py +++ b/oxarchive/resources/instruments.py @@ -145,8 +145,8 @@ class Hip3InstrumentsResource: >>> instruments = client.hyperliquid.hip3.instruments.list() >>> >>> # Get specific instrument - >>> us500 = client.hyperliquid.hip3.instruments.get("km:US500") - >>> print(f"Mark price: {us500.mark_price}") + >>> tsla = client.hyperliquid.hip3.instruments.get("xyz:TSLA") + >>> print(f"Mark price: {tsla.mark_price}") """ def __init__(self, http: HttpClient, base_path: str = "/v1/hyperliquid/hip3", coin_transform=None): @@ -174,7 +174,7 @@ def get(self, coin: str) -> Hip3Instrument: Get a specific HIP-3 instrument by coin name. Args: - coin: The coin name (e.g., 'km:US500', 'xyz:XYZ100'). Case-sensitive. + coin: The coin name (e.g., 'xyz:TSLA', 'xyz:XYZ100'). Case-sensitive. Returns: HIP-3 instrument details with latest market data diff --git a/oxarchive/resources/l2_orderbook.py b/oxarchive/resources/l2_orderbook.py index c4300b5..56eac4d 100644 --- a/oxarchive/resources/l2_orderbook.py +++ b/oxarchive/resources/l2_orderbook.py @@ -17,11 +17,13 @@ class L2OrderBookResource: >>> # Get current full-depth L2 orderbook >>> snapshot = client.hyperliquid.l2_orderbook.get("BTC") >>> - >>> # Get L2 orderbook at a historical timestamp - >>> snapshot = client.hyperliquid.l2_orderbook.get("BTC", timestamp=1711900800000) + >>> # Get L2 orderbook an hour ago (served from 2026-03-11 01:03 UTC) + >>> now = datetime.now(timezone.utc) + >>> hour_ago = now - timedelta(hours=1) + >>> snapshot = client.hyperliquid.l2_orderbook.get("BTC", timestamp=hour_ago) >>> >>> # Get L2 orderbook history - >>> history = client.hyperliquid.l2_orderbook.history("BTC", start="2026-03-21", end="2026-03-22") + >>> history = client.hyperliquid.l2_orderbook.history("BTC", start=hour_ago, end=now) """ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str.upper): diff --git a/oxarchive/resources/l4_orderbook.py b/oxarchive/resources/l4_orderbook.py index 1af5beb..00976fb 100644 --- a/oxarchive/resources/l4_orderbook.py +++ b/oxarchive/resources/l4_orderbook.py @@ -24,11 +24,13 @@ class L4OrderBookResource: >>> # Get current L4 orderbook snapshot >>> snapshot = client.hyperliquid.l4_orderbook.get("BTC") >>> - >>> # Get L4 orderbook diffs - >>> diffs = client.hyperliquid.l4_orderbook.diffs("BTC", start="2024-01-01", end="2024-01-02") + >>> # Get L4 orderbook diffs for the last hour + >>> now = datetime.now(timezone.utc) + >>> hour_ago = now - timedelta(hours=1) + >>> diffs = client.hyperliquid.l4_orderbook.diffs("BTC", start=hour_ago, end=now) >>> - >>> # Get L4 orderbook history - >>> history = client.hyperliquid.l4_orderbook.history("BTC", start="2024-01-01", end="2024-01-02") + >>> # Get L4 orderbook history (checkpoints) + >>> history = client.hyperliquid.l4_orderbook.history("BTC", start=hour_ago, end=now) """ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str.upper): diff --git a/oxarchive/resources/lighter_liquidations.py b/oxarchive/resources/lighter_liquidations.py index 6db7650..0d4cd6f 100644 --- a/oxarchive/resources/lighter_liquidations.py +++ b/oxarchive/resources/lighter_liquidations.py @@ -34,7 +34,10 @@ class LighterLiquidationsResource: ``source == "ws"`` and the venue's raw JSON in ``raw_json``. Example: - >>> page = client.lighter.liquidations.history("BTC", start="2026-09-01", end="2026-09-02") + >>> now = datetime.now(timezone.utc) + >>> page = client.lighter.liquidations.history( + ... "BTC", start=now - timedelta(days=1), end=now + ... ) >>> for liq in page.data: ... print(liq.timestamp, liq.price, liq.size, liq.usd_amount) >>> diff --git a/oxarchive/resources/liquidations.py b/oxarchive/resources/liquidations.py index 01117f2..717baea 100644 --- a/oxarchive/resources/liquidations.py +++ b/oxarchive/resources/liquidations.py @@ -129,7 +129,10 @@ def volume( CursorResponse with liquidation volume buckets and next_cursor for pagination Example: - >>> result = client.hyperliquid.liquidations.volume("BTC", start="2025-06-01", end="2025-06-02") + >>> now = datetime.now(timezone.utc) + >>> result = client.hyperliquid.liquidations.volume( + ... "BTC", start=now - timedelta(days=1), end=now + ... ) >>> for bucket in result.data: ... print(f"{bucket.timestamp}: ${bucket.total_usd:.0f} ({bucket.count} liquidations)") """ @@ -343,21 +346,17 @@ class LiquidationsResource(_LiquidationsBase): Retrieve historical liquidation events from Hyperliquid. - Note: Liquidation data is available from May 25, 2025 onwards. + Note: Liquidations are served from 2025-12-22. Example: - >>> # Get recent liquidations - >>> liquidations = client.hyperliquid.liquidations.history( - ... "BTC", - ... start="2025-06-01", - ... end="2025-06-02" - ... ) + >>> # Get the last day of liquidations + >>> end = datetime.now(timezone.utc) + >>> start = end - timedelta(days=1) + >>> liquidations = client.hyperliquid.liquidations.history("BTC", start=start, end=end) >>> >>> # Get liquidations for a specific user >>> user_liquidations = client.hyperliquid.liquidations.by_user( - ... "0x1234...", - ... start="2025-06-01", - ... end="2025-06-02" + ... "0x1234...", start=start, end=end ... ) """ @@ -477,7 +476,8 @@ class Hip3LiquidationsResource(_LiquidationsBase): ``by_user``. Symbols keep their builder prefix and case. Example: + >>> now = datetime.now(timezone.utc) >>> liqs = client.hyperliquid.hip3.liquidations.history( - ... "xyz:XYZ100", start="2026-09-01", end="2026-09-02" + ... "xyz:XYZ100", start=now - timedelta(days=7), end=now ... ) """ diff --git a/oxarchive/resources/openinterest.py b/oxarchive/resources/openinterest.py index f7eef93..19110c7 100644 --- a/oxarchive/resources/openinterest.py +++ b/oxarchive/resources/openinterest.py @@ -27,8 +27,9 @@ class _OpenInterestResourceBase(Generic[RecordT]): >>> # Get current open interest >>> current = client.open_interest.current("BTC") >>> - >>> # Get open interest history - >>> history = client.open_interest.history("ETH", start="2024-01-01", end="2024-01-07") + >>> # Get open interest history for the last week + >>> now = datetime.now(timezone.utc) + >>> history = client.open_interest.history("ETH", start=now - timedelta(days=7), end=now) """ def __init__( diff --git a/oxarchive/resources/oracle.py b/oxarchive/resources/oracle.py index bdf86fb..395c930 100644 --- a/oxarchive/resources/oracle.py +++ b/oxarchive/resources/oracle.py @@ -10,12 +10,12 @@ class Hip3OracleResource: """ HIP-3 oracle reads: the deployer-pushed external price and the discovery bounds. - Symbols keep their builder prefix and case (for example ``km:US500``). + Symbols keep their builder prefix and case (for example ``xyz:TSLA``). Example: - >>> price = client.hyperliquid.hip3.oracle.external_price("km:US500") + >>> price = client.hyperliquid.hip3.oracle.external_price("xyz:TSLA") >>> print(price.external_price, price.mark_price) - >>> bounds = client.hyperliquid.hip3.oracle.discovery_bounds("km:US500") + >>> bounds = client.hyperliquid.hip3.oracle.discovery_bounds("xyz:TSLA") >>> print(bounds.lower_bound, bounds.upper_bound) """ diff --git a/oxarchive/resources/orderbook.py b/oxarchive/resources/orderbook.py index 3130100..9843cb1 100644 --- a/oxarchive/resources/orderbook.py +++ b/oxarchive/resources/orderbook.py @@ -117,8 +117,10 @@ def history( venue: Hyperliquid core, HIP-3, HIP-4, spot and Lighter) granularity: Data resolution for the Lighter order book (Lighter mainnet and Robinhood Chain only). - Options: 'checkpoint' (1min, default), '30s', '10s', '1s', 'tick'. - Credit multipliers: checkpoint=1x, 30s=2x, 10s=3x, 1s=10x, tick=20x. + Options: 'checkpoint' (1min, default), '30s', '10s', '1s'. + Credit multipliers: checkpoint=1x, 30s=2x, 10s=3x, 1s=10x. + Tick-level data (20x) is a checkpoint plus raw deltas, not + snapshots: use :meth:`history_tick` for it. Returns: CursorResponse with order book snapshots and next_cursor for pagination diff --git a/oxarchive/resources/orders.py b/oxarchive/resources/orders.py index bdc83e0..d079a9e 100644 --- a/oxarchive/resources/orders.py +++ b/oxarchive/resources/orders.py @@ -295,15 +295,17 @@ class OrdersResource(_OrderFlowResource): L4 order history, flow, TP/SL and trigger levels (Hyperliquid core and HIP-3). Example: - >>> # Get order history - >>> result = client.hyperliquid.orders.history("BTC", start="2024-01-01", end="2024-01-02") + >>> # Get the last hour of order history + >>> end = datetime.now(timezone.utc) + >>> start = end - timedelta(hours=1) + >>> result = client.hyperliquid.orders.history("BTC", start=start, end=end) >>> orders = result.data >>> >>> # Get order flow aggregation - >>> flow = client.hyperliquid.orders.flow("BTC", start="2024-01-01", end="2024-01-02") + >>> flow = client.hyperliquid.orders.flow("BTC", start=start, end=end) >>> >>> # Get TP/SL history - >>> tpsl = client.hyperliquid.orders.tpsl("BTC", start="2024-01-01", end="2024-01-02") + >>> tpsl = client.hyperliquid.orders.tpsl("BTC", start=start, end=end) """ def trigger_levels( @@ -451,7 +453,8 @@ class Hip4OrdersResource(_OrderFlowResource): class SpotOrdersResource(_OrdersBase): """ - Hyperliquid spot L4 order lifecycle history (live from 2026-05-05). + Hyperliquid spot L4 order lifecycle history (``client.symbols.list()`` has + each pair's first date). Spot serves order history only: there is no flow, TP/SL or trigger-level route, and the history route takes no user, status, order-type or diff --git a/oxarchive/resources/positions.py b/oxarchive/resources/positions.py index a7f2a8b..b9441e9 100644 --- a/oxarchive/resources/positions.py +++ b/oxarchive/resources/positions.py @@ -248,11 +248,12 @@ class HyperliquidPositionsResource(_PositionsBase): >>> print(now.meta.as_of, now.meta.quality) >>> >>> # State at an instant (an exact hour serves the hourly snapshot) - >>> then = client.hyperliquid.positions.get("0xabc...", timestamp="2026-09-01T12:00:00Z") + >>> hour = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + >>> then = client.hyperliquid.positions.get("0xabc...", timestamp=hour - timedelta(hours=1)) >>> >>> # Every change in a window, following cursors >>> for leg in client.hyperliquid.positions.iterate_changes( - ... "0xabc...", start="2026-09-01", end="2026-09-02" + ... "0xabc...", start=hour - timedelta(days=1), end=hour ... ): ... print(leg.timestamp, leg.event_type, leg.start_position, leg.end_position) """ diff --git a/oxarchive/resources/spot.py b/oxarchive/resources/spot.py index 58a3228..4da4ba2 100644 --- a/oxarchive/resources/spot.py +++ b/oxarchive/resources/spot.py @@ -6,9 +6,9 @@ the dashed form. Spot has no funding, no open interest, or liquidations. Candle history is served -from ``2025-03-22T10:50:22Z`` with 1,000-row pages; trades go back to -2025-03-22. L4 and order history are served from 2026-03-10 and the order book -from 2026-05-05. +from 2025-03-22 10:50 UTC with 1,000-row pages and trades from 2025-03-22 +10:50:22 UTC; the order book from 2026-05-05 19:56 UTC and L4 from 2026-05-05 +22:57 UTC (PURR-USDC from 2026-03-11 01:03 UTC). """ from __future__ import annotations diff --git a/oxarchive/resources/trades.py b/oxarchive/resources/trades.py index 8c39fae..4b6295a 100644 --- a/oxarchive/resources/trades.py +++ b/oxarchive/resources/trades.py @@ -23,14 +23,16 @@ class TradesResource: paging, so a full page holds ``limit`` matching trades. Example: - >>> # Get trade history with cursor-based pagination (recommended) - >>> result = client.hyperliquid.trades.history("BTC", start="2024-01-01", end="2024-01-02") + >>> # Get the last hour of trades with cursor-based pagination (recommended) + >>> end = datetime.now(timezone.utc) + >>> start = end - timedelta(hours=1) + >>> result = client.hyperliquid.trades.history("BTC", start=start, end=end) >>> trades = result.data >>> >>> # Get all pages >>> while result.has_more: ... result = client.hyperliquid.trades.history( - ... "BTC", start="2024-01-01", end="2024-01-02", cursor=result.next_cursor + ... "BTC", start=start, end=end, cursor=result.next_cursor ... ) ... trades.extend(result.data) >>> diff --git a/oxarchive/types.py b/oxarchive/types.py index a10cfa5..be9ea33 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -349,7 +349,7 @@ class Hip3Instrument(_ApiRecord): """ coin: str - """Full coin name (e.g., km:US500, xyz:XYZ100).""" + """Full coin name (e.g., xyz:TSLA, xyz:XYZ100).""" namespace: str """Builder namespace (e.g., km, xyz).""" @@ -386,7 +386,8 @@ class Hip4SideSpec(BaseModel): """Public asset id: 100_000_000 + 10*outcome_id + side.""" display_title: Optional[str] = None - """Human-readable per-side title, as the API returns it: the outcome's question followed by the side name.""" + """Human-readable per-side title, as the API returns it: the outcome's + question followed by the side name.""" slug: Optional[str] = None """Per-side URL slug mirroring HL's URL pattern (e.g. 'btc-above-78213-yes-may-04-0600').""" @@ -2624,7 +2625,7 @@ class ResponseMeta(BaseModel): symbol: Optional[str] = None """On per-symbol routes, the canonical public symbol the response is for - (for example ``BTC``, ``km:US500``, ``HYPE-USDC``, ``#0``).""" + (for example ``BTC``, ``xyz:TSLA``, ``HYPE-USDC``, ``#0``).""" venue: Optional[str] = None """On per-symbol routes, the venue that served it: ``"hyperliquid"``, @@ -3273,7 +3274,7 @@ class Hip3OracleDiscoveryBounds(_ApiRecord): """ symbol: str - """HIP-3 symbol (for example ``km:US500``).""" + """HIP-3 symbol (for example ``xyz:TSLA``).""" reference_price: float """External price when available, otherwise the mark price.""" @@ -3312,7 +3313,7 @@ class Hip3OracleExternalPrice(_ApiRecord): """Latest deployer-pushed external price and mark price for a HIP-3 market.""" symbol: str - """HIP-3 symbol (for example ``km:US500``).""" + """HIP-3 symbol (for example ``xyz:TSLA``).""" external_price: Optional[float] = None """Externally derived reference price, when available.""" @@ -3486,7 +3487,7 @@ class SymbolEntry(BaseModel): model_config = ConfigDict(extra="allow") symbol: str - """Symbol as the venue's routes take it (for example ``BTC``, ``km:US500``, + """Symbol as the venue's routes take it (for example ``BTC``, ``xyz:TSLA``, ``HYPE-USDC``, ``#0``).""" exchange: str diff --git a/oxarchive/websocket.py b/oxarchive/websocket.py index 235e11d..d7f66b5 100644 --- a/oxarchive/websocket.py +++ b/oxarchive/websocket.py @@ -1408,7 +1408,7 @@ async def multi_replay( Args: channels: List of channels to replay together (e.g., ``["orderbook", "trades", "funding"]``). - coin: Trading pair (e.g., ``'BTC'``, ``'ETH'``, ``'km:US500'``). + coin: Trading pair (e.g., ``'BTC'``, ``'ETH'``, ``'xyz:TSLA'``). start: Start timestamp (Unix ms). end: End timestamp (Unix ms, defaults to now). speed: Playback speed multiplier (1 = real-time, 10 = 10x faster). diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index c981c53..08c7e6b 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -66,7 +66,9 @@ ROOT = Path(__file__).resolve().parents[1] -def _page(data: Any, *, has_more: bool, next_cursor: Optional[str] = None, **meta: Any) -> dict[str, Any]: +def _page( + data: Any, *, has_more: bool, next_cursor: Optional[str] = None, **meta: Any +) -> dict[str, Any]: body = envelope(data, next_cursor=next_cursor, **meta) body["meta"]["has_more"] = has_more return body diff --git a/tests/test_coverage_contract.py b/tests/test_coverage_contract.py index 2846161..11718b8 100644 --- a/tests/test_coverage_contract.py +++ b/tests/test_coverage_contract.py @@ -379,13 +379,16 @@ def test_public_copy_keeps_family_specific_coverage() -> None: assert "2026-05-02" in readme assert "~10s" in readme assert "250 orders per side" in readme - assert "March 5, 2026" in readme + assert "2026-03-05 03:33 UTC" in readme assert "raw ~1 min" not in readme assert "no funding, no liquidations, and no candles" not in readme assert "no funding / liquidations / candles" not in types # HIP-4 order book and open interest replay but do not stream live (capabilities). assert "live bridges paused" not in types - assert "Replay only: ``candles``, ``hip3_candles``, ``hip4_orderbook``, ``hip4_open_interest``" in " ".join(types.split()) + replay_only = ( + "Replay only: ``candles``, ``hip3_candles``, ``hip4_orderbook``, ``hip4_open_interest``" + ) + assert replay_only in " ".join(types.split()) assert "250 orders per side" in l3_resource assert "price levels per side" not in l3_resource assert "self.candles = CandlesResource" in exchanges @@ -393,7 +396,7 @@ def test_public_copy_keeps_family_specific_coverage() -> None: assert "HIP-3 and Lighter candle pages accept up to 10,000 rows" in readme assert "Candle pagination cursors are numeric timestamp strings" in readme assert "client.spot.candles.history" in readme - assert "2025-03-22T10:50:22Z" in readme + assert "2025-03-22 10:50 UTC" in readme assert "1m/5m/15m/30m/1h/4h/1d/1w" in readme assert "returns 501" not in readme assert "SpotCandlesResource" in exchanges