diff --git a/.github/workflows/package-contents.yml b/.github/workflows/package-contents.yml index 0525aec..ed91495 100644 --- a/.github/workflows/package-contents.yml +++ b/.github/workflows/package-contents.yml @@ -1,8 +1,9 @@ name: Package contents # Plants untracked files in the tree, then fails unless every file in the -# crate is a tracked source file or metadata cargo generates while packaging. -# The check is an allowlist on purpose: anything else is a failure. +# crate is a tracked source file or metadata cargo generates while packaging, +# and unless every tracked source file is in the crate. The check is an +# allowlist on purpose: anything else is a failure. on: pull_request: push: @@ -18,6 +19,8 @@ jobs: mkdir -p .scratch/probe && echo stray > .scratch/probe/stray.rs echo stray > examples/stray.rs echo stray > src/stray.txt + echo stray > src/stray.rs + echo stray > src/resources/stray.rs - name: The crate contains only tracked files and cargo metadata run: | set -euo pipefail @@ -27,3 +30,9 @@ jobs: bad=$(comm -23 /tmp/crate.txt /tmp/allowed.txt) if [ -n "$bad" ]; then echo "::error::unexpected crate files: $bad"; exit 1; fi grep -q '^src/lib.rs$' /tmp/crate.txt || { echo "::error::crate has no src/lib.rs"; exit 1; } + - name: Every tracked source file is in the crate + run: | + set -euo pipefail + git ls-files 'src/*.rs' | sort > /tmp/sources.txt + missing=$(comm -23 /tmp/sources.txt /tmp/crate.txt) + if [ -n "$missing" ]; then echo "::error::tracked source files missing from the crate (add them to include in Cargo.toml): $missing"; exit 1; fi diff --git a/.gitignore b/.gitignore index 0e429ed..a60ae44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ -# Tests (internal only, not published) -tests/* -!tests/coverage_contract.rs -!tests/rh_lighter_and_positions.rs -!tests/surface_parity.rs +# Local-only test programs, never committed. Every other file under tests/ is +# tracked. Tests do not ship in the crate either way: Cargo.toml's include +# lists what is published. +tests/client_tests.rs examples/test_all.rs # Build output diff --git a/CHANGELOG.md b/CHANGELOG.md index bea27a5..d86664d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,140 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). ## [1.12.0] - 2026-09-28 +### Upgrading from 1.8 + +1.8.0 is the last release published to crates.io before this one, so +upgrading from it also brings in the changes listed under 1.9.0 to 1.11.0 +below. These need code changes: + +- **Rust 1.85 or later.** The minimum supported Rust version was 1.75. +- **Removed fields.** `L4HistoryParams::depth` and + `Hip4L4HistoryParams::depth` (L4 history returns whole snapshots), and + `Hip4OpenInterestRecord::oracle_price` (HIP-4 has no oracle price). +- **Changed types.** `GetTradesParams::side` and `Hip4TradesParams::side` are + `Option` (`TradeSide::Buy` or `TradeSide::Sell`) instead of + `Option`. `client.hyperliquid.spot.orders` is a + `SpotOrdersResource` whose `history()` takes `SpotOrderHistoryParams`, and + `client.hyperliquid.hip3.liquidations` is a `Hip3LiquidationsResource`. +- **Removed methods.** `client.hyperliquid.hip3.liquidations.by_user()`, and + `flow()`, `tpsl()`, `trigger_levels()` and `trigger_levels_history()` on + `client.hyperliquid.spot.orders`. They called routes the API does not + serve and always failed. +- **New fields on parameter structs.** A struct literal that lists every + field needs the new ones: `OrderHistoryParams::triggered`, + `Hip4OrderHistoryParams::triggered`, `OrderFlowParams::cursor`, + `Hip4OrderFlowParams::cursor` and `L3HistoryParams::account`. End + literals with `..Default::default()` where the struct implements + `Default`, or set the field to `None`. +- **New fields on response types.** `CursorResponse` gains `has_more` and + `meta`; `L4OrderEntry` gains `timestamp` and `timestamp_ms`; + `OrderHistoryEntry` gains `trigger_condition` and `trigger_price`; + `LiquidationLevels`, `LiquidationLevelsHistoryItem` and + `TriggerLevelsHistoryItem` gain `snapshot_ts_ms`; `CoinFreshness` gains + `symbol`; `IncidentsResponse` gains `pagination`. This affects only code + that builds these types with struct literals, such as test fixtures. +- **New fields on enum variants.** `Error::Api` gains `error_code`, `param` + and `valid_values`, and `ServerMsg::Error` gains `error_code`. A pattern + that names every field needs them or `..`. +- **New enum variants.** `OiFundingInterval::OneMinute` and + `ClientMsg::SubscribeWithInterval`. A `match` without a wildcard arm needs + an arm for each, and an `as` cast of an `OiFundingInterval` value gives a + different number. +- **`OxArchive`** has a private field, so it can no longer be built with a + struct literal. Use `OxArchive::new()` or `OxArchive::builder()`. + +These change what a call returns, with no code change needed: + +- Every request selects API version 2026-10-01. The SDK's types read the + shapes it selects; code that reads raw JSON or `extra` sees RFC 3339 times + with integer `*_ms` fields alongside, and Lighter replay rows in the live + shapes, which `ServerMsg::lighter_live_data()` decodes. +- Lighter `funding_rate` values are decimal fractions (`0.0001` means + `0.01%`), not percentages (since 1.9.1). +- `Trade::fee`, `closed_pnl` and `start_position` are `"0"` when the venue + recorded a zero; `None` means the source did not record the value (since + 1.10.0). +- `OxArchiveWs::stream()` and `stream_stop()` are deprecated: the server no + longer streams in bulk over WebSocket (since 1.11.0). + ### Added +- API version 2026-10-01. Every request sends the `0xArchive-Version: + 2026-10-01` header and the WebSocket client connects with + `version=2026-10-01` (`API_VERSION`, `API_VERSION_HEADER`). The version + selects the standard `{success, data, meta}` envelope on every route, + RFC 3339 UTC times with an integer `*_ms` field alongside, the stable error + codes, and the live row shapes on Lighter replay. +- Typed error codes. `Error::Api` gains `error_code` (`ErrorCode`), `param` + and `valid_values` next to `code` (the HTTP status) and `request_id`, and + `Error` has `error_code()`, `status()`, `request_id()`, `param()` and + `valid_values()` accessors. `ErrorCode` names every documented code + (`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`) and keeps any other as `ErrorCode::Other`. The + error's `Display` output includes the code. +- WebSocket `ServerMsg::Error` carries `error_code`, and + `ServerMsg::error_code()` reads it. Lag notices carry `slow_consumer`; an + endpoint that does not serve a channel answers `endpoint_unsupported`; a + channel without a mode (live, replay, `replay.seek`) answers + `unsupported_for_venue`. +- `has_more` on every paged response. `CursorResponse` and `MetaResponse` + are one type with `data`, `next_cursor`, `has_more` and the full `meta` + block; page while `has_more` is `true`. `ResponseMeta` gains `has_more`, + `symbol` (the canonical public symbol a per-symbol route answered for, such + as `#0` for a HIP-4 `"0"`) and `venue` (`hyperliquid`, `hip3`, `hip4`, + `spot`, `lighter` or `rh-lighter`). +- `client.capabilities()` (`GET /v1/capabilities`): one `Capability` row per + venue and datatype with its REST routes, WebSocket channels, `live` and + `replay` flags, `available_from`, cadence, `page_limit` and `intervals`. + `Capability::for_channel()` finds a channel's row. +- `client.data_quality.status_coverage()` (`GET /v1/status/coverage`), the + public coverage summary, and `client.list_symbols()`, the same call as + `client.symbols()`. +- Trade side filter: `side` (`TradeSide::Buy` or `TradeSide::Sell`, sent as + `buy` or `sell`) on `GetTradesParams` and `Hip4TradesParams`, and + `RecentTradesParams` for `trades.recent_with()` and + `hip4.get_trades_recent_with()`, on every venue. The filter keeps rows + whose `side` is `"B"` or `"A"` and applies before paging. +- `triggered` on `OrderHistoryParams` and `Hip4OrderHistoryParams`: + `Some(true)` keeps only orders whose trigger fired, `Some(false)` leaves + them out (Hyperliquid, HIP-3 and HIP-4). +- `depth` on `L2HistoryParams` (full-depth L2 history, Hyperliquid and + HIP-3) and `Hip4OrderBookHistoryParams`, which + `hip4.get_orderbook_history()` now takes; a `Hip4HistoryRange` still + converts into it. `OrderBookHistoryParams::depth` applies to HIP-3 and + Spot order book history too. +- Verb aliases, next to the existing names: `trades.history()` for + `trades.list()`, and on HIP-4 `list_instruments()`, `get_trades_history()`, + `get_open_interest_history()` and `get_price_history()` for + `get_instruments()`, `get_trades()`, `get_open_interest()` and + `get_prices()`. +- `orderbook.history_tick_page()` with `TickPageParams` and `TickPage`: one + page of tick-level order book data with its cursor and `has_more`, for + paging through a range. The first page carries the checkpoint; later pages + carry deltas only. +- WebSocket replay for every L4 channel and both full-depth order book + channels: `hip3_l4_diffs`, `hip3_l4_orders`, `spot_l4_diffs`, + `spot_l4_orders`, `hip4_l4_diffs`, `hip4_l4_orders`, `orderbook_full` and + `hip3_orderbook_full` replay like core `l4_diffs` and `l4_orders`: an + `l4_snapshot` from the nearest L4 checkpoint, then ordered `l4_batch` + frames, in bulk (`speed` is ignored, `replay.seek` is refused) and one + channel at a time. `replay()` sends them; `replay_multi()` rejects them + before sending. `L4_REPLAY_CHANNELS`, `is_l4_channel()`, + `FULL_DEPTH_CHANNELS`, `is_full_depth_channel()` and + `is_single_channel_replay()` name them. +- `OrderHistoryEntry` gains `trigger_condition` and `trigger_price`, sent on + `triggered` rows, and decodes rows without `tif`, such as `triggered` + rows, with an empty `tif`. +- `L4OrderEntry` gains `timestamp` (RFC 3339) and `timestamp_ms`, the resting + order's queue time (`None` when unknown). Checkpoint history rows, which + send the queue time as integer milliseconds, fill both fields the same + way. `LiquidationLevels`, `LiquidationLevelsHistoryItem` and + `TriggerLevelsHistoryItem` gain `snapshot_ts_ms`. - `client.rh_lighter`, a client for Lighter on Robinhood Chain (`/v1/rh-lighter`), the second Lighter deployment. It has the same resources as `client.lighter` except the L3 order book: `orderbook`, @@ -14,13 +147,14 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). `price_history()`. Markets are quoted in USDG, with uppercase perp symbols (`BTC`) and dashed spot pairs (`AAPL-USDG`). Trades and liquidations are served from the venue launch on 2026-06-26 20:10:26 UTC; order book, open - interest and funding from 2026-08-22 18:43 UTC; candles from 2026-06-26 - once candle history is enabled for this deployment. `trades.list()` is + interest and funding from 2026-08-22 18:43 UTC; candles from + 2026-06-26 20:10 UTC. `trades.list()` is final up to the finalization boundary and `trades.recent()` is the preliminary tier, as on mainnet. - `liquidations` on both Lighter clients (`LighterLiquidationsResource`), with `history()` returning `LighterLiquidation` rows and `volume()` returning - `LighterLiquidationVolume` buckets. Mainnet history starts on 2026-06-10 + `LighterLiquidationVolume` buckets, each with `timestamp` as an RFC 3339 + UTC string and `timestamp_ms`. Mainnet history starts on 2026-06-10 and Robinhood Chain history at the venue launch, 2026-06-26 20:10:26 UTC. On Robinhood Chain, rows from before live capture were backfilled from the venue's finalized export and have `source` `"bucket"` and an empty @@ -28,8 +162,9 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). JSON. - `TradesResource::list_with_meta()` and `TradesResource::recent_with_meta()`, which return the same rows as `list()` and `recent()` as a - `MetaResponse>` with the full response `meta`. On both Lighter - deployments that includes the finalization boundary + `MetaResponse>` with the full response `meta` (`list()` returns + the same value, since every paged response carries `meta`). On both + Lighter deployments that includes the finalization boundary (`meta.finalized_through`), the clamp of a range that reached past it (`meta.requested_end`, `meta.clamped_to`) and, on `recent_with_meta()`, `meta.preliminary_row_count`. @@ -66,6 +201,10 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). served on `wss://api.0xarchive.io/ws`, and replay for those four and `rh_lighter_candles`. Live payloads have the mainnet Lighter shapes and decode with `ServerMsg::lighter_live_data()` and `LighterLiveData::decode()`. + Replay rows of the book, trades, open interest and funding channels, on + both deployments, now have the live shapes too and decode the same way + (`historical_data` and `replay_snapshot`); a replayed trades row is one + fill. `subscribe_with_interval()` accepts `rh_lighter_orderbook` (100 to 5000 ms). - `RH_LIGHTER_REPLAY_CHANNELS`, `RH_LIGHTER_LIVE_CHANNELS`, `RH_LIGHTER_REPLAY_ONLY_CHANNELS`, `RH_LIGHTER_SUBSCRIPTION_ERROR`, @@ -107,8 +246,9 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). `parse_signature_header()` returns the parsed parts. The verifier's `Debug` output never includes a secret. - `client.hyperliquid.cvd()` and `client.hyperliquid.hip3.cvd()`: cumulative - volume delta buckets (`CvdBucket`: taker buy and sell notional, `delta`, - `cumulative_delta`) at `1m` to `1w`, cursor-paged with `CvdParams`. They + volume delta buckets (`CvdBucket`: open time as `timestamp` (RFC 3339) and + `timestamp_ms`, taker buy and sell notional, `delta`, `cumulative_delta`) + at `1m` to `1w`, cursor-paged with `CvdParams`. They return a `MetaResponse`, whose `meta.notice` says when a response is one page of several; `cumulative_delta` restarts on every page. - `client.symbols()`: the public symbol universe (`GET /v1/symbols`), one @@ -123,7 +263,8 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). - `client.hyperliquid.hip3.oracle`: `external_price()` returns the latest deployer-pushed external price and mark price (`Hip3OracleExternalPrice`), and `discovery_bounds()` the instantaneous - discovery bounds (`Hip3OracleDiscoveryBounds`). + discovery bounds (`Hip3OracleDiscoveryBounds`), both with the source time + as `timestamp` (RFC 3339) and `timestamp_ms`. - `client.hyperliquid.hip4.list_questions()` and `get_question()`: HIP-4 questions (`Hip4Question`), which group binary outcomes under one ballot. `list_questions()` is cursor-paged with `Hip4ListQuestionsParams`. @@ -133,11 +274,9 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). `WalletMetrics`), filtered, sorted and offset-paged with `WalletClassifyParams`. - WebSocket: `orderbook_full` and `hip3_orderbook_full`, the full-depth L2 - order books, are documented in the README channel table. They are - live-only: `replay()` and `replay_multi()` reject them before sending - (`LIVE_ONLY_FULL_DEPTH_CHANNELS`, `is_live_only_full_depth_channel()`, - `FULL_DEPTH_REPLAY_ERROR`). Their frames decode as `ServerMsg::L4Snapshot` - (the whole book) and `ServerMsg::L4Batch` (price-level changes). + order books, are documented in the README channel table. Their frames + decode as `ServerMsg::L4Snapshot` (the whole book) and `ServerMsg::L4Batch` + (price-level changes), live and in replay. - `client.lighter.l3_orderbook.get_with_params()` with `L3OrderBookParams`: a snapshot at `timestamp`, filtered to one `account` index, with `depth`. `L3HistoryParams` gains `account`; it is built with a struct literal, so a @@ -150,11 +289,26 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). ### Changed - New dependencies `hmac` and `sha2`, used only by `webhook_signature`. -- Parameters the API ignores are no longer sent or offered: - `GetTradesParams::side` and `Hip4TradesParams::side` (trades are not - filtered by side), and `depth` on `L2HistoryParams`, `L4HistoryParams` and - `Hip4L4HistoryParams` (history returns whole snapshots). Remove these - fields from struct literals. +- `GetTradesParams::side` and `Hip4TradesParams::side` are + `Option` (`buy` or `sell`), which the API now applies, in + place of the `"A"` / `"B"` strings it ignored. A literal with + `side: None` is unchanged. +- `depth` is no longer offered on `L4HistoryParams` and + `Hip4L4HistoryParams`: L4 history returns whole snapshots. Remove it from + struct literals. +- `CursorResponse` is now the same type as `MetaResponse` and gains + `has_more` and `meta`. A struct literal or a pattern that names every + field needs the two new fields (or `..`). +- `Error::Api` gains `error_code`, `param` and `valid_values`, and + `ServerMsg::Error` gains `error_code`. A pattern that names every field, + such as `ServerMsg::Error { message }`, needs the new fields or `..`. +- `ServerMsg::lighter_live_data()` also decodes Lighter replay rows, which + arrive in the live shapes. +- `replay()` no longer rejects the HIP-3, Spot and HIP-4 L4 channels or the + full-depth channels, which the API now replays. +- `client.symbols()` and the data quality methods read the standard + envelope, and still read the older bodies. +- Documentation and messages name the venue "Lighter". - `client.hyperliquid.spot.orders` is a `SpotOrdersResource` whose `history()` takes `SpotOrderHistoryParams` (`start`, `end`, `cursor`, `limit`). The Spot route does not filter by user, status or order type, @@ -172,6 +326,14 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). the buckets the API serves: `"1m"` (the default), `"5m"`, `"15m"` and `"1h"`. +### Deprecated +- `LIVE_ONLY_L4_CHANNELS` and `is_live_only_l4_channel()`: every L4 channel + now replays, and `is_live_only_l4_channel()` always returns `false`. Use + `L4_REPLAY_CHANNELS` and `is_l4_channel()`. +- `SpotPair::mark_price`, `mid_price`, `latest_timestamp` and `is_active`. + The pairs routes do not return them, so they are always `None`. Read + prices from the pair's order book or trades. + ### Removed - `client.hyperliquid.hip3.liquidations.by_user()`, and `flow()`, `tpsl()`, `trigger_levels()` and `trigger_levels_history()` on @@ -180,6 +342,26 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). remains. ### Fixed +- A WebSocket replay requested with `speed: None` got no answer: the + request carried `"speed": null`, which the server does not accept. + `replay()` and `replay_multi()` now leave an unset `speed` or `end` out of + the request, so the server applies its defaults (1x, and up to now). This + matters most for the L4 and full-depth channels, which ignore `speed`. +- `SpotPair::base`, `quote`, `wire_symbol` and `spot_index` were always + `None` on `spot.pairs.list()` and `spot.pairs.get()`: the pairs routes send + them as `base_token_name`, `quote_token_name`, `name` and `pair_index`. + Both spellings now deserialize. The other registry fields (such as + `is_canonical` and the token decimals) stay in `extra`. +- `orderbook.collect_tick_history()` returned only the first page of a + range: it stopped on a page of fewer than 1,000 deltas, while tick pages + hold 100 by default, and it advanced by time, which reopened the same + checkpoint. It now follows the response's cursor (`cursor` and + `cursor_seq`) and applies every delta until `has_more` is `false`. The + README's manual pagination example does the same with + `history_tick_page()`. +- `spot.twap.by_symbol()` and `by_user()` failed to decode rows whose + `executed_size` or `executed_notional` arrive as numbers. Both fields + accept a number or a string and keep the decimal string. - `data_quality.symbol_coverage()` and `exchange_coverage()` percent-encode their path segments and keep the symbol's case, so `km:US500`, `HYPE-USDC` and HIP-4 `#0` reach the right route. A `#0` was cut off as a @@ -196,12 +378,29 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). `data_types` holds only the data types. ### Documentation -- 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 available instant + on every venue, as `GET /v1/capabilities` returns it, in place of months + (such as "April 2023+" and "February 2026+") and fixed market counts. The + Robinhood Chain candle start is 2026-06-26 20:10 UTC. +- README, rustdoc and example code read the most recent minutes, hours or + days instead of fixed dates, so every snippet works on a Free key's + rolling 30-day window, and uses markets that are trading (`xyz:XYZ100` on + HIP-3, the open daily BTC outcome on HIP-4). The README's Robinhood Chain + trades snippet sets `side`, and the HIP-3 instruments snippet prints + `namespace` and `ticker` with `{:?}`; both now compile. The WebSocket + replay examples use 10x, which every plan allows. +- The README's WebSocket channel table matches `GET /v1/capabilities`: + `spot_twap` is not a WebSocket channel (Spot TWAP is REST only), and + `hip4_orderbook` and `hip4_open_interest` replay but do not stream live. + The Enterprise replay speed is 1000x. +- Recent trades are documented for HIP-3, HIP-4, Spot, Lighter and Lighter + on Robinhood Chain. +- `examples/levels_smoke.rs` describes each call, and + `examples/websocket.rs` no longer subscribes to a settled HIP-4 market. - Documentation links point at docs.0xarchive.io. +- docs.rs builds the documentation with every feature, so the WebSocket + client (`oxarchive::ws`, behind the `websocket` feature) is documented + there. ## [1.11.0] - 2026-09-25 @@ -328,7 +527,7 @@ TypeScript and Python SDKs on one version. cursor-paginated history with `summary` mode). History retained from 2026-07-27. - **Trigger levels**: `orders.trigger_levels()` and - `trigger_levels_history()` — the pending stop-loss / take-profit map + `trigger_levels_history()`: the pending stop-loss / take-profit map (15-minute snapshot history). Typed models exported at the crate root. - **WebSocket L4 frames**: `ServerMsg::L4Snapshot` and `ServerMsg::L4Batch`. Previously these server messages failed to deserialize and were silently @@ -345,7 +544,7 @@ TypeScript and Python SDKs on one version. ### Fixed - `liquidations.by_user()` hit `/liquidations/{address}` instead of - `/liquidations/user/{address}` — the server treated the wallet as a coin + `/liquidations/user/{address}`; the server treated the wallet as a coin symbol and returned an empty array, silently. - `Candle` OHLCV fields declared plain `String` but the wire serves JSON numbers, so every `candles.history()` call failed to deserialize on all diff --git a/Cargo.toml b/Cargo.toml index cb9d83d..e7d263f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,43 @@ keywords = ["market-data", "hyperliquid", "lighterxyz", "orderbook", "trading"] categories = ["api-bindings", "finance"] readme = "README.md" # An allowlist of what ships. Cargo ignores `exclude` once `include` is set, and -# `include` does not consult .gitignore, so examples are named one by one: a -# directory pattern would also pick up untracked local files. +# `include` does not consult .gitignore, so every file is named one by one: a +# glob such as `/src/**/*.rs` would also pick up untracked local files. A new +# source file must be added here; the package-contents workflow fails when a +# tracked source file is missing from the crate. include = [ - "/src/**/*.rs", + "/src/client.rs", + "/src/error.rs", + "/src/exchanges.rs", + "/src/http.rs", + "/src/l4_reconstructor.rs", + "/src/lib.rs", + "/src/orderbook_reconstructor.rs", + "/src/resources/breadth.rs", + "/src/resources/candles.rs", + "/src/resources/cvd.rs", + "/src/resources/data_quality.rs", + "/src/resources/funding.rs", + "/src/resources/instruments.rs", + "/src/resources/l2_orderbook.rs", + "/src/resources/l3_orderbook.rs", + "/src/resources/l4_orderbook.rs", + "/src/resources/liquidations.rs", + "/src/resources/mod.rs", + "/src/resources/open_interest.rs", + "/src/resources/oracle.rs", + "/src/resources/orderbook.rs", + "/src/resources/orders.rs", + "/src/resources/positions.rs", + "/src/resources/spot.rs", + "/src/resources/trades.rs", + "/src/resources/wallets.rs", + "/src/resources/web3.rs", + "/src/resources/webhooks.rs", + "/src/types.rs", + "/src/webhook_signature.rs", + "/src/ws/client.rs", + "/src/ws/mod.rs", "/examples/basic.rs", "/examples/levels_smoke.rs", "/examples/pagination.rs", @@ -81,4 +114,8 @@ name = "websocket" path = "examples/websocket.rs" required-features = ["websocket"] +[package.metadata.docs.rs] +# Document the `websocket` feature (`oxarchive::ws`) on docs.rs. +all-features = true + [workspace] diff --git a/README.md b/README.md index 1e97410..0bc2a44 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Rust client for async services that need typed 0xArchive market data. -0xArchive is granular market data infrastructure for two venues: Hyperliquid and Lighter.xyz. Hyperliquid includes core perps (`/v1/hyperliquid`), HIP-3 builder perps (`/v1/hyperliquid/hip3`), HIP-4 outcome markets (`/v1/hyperliquid/hip4`), and Hyperliquid Spot (`/v1/hyperliquid/spot`). Lighter has two deployments: mainnet (`/v1/lighter`, `client.lighter`) and Robinhood Chain (`/v1/rh-lighter`, `client.rh_lighter`). +0xArchive is granular market data infrastructure for two venues: Hyperliquid and Lighter. Hyperliquid includes core perps (`/v1/hyperliquid`), HIP-3 builder perps (`/v1/hyperliquid/hip3`), HIP-4 outcome markets (`/v1/hyperliquid/hip4`), and Hyperliquid Spot (`/v1/hyperliquid/spot`). Lighter has two deployments: mainnet (`/v1/lighter`, `client.lighter`) and Robinhood Chain (`/v1/rh-lighter`, `client.rh_lighter`). Use this SDK when the integration belongs in an async Rust service, data system, backtest runner, or strongly typed market-data pipeline. @@ -20,6 +20,7 @@ Or add directly to your `Cargo.toml`: [dependencies] oxarchive = "1.12" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +chrono = "0.4" # the examples below use it for time windows ``` For WebSocket support (real-time streaming and replay): @@ -41,7 +42,7 @@ async fn main() -> oxarchive::Result<()> { let ob = client.hyperliquid.orderbook.get("BTC", None).await?; println!("Hyperliquid BTC mid price: {:?}", ob.mid_price); - // Lighter.xyz uses its own venue client + // Lighter uses its own venue client let lighter_ob = client.lighter.orderbook.get("BTC", None).await?; println!("Lighter BTC mid price: {:?}", lighter_ob.mid_price); @@ -56,29 +57,41 @@ async fn main() -> oxarchive::Result<()> { // Hyperliquid HIP-3 builder perps stay under client.hyperliquid.hip3 let hip3 = client.hyperliquid.hip3.instruments.list().await?; - let hip3_ob = client.hyperliquid.hip3.orderbook.get("km:US500", None).await?; + let hip3_ob = client.hyperliquid.hip3.orderbook.get("xyz:XYZ100", None).await?; let hip3_funding = client.hyperliquid.hip3.funding.current("xyz:XYZ100").await?; + println!( + "{} HIP-3 markets; xyz:XYZ100 mid price {:?}, funding {}", + hip3.len(), hip3_ob.mid_price, hip3_funding.funding_rate, + ); // Hyperliquid Spot stays under client.hyperliquid.spot (dashed canonical pairs) let pairs = client.hyperliquid.spot.pairs.list().await?; let hype_ob = client.hyperliquid.spot.orderbook.get("HYPE-USDC", None).await?; - println!("HYPE-USDC mid price: {:?}", hype_ob.mid_price); + println!("{} spot pairs; HYPE-USDC mid price: {:?}", pairs.len(), hype_ob.mid_price); - // Historical order book snapshots + // Order book snapshots from the last hour use oxarchive::resources::orderbook::OrderBookHistoryParams; + let end = chrono::Utc::now(); + let start = end - chrono::Duration::hours(1); let history = client.hyperliquid.orderbook.history("ETH", OrderBookHistoryParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: start.into(), + end: end.into(), cursor: None, limit: Some(100), depth: None, granularity: None, }).await?; + println!("{} ETH snapshots in the last hour", history.data.len()); Ok(()) } ``` +The examples in this README read the most recent minutes, hours or days, so +they work on every plan, including Free's rolling 30-day window. Each dataset's first +available instant is listed in [Data Coverage](#data-coverage) and returned by +[`client.capabilities()`](#capabilities). + ## Choose Your Next Path | Need | Link | @@ -95,14 +108,18 @@ When prototyping Rust services from Claude Code, ChatGPT Codex, or another codin ## Data Coverage +Dates are UTC and give each dataset's first available instant, as returned +by `client.capabilities()`. Exact starts can be later for markets listed after +them. + | 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 2, 2026+ | Candles and outcome-side OI are served from 2026-05-02; OI updates at ~10s. No funding or liquidations. | -| Hyperliquid Spot | Trades March 2025+; candles from exactly 2025-03-22T10:50:22Z; orderbook, L4, TWAP, and freshness from May 2026 | 326 authenticated inventory rows using dashed symbols (`HYPE-USDC`, `PURR-USDC`, ...). No funding, OI, or liquidations. | -| Lighter.xyz (mainnet) | Candles from 2025-08-01; observed global per-fill trade floor January 17, 2025; exact starts vary by market. L3 from March 5, 2026+; liquidations from 2026-06-10 | Maker/taker trade context; L3 caps at 250 orders per side; funding/OI update at ~10s. | -| Lighter.xyz (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; candles from 2026-06-26 once enabled | Second Lighter deployment, quoted in USDG: 84 markets, 57 perps (`BTC`) and 27 spot pairs (`AAPL-USDG`). No L3. | +| Hyperliquid | Order book from 2023-04-15; trades from 2023-04-15 03:31; funding, OI, 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 and order history from 2026-05-05 22:57 | Dashed symbols (`HYPE-USDC`, `PURR-USDC`, ...); list them with `client.hyperliquid.spot.pairs.list()`. No funding, OI, or liquidations. | +| Lighter (mainnet) | Trades from 2025-01-17 08:43 (exact starts vary by market); 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 | Maker/taker trade context; L3 caps at 250 orders per side; funding/OI update at ~10s. | +| Lighter (Robinhood Chain) | Candles from 2026-06-26 20:10; trades and liquidations from 2026-06-26 20:10:26 (venue launch); order book, OI, funding, and price history from 2026-08-22 18:43 | Second Lighter deployment, quoted in USDG, with perps (`BTC`) and spot pairs (`AAPL-USDG`); list them with `client.rh_lighter.instruments.list()`. No L3. | | Account positions | Hyperliquid core change log from 2025-05-25, HIP-3 from 2025-10-13, hourly snapshots from 2026-06-07, live every 5 minutes. Lighter mainnet from 2025-01-17, Robinhood Chain from 2026-06-26, hourly, live every 2 minutes | Perp positions per wallet or account, as of any time in coverage. See [Account Positions](#account-positions). | ## Configuration @@ -117,6 +134,46 @@ let client = OxArchive::builder("0xa_your_api_key") .build()?; ``` +Every request sends the `0xArchive-Version: 2026-10-01` header +(`oxarchive::API_VERSION`), and the WebSocket client connects with +`version=2026-10-01`. The version selects the response shapes the SDK types +describe: the standard `{success, data, meta}` envelope on every route, +RFC 3339 UTC times with an integer `*_ms` field alongside, and the stable +error codes in [Error Handling](#error-handling). + +## Pagination + +Paged methods return a `CursorResponse` (the same type as `MetaResponse`): +the page's `data`, `next_cursor`, `has_more`, and the full `meta` block. Pass +`next_cursor` back as `cursor`, with every other parameter unchanged, while +`has_more` is `true`. A last page can be empty when the page before it was +exactly full. Per-symbol routes also report the canonical symbol and the +venue they answered for in `meta.symbol` and `meta.venue` (a HIP-4 `"0"` is +answered as `#0`). + +```rust +use chrono::{Duration, Utc}; +use oxarchive::resources::trades::GetTradesParams; + +let end = Utc::now(); +let start = end - Duration::minutes(10); +let mut cursor = None; +loop { + let page = client.hyperliquid.trades.history("BTC", GetTradesParams { + start: start.into(), + end: end.into(), + cursor, + limit: Some(1000), + side: None, + }).await?; + println!("{} rows for {:?} on {:?}", page.data.len(), page.meta.symbol, page.meta.venue); + if !page.has_more { + break; + } + cursor = page.next_cursor; +} +``` + ## Symbols `client.symbols()` lists the public symbol universe across every venue family @@ -131,6 +188,27 @@ let hip3: Vec<_> = symbols.iter().filter(|s| s.exchange == "hip3").collect(); println!("{} symbols, {} on HIP-3", symbols.len(), hip3.len()); ``` +`client.list_symbols()` is the same call. + +## Capabilities + +`client.capabilities()` returns what each venue serves (`GET /v1/capabilities`), +one `Capability` row per venue and datatype: its REST routes, its WebSocket +channels, whether those stream live (`live`) and replay history (`replay`), +the first instant served (`available_from`), the cadence, the largest page +(`page_limit`), and the accepted `interval` values. The route is public and +uses no credits. Read replay availability from it rather than from a fixed +list: + +```rust +use oxarchive::Capability; + +let rows = client.capabilities().await?; +if let Some(row) = Capability::for_channel(&rows, "spot_l4_diffs") { + println!("spot_l4_diffs: live={} replay={} from {:?}", row.live, row.replay, row.available_from); +} +``` + ## REST API Reference The sections below show which resources are available on each exchange client: @@ -165,6 +243,7 @@ HIP-4 outcome markets (`client.hyperliquid.hip4`) have their own methods, listed ### Order Book ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::orderbook::{GetOrderBookParams, OrderBookHistoryParams}; // Get current order book @@ -173,16 +252,18 @@ println!("Mid price: {:?}", ob.mid_price); println!("Best bid: {:?}", ob.bids.first()); println!("Best ask: {:?}", ob.asks.first()); -// Get with specific timestamp and depth +// Get the book as of a timestamp (one hour ago), 20 levels per side +let end = Utc::now(); +let start = end - Duration::hours(1); let ob = client.hyperliquid.orderbook.get("BTC", Some(GetOrderBookParams { - timestamp: Some(1704067200000_i64.into()), + timestamp: Some(start.into()), depth: Some(20), })).await?; // Get historical snapshots let history = client.hyperliquid.orderbook.history("BTC", OrderBookHistoryParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), depth: None, @@ -194,18 +275,21 @@ let history = client.hyperliquid.orderbook.history("BTC", OrderBookHistoryParams Depth 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. -**Note:** Dedicated L2 routes derived from L4 return all served levels where supported. Lighter L3 exposes individual resting orders rather than price levels and begins March 5, 2026. +**Note:** Dedicated L2 routes derived from L4 return all served levels where supported. `depth` limits the levels per side on order book history for every venue (`OrderBookHistoryParams::depth`, `Hip4OrderBookHistoryParams::depth`) and on full-depth L2 history (`L2HistoryParams::depth`). Lighter L3 exposes individual resting orders rather than price levels and begins at 2026-03-05 03:33 UTC. #### Lighter Orderbook Granularity -Lighter.xyz orderbook history supports a `granularity` parameter for different data resolutions, on both deployments (`client.lighter` and `client.rh_lighter`): +Lighter orderbook history supports a `granularity` parameter for different data resolutions, on both deployments (`client.lighter` and `client.rh_lighter`): ```rust +use chrono::{Duration, Utc}; +use oxarchive::resources::orderbook::OrderBookHistoryParams; use oxarchive::types::LighterGranularity; +let end = Utc::now(); let history = client.lighter.orderbook.history("BTC", OrderBookHistoryParams { - start: 1769904000000_i64.into(), // 2026-02-01 00:00 UTC - end: 1769990400000_i64.into(), // 2026-02-02 00:00 UTC + start: (end - Duration::hours(1)).into(), + end: end.into(), cursor: None, limit: None, depth: None, @@ -226,17 +310,21 @@ let history = client.lighter.orderbook.history("BTC", OrderBookHistoryParams { Tick-level orderbook history returns a full **checkpoint** plus **incremental deltas**, allowing you to reconstruct the exact state of the order book at every tick. This is the most granular data available and is ideal for backtesting, market microstructure research, and latency analysis. ```rust +use chrono::{Duration, Utc}; use oxarchive::OxArchive; use oxarchive::orderbook_reconstructor::OrderBookReconstructor; -use oxarchive::types::ReconstructOptions; let client = OxArchive::new("your-api-key")?; -// Option 1: One-shot — fetch and reconstruct in one call +// The last ten minutes, in Unix milliseconds +let end = Utc::now().timestamp_millis(); +let start = end - Duration::minutes(10).num_milliseconds(); + +// Option 1: One-shot: fetch and reconstruct in one call let snapshots = client.lighter.orderbook.history_reconstructed( "BTC", - 1769904000000_i64, // start (2026-02-01 00:00 UTC) - 1769907600000_i64, // end (2026-02-01 01:00 UTC) + start, + end, Some(20), // depth (top 20 levels per side) true, // emit_all: snapshot after every tick ).await?; @@ -246,18 +334,18 @@ for snapshot in &snapshots { snapshot.timestamp, snapshot.mid_price, snapshot.spread_bps, snapshot.sequence); } -// Option 2: Auto-paginated — fetches all pages automatically +// Option 2: Auto-paginated: fetches all pages automatically let all_snapshots = client.lighter.orderbook.collect_tick_history( "BTC", - 1769904000000_i64, // 2026-02-01 00:00 UTC - 1769990400000_i64, // 2026-02-02 00:00 UTC + start, + end, Some(20), ).await?; println!("{} tick-level snapshots", all_snapshots.len()); -// Option 3: Manual control — fetch raw tick data and reconstruct yourself +// Option 3: Manual control: fetch raw tick data and reconstruct yourself let tick_data = client.lighter.orderbook.history_tick( - "BTC", 1769904000000_i64, 1769907600000_i64, None, + "BTC", start, end, None, ).await?; // Check data integrity @@ -285,36 +373,36 @@ println!("Final mid price: {:?}", final_state.mid_price); #### Manual Pagination -For large time ranges where memory is a concern, paginate manually instead of using `collect_tick_history`: +For large time ranges where memory is a concern, paginate manually instead of using `collect_tick_history`. The first page carries the checkpoint at or before `start`; each later page carries only deltas to apply on top of the running book. A page holds a bounded number of deltas (100 by default), so continue while `has_more` is `true`, passing the page's cursor back with the same `start`, `end`, and `depth`: ```rust -let mut cursor = 1769904000000_i64; // 2026-02-01 00:00 UTC -let end = 1769990400000_i64; // 2026-02-02 00:00 UTC; +use chrono::{Duration, Utc}; +use oxarchive::orderbook_reconstructor::OrderBookReconstructor; +use oxarchive::resources::orderbook::TickPageParams; -while cursor < end { - let tick_data = client.lighter.orderbook.history_tick( - "BTC", cursor, end, Some(20), - ).await?; +let end = Utc::now().timestamp_millis(); +let start = end - Duration::minutes(10).num_milliseconds(); +let mut params = TickPageParams { + depth: Some(20), + ..TickPageParams::new(start, end) +}; +let mut reconstructor = OrderBookReconstructor::new(); - if tick_data.deltas.is_empty() { - break; +loop { + let page = client.lighter.orderbook.history_tick_page("BTC", params.clone()).await?; + if let Some(checkpoint) = &page.checkpoint { + reconstructor.initialize(checkpoint); } + for delta in &page.deltas { + reconstructor.apply_delta(delta); + } + let book = reconstructor.get_snapshot(Some(20)); + println!("{}: {} bids, {} asks", book.timestamp, book.bids.len(), book.asks.len()); - // Process this page... - let mut reconstructor = OrderBookReconstructor::new(); - let final_state = reconstructor.reconstruct_final( - &tick_data.checkpoint, &tick_data.deltas, Some(20), - ); - println!("{}: {} bids, {} asks", final_state.timestamp, final_state.bids.len(), final_state.asks.len()); - - // Advance cursor past the last delta - let last_ts = tick_data.deltas.iter().map(|d| d.timestamp).max().unwrap(); - cursor = last_ts + 1; - - // Fewer than ~1000 deltas means end of range - if tick_data.deltas.len() < 1000 { + if !page.has_more { break; } + params = params.after(&page); } ``` @@ -323,50 +411,80 @@ while cursor < end { Cursor-based pagination for efficient retrieval of large datasets. ```rust -use oxarchive::resources::trades::GetTradesParams; +use chrono::{Duration, Utc}; +use oxarchive::resources::trades::{GetTradesParams, RecentTradesParams}; +use oxarchive::types::TradeSide; + +let end = Utc::now(); +let start = end - Duration::minutes(10); -// Get trades with pagination -let result = client.hyperliquid.trades.list("BTC", GetTradesParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), +// Get trades with pagination (`history()` and `list()` are the same call) +let result = client.hyperliquid.trades.history("BTC", GetTradesParams { + start: start.into(), + end: end.into(), limit: Some(1000), cursor: None, + side: None, }).await?; // Paginate through all results let mut all_trades = result.data; +let mut has_more = result.has_more; let mut cursor = result.next_cursor; -while let Some(c) = cursor { - let page = client.hyperliquid.trades.list("BTC", GetTradesParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), +while has_more { + let page = client.hyperliquid.trades.history("BTC", GetTradesParams { + start: start.into(), + end: end.into(), limit: Some(1000), - cursor: Some(c), + cursor: cursor.take(), + side: None, }).await?; all_trades.extend(page.data); + has_more = page.has_more; cursor = page.next_cursor; } -// Get recent trades (Lighter and HIP-3 only) +// Only the buy side (`side` "B"); `TradeSide::Sell` keeps "A" +let buys = client.hyperliquid.hip3.trades.history("xyz:XYZ100", GetTradesParams { + start: start.into(), + end: end.into(), + limit: Some(1000), + cursor: None, + side: Some(TradeSide::Buy), +}).await?; + +// Get recent trades (HIP-3, HIP-4, Spot, Lighter and Robinhood Chain) let recent = client.lighter.trades.recent("BTC", Some(100)).await?; let rh_recent = client.rh_lighter.trades.recent("BTC", Some(100)).await?; -let hip3_recent = client.hyperliquid.hip3.trades.recent("km:US500", Some(50)).await?; +let hip3_recent = client.hyperliquid.hip3.trades.recent("xyz:XYZ100", Some(50)).await?; +let spot_sells = client.hyperliquid.spot.trades.recent_with("HYPE-USDC", RecentTradesParams { + limit: Some(50), + side: Some(TradeSide::Sell), +}).await?; ``` -**Note:** The `recent()` method is available for Lighter.xyz and HIP-3 only. Hyperliquid does not have a recent trades endpoint — use `list()` with a time range instead. +**Note:** Recent trades are available for HIP-3, Spot, Lighter, and Lighter on Robinhood Chain through `recent()`, and for HIP-4 through `hip4.get_trades_recent()`. Hyperliquid core perps do not have a recent trades endpoint; use `history()` with a time range instead. + +`side` filters on each row's own side, before paging: where a trade is returned as one fill per side (maker and taker), `TradeSide::Buy` keeps the buying fill of each trade. Keep it unchanged while paging. HIP-4 takes the same filter on `Hip4TradesParams::side` and `get_trades_recent_with()`. -On both Lighter deployments, `list()` returns final trades only: `end` is clamped to the finalization boundary, which runs about a day behind, so a range that reaches past it ends early with `next_cursor` `None`. `recent()` serves the newer, preliminary tier. To see the boundary, use `list_with_meta()` and `recent_with_meta()`, which return the same rows as a `MetaResponse` with the full `meta` block: +On both Lighter deployments, `list()` returns final trades only: `end` is clamped to the finalization boundary, which runs about a day behind, so a range that reaches past it ends early with `has_more` `false`. `recent()` serves the newer, preliminary tier. The page's `meta` block says where the boundary is; `list_with_meta()` and `recent_with_meta()` return the same rows as a `MetaResponse`: - `meta.finalized_through`: every trade before it is final. - `meta.requested_end` and `meta.clamped_to`: set when your `end` was past the boundary; `clamped_to` is where the range stopped. - `meta.preliminary_row_count` (on `recent_with_meta()`): how many rows in the response are not final yet. ```rust +use chrono::{Duration, Utc}; +use oxarchive::resources::trades::GetTradesParams; + +// The last two days: the newest part is past the finalization boundary. +let end = Utc::now(); let page = client.rh_lighter.trades.list_with_meta("BTC", GetTradesParams { - start: 1788220800000_i64.into(), // 2026-09-01 00:00 UTC - end: 1788307200000_i64.into(), // 2026-09-02 00:00 UTC + start: (end - Duration::days(2)).into(), + end: end.into(), limit: Some(1000), cursor: None, + side: None, }).await?; if let Some(boundary) = &page.meta.clamped_to { println!("range stopped at {boundary}; the rest is not final yet"); @@ -393,7 +511,7 @@ for inst in &instruments { // Get specific instrument let btc = client.hyperliquid.instruments.get("BTC").await?; -// Lighter.xyz instruments (different schema with fees, market IDs) +// Lighter instruments (different schema with fees, market IDs) let lighter_instruments = client.lighter.instruments.list().await?; for inst in &lighter_instruments { println!("{}: taker_fee={:?}, maker_fee={:?}", inst.symbol, inst.taker_fee, inst.maker_fee); @@ -402,7 +520,10 @@ for inst in &lighter_instruments { // HIP-3 instruments (derived from live data, includes mark price + OI) let hip3_instruments = client.hyperliquid.hip3.instruments.list().await?; for inst in &hip3_instruments { - println!("{} ({}:{}): mark={:?}", inst.coin, inst.namespace, inst.ticker, inst.mark_price); + println!( + "{} (namespace {:?}, ticker {:?}): mark={:?}", + inst.coin, inst.namespace, inst.ticker, inst.mark_price, + ); } ``` @@ -419,6 +540,7 @@ is unavailable (`None`) when no instrument is eligible; do not render it as denominator varies. ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::breadth::BreadthHistoryParams; use oxarchive::types::OiFundingInterval; @@ -428,9 +550,11 @@ println!("HIP-3 above session VWAP: {:?}%", current.value_pct); let core = client.hyperliquid.breadth.current().await?; println!("Core perps above session VWAP: {:?}%", core.value_pct); +// The last 24 hours at five-minute steps +let end = Utc::now(); let history = client.hyperliquid.hip3.breadth.history(BreadthHistoryParams { - start: Some(1787961600000_i64.into()), - end: Some(1788048000000_i64.into()), + start: Some((end - Duration::days(1)).into()), + end: Some(end.into()), interval: Some(OiFundingInterval::FiveMinutes), cursor: None, limit: Some(1000), @@ -444,11 +568,12 @@ instantaneous discovery bounds. The bounds apply `bound_fraction`, derived from the market's maximum leverage, on each side of the reference price, which is the external price when there is one and the mark price otherwise. The full ratcheted range can be wider when deployer-specific reset configuration -applies. Keep the builder prefix and case in the symbol. +applies. Keep the builder prefix and case in the symbol. `timestamp` is an +RFC 3339 UTC string and `timestamp_ms` the same instant in Unix milliseconds. ```rust let price = client.hyperliquid.hip3.oracle.external_price("xyz:XYZ100").await?; -println!("external {:?}, mark {:?} at block {}", price.external_price, price.mark_price, price.block_number); +println!("external {:?}, mark {:?} at block {} ({})", price.external_price, price.mark_price, price.block_number, price.timestamp); let bounds = client.hyperliquid.hip3.oracle.discovery_bounds("xyz:XYZ100").await?; println!( @@ -461,6 +586,7 @@ println!( ### Funding Rates ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::funding::FundingHistoryParams; use oxarchive::types::OiFundingInterval; @@ -468,10 +594,11 @@ use oxarchive::types::OiFundingInterval; let current = client.hyperliquid.funding.current("BTC").await?; println!("Funding rate: {}", current.funding_rate); -// Get history with aggregation interval +// Get the last 24 hours at an aggregation interval +let end = Utc::now(); let history = client.hyperliquid.funding.history("ETH", FundingHistoryParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: (end - Duration::days(1)).into(), + end: end.into(), cursor: None, limit: None, interval: Some(OiFundingInterval::OneHour), @@ -499,6 +626,7 @@ Raw cadence is route-specific: Hyperliquid core funding is ~1 minute; HIP-3 and ### Open Interest ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::open_interest::OpenInterestHistoryParams; use oxarchive::types::OiFundingInterval; @@ -506,10 +634,11 @@ use oxarchive::types::OiFundingInterval; let current = client.hyperliquid.open_interest.current("BTC").await?; println!("Open interest: {}", current.open_interest); -// Get history with aggregation +// Get the last 24 hours with aggregation +let end = Utc::now(); let history = client.hyperliquid.open_interest.history("BTC", OpenInterestHistoryParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: (end - Duration::days(1)).into(), + end: end.into(), cursor: None, limit: None, interval: Some(OiFundingInterval::OneHour), @@ -521,20 +650,25 @@ let history = client.hyperliquid.open_interest.history("BTC", OpenInterestHistor Historical liquidation events from 2025-12-22. Available on `client.hyperliquid.liquidations` and `client.hyperliquid.hip3.liquidations`. Liquidations by user (`by_user()`) are Hyperliquid core only. ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::liquidations::*; +// The last seven days +let end = Utc::now(); +let start = end - Duration::days(7); + // Get liquidation history let liquidations = client.hyperliquid.liquidations.history("BTC", LiquidationHistoryParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: start.into(), + end: end.into(), cursor: None, limit: None, }).await?; // Get liquidations for a specific user let user_liq = client.hyperliquid.liquidations.by_user("0x1234...", LiquidationsByUserParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: start.into(), + end: end.into(), coin: Some("BTC".to_string()), cursor: None, limit: None, @@ -542,8 +676,8 @@ let user_liq = client.hyperliquid.liquidations.by_user("0x1234...", Liquidations // Get pre-aggregated liquidation volume (100-1000x less data) let volume = client.hyperliquid.liquidations.volume("BTC", LiquidationVolumeParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: start.into(), + end: end.into(), interval: Some("1h".to_string()), cursor: None, limit: None, @@ -553,16 +687,16 @@ for bucket in &volume.data { } // HIP-3 liquidations (same API, different exchange prefix) -let hip3_liqs = client.hyperliquid.hip3.liquidations.history("km:US500", LiquidationHistoryParams { - start: 1772323200000_i64.into(), // 2026-03-01 00:00 UTC - end: 1772409600000_i64.into(), // 2026-03-02 00:00 UTC +let hip3_liqs = client.hyperliquid.hip3.liquidations.history("xyz:XYZ100", LiquidationHistoryParams { + start: start.into(), + end: end.into(), cursor: None, limit: None, }).await?; -let hip3_vol = client.hyperliquid.hip3.liquidations.volume("km:US500", LiquidationVolumeParams { - start: 1772323200000_i64.into(), // 2026-03-01 00:00 UTC - end: 1772409600000_i64.into(), // 2026-03-02 00:00 UTC +let hip3_vol = client.hyperliquid.hip3.liquidations.volume("xyz:XYZ100", LiquidationVolumeParams { + start: start.into(), + end: end.into(), interval: Some("1h".to_string()), cursor: None, limit: None, @@ -587,14 +721,20 @@ were backfilled from the venue's finalized export and have `source` `"bucket"` and an empty `raw_json`; rows captured live have `source` `"ws"` and the venue's raw JSON in `raw_json`. Volume buckets (`LighterLiquidationVolume`) carry `total_usd` and `count`, with no -long/short split. +long/short split. `timestamp` is an RFC 3339 UTC string on both, with +`timestamp_ms` the same instant in Unix milliseconds. ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::liquidations::{LiquidationHistoryParams, LiquidationVolumeParams}; +// The last seven days +let end = Utc::now(); +let start = end - Duration::days(7); + let liquidations = client.rh_lighter.liquidations.history("BTC", LiquidationHistoryParams { - start: 1788220800000_i64.into(), // 2026-09-01 00:00 UTC - end: 1788307200000_i64.into(), // 2026-09-02 00:00 UTC + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), }).await?; @@ -603,8 +743,8 @@ for liq in &liquidations.data { } let volume = client.lighter.liquidations.volume("BTC", LiquidationVolumeParams { - start: 1788220800000_i64.into(), - end: 1788307200000_i64.into(), + start: start.into(), + end: end.into(), interval: Some("1h".to_string()), cursor: None, limit: None, @@ -617,12 +757,16 @@ for bucket in &volume.data { ### Candles (OHLCV) ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::candles::CandleHistoryParams; use oxarchive::types::CandleInterval; +// Hourly candles for the last 24 hours +let end = Utc::now(); +let start = end - Duration::days(1); let candles = client.hyperliquid.candles.history("BTC", CandleHistoryParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: start.into(), + end: end.into(), interval: Some(CandleInterval::OneHour), cursor: None, limit: None, @@ -638,13 +782,21 @@ for candle in &candles.data { `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w` Hyperliquid, HIP-3, and Lighter candle routes accept at most 10,000 rows per -page. HIP-4 and Spot accept at most 1,000. Lighter candle history is served -from **2025-08-01**; HIP-4 candle history is served from **2026-05-02**. +page. HIP-4 and Spot accept at most 1,000. Candle history is served from +**2025-03-22 12:00 UTC** on Hyperliquid, **2025-12-22** on HIP-3, +**2026-05-02 08:00 UTC** on HIP-4, **2025-03-22 10:50 UTC** on Spot, +**2025-08-01** on Lighter, and **2026-06-26 20:10 UTC** on Lighter on +Robinhood Chain. ```rust +use chrono::{Duration, Utc}; +use oxarchive::resources::candles::CandleHistoryParams; +use oxarchive::types::CandleInterval; + +let end = Utc::now(); let lighter_candles = client.lighter.candles.history("BTC", CandleHistoryParams { - start: 1754006400000_i64.into(), // 2025-08-01 00:00 UTC - end: 1754092800000_i64.into(), // 2025-08-02 00:00 UTC + start: (end - Duration::days(1)).into(), + end: end.into(), cursor: None, limit: Some(1000), interval: Some(CandleInterval::OneHour), @@ -653,61 +805,70 @@ let lighter_candles = client.lighter.candles.history("BTC", CandleHistoryParams ### Orders (Hyperliquid and HIP-3) -Order history, order flow aggregation, and TP/SL order queries. Available on `client.hyperliquid.orders` and `client.hyperliquid.hip3.orders`. +Order history, order flow aggregation, and TP/SL order queries. Available on `client.hyperliquid.orders` and `client.hyperliquid.hip3.orders`. `triggered: Some(true)` on order history keeps only orders whose trigger fired (status `triggered`) and `Some(false)` leaves them out; HIP-4 takes the same filter on `Hip4OrderHistoryParams`. ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::orders::*; -// Get order history for a symbol +// Order history is served from 2026-03-11 01:03 UTC; read the last ten minutes +let end = Utc::now(); +let start = end - Duration::minutes(10); let orders = client.hyperliquid.orders.history("BTC", OrderHistoryParams { - start: Some(1704067200000_i64.into()), - end: Some(1704153600000_i64.into()), - user: None, - status: None, - order_type: None, - cursor: None, + start: Some(start.into()), + end: Some(end.into()), limit: Some(1000), + ..Default::default() }).await?; // Filter by user address let user_orders = client.hyperliquid.orders.history("BTC", OrderHistoryParams { - start: Some(1704067200000_i64.into()), - end: Some(1704153600000_i64.into()), + start: Some(start.into()), + end: Some(end.into()), user: Some("0x1234...".to_string()), status: Some("filled".to_string()), - order_type: None, - cursor: None, - limit: None, + ..Default::default() +}).await?; + +// Only orders whose trigger fired +let triggered = client.hyperliquid.orders.history("BTC", OrderHistoryParams { + start: Some(start.into()), + end: Some(end.into()), + triggered: Some(true), + ..Default::default() }).await?; -// Get aggregated order flow, one page of time buckets (2026-07-13 UTC at 1m). +// Get aggregated order flow over the last 24 hours at 1m. // A page holds up to `limit` buckets (default 1000, max 10000); follow -// next_cursor with the same start, end and interval until it is None. +// next_cursor with the same start, end and interval while has_more is true. +let day_ago = end - Duration::days(1); let flow = client.hyperliquid.orders.flow("BTC", OrderFlowParams { - start: Some(1783900800000_i64.into()), - end: Some(1783987200000_i64.into()), + start: Some(day_ago.into()), + end: Some(end.into()), interval: Some("1m".to_string()), // 1m (default), 5m, 15m, 1h cursor: None, limit: None, }).await?; let mut flow_buckets = flow.data; +let mut has_more = flow.has_more; let mut cursor = flow.next_cursor; -while let Some(c) = cursor { +while has_more { let page = client.hyperliquid.orders.flow("BTC", OrderFlowParams { - start: Some(1783900800000_i64.into()), - end: Some(1783987200000_i64.into()), + start: Some(day_ago.into()), + end: Some(end.into()), interval: Some("1m".to_string()), - cursor: Some(c), + cursor: cursor.take(), limit: None, }).await?; flow_buckets.extend(page.data); + has_more = page.has_more; cursor = page.next_cursor; } // Get TP/SL (take-profit / stop-loss) orders let tpsl = client.hyperliquid.orders.tpsl("BTC", TpslParams { - start: Some(1704067200000_i64.into()), - end: Some(1704153600000_i64.into()), + start: Some(day_ago.into()), + end: Some(end.into()), user: None, triggered: Some(true), cursor: None, @@ -715,46 +876,55 @@ let tpsl = client.hyperliquid.orders.tpsl("BTC", TpslParams { }).await?; // HIP-3 orders -let hip3_orders = client.hyperliquid.hip3.orders.history("km:US500", OrderHistoryParams::default()).await?; +let hip3_orders = client.hyperliquid.hip3.orders.history("xyz:XYZ100", OrderHistoryParams { + start: Some(start.into()), + end: Some(end.into()), + ..Default::default() +}).await?; ``` ### L4 Orderbook (Hyperliquid and HIP-3) -Node-level L4 orderbook data with user attribution. Available on `client.hyperliquid.l4_orderbook` and `client.hyperliquid.hip3.l4_orderbook`. +Node-level L4 orderbook data with user attribution. Available on `client.hyperliquid.l4_orderbook` and `client.hyperliquid.hip3.l4_orderbook`. On a current or point-in-time snapshot each resting order carries its queue time: `timestamp` (RFC 3339 UTC) and `timestamp_ms`, both `None` when the queue time is unknown. ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::l4_orderbook::*; +// L4 is served from 2026-03-11 01:03 UTC; read the last ten minutes +let end = Utc::now(); +let start = end - Duration::minutes(10); + // Get current L4 orderbook snapshot let l4 = client.hyperliquid.l4_orderbook.get("BTC", None).await?; -// Get with specific timestamp and depth +// Get the book as of a timestamp, 20 orders per side let l4 = client.hyperliquid.l4_orderbook.get("BTC", Some(L4OrderBookParams { - timestamp: Some(1704067200000_i64.into()), + timestamp: Some(start.into()), depth: Some(20), })).await?; // Get paginated L4 orderbook diffs let diffs = client.hyperliquid.l4_orderbook.diffs("BTC", L4DiffsParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), }).await?; -// Get paginated L4 orderbook history +// Get paginated L4 checkpoint history (whole snapshots; keep `limit` small) let history = client.hyperliquid.l4_orderbook.history("BTC", L4HistoryParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: (end - Duration::hours(1)).into(), + end: end.into(), cursor: None, - limit: Some(1000), + limit: Some(2), }).await?; // HIP-3 L4 orderbook -let hip3_l4 = client.hyperliquid.hip3.l4_orderbook.get("km:US500", None).await?; -let hip3_diffs = client.hyperliquid.hip3.l4_orderbook.diffs("km:US500", L4DiffsParams { - start: 1772323200000_i64.into(), // 2026-03-01 00:00 UTC - end: 1772409600000_i64.into(), // 2026-03-02 00:00 UTC +let hip3_l4 = client.hyperliquid.hip3.l4_orderbook.get("xyz:XYZ100", None).await?; +let hip3_diffs = client.hyperliquid.hip3.l4_orderbook.diffs("xyz:XYZ100", L4DiffsParams { + start: start.into(), + end: end.into(), cursor: None, limit: None, }).await?; @@ -765,35 +935,41 @@ let hip3_diffs = client.hyperliquid.hip3.l4_orderbook.diffs("km:US500", L4DiffsP Full-depth L2 orderbook derived from L4 data. Available on `client.hyperliquid.l2_orderbook` and `client.hyperliquid.hip3.l2_orderbook`. ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::l2_orderbook::*; +// Full-depth L2 is served from 2026-03-11 01:03 UTC; read the last hour +let end = Utc::now(); +let start = end - Duration::hours(1); + // Get current L2 full-depth orderbook let l2 = client.hyperliquid.l2_orderbook.get("BTC", None).await?; // Get L2 orderbook at a specific timestamp let l2 = client.hyperliquid.l2_orderbook.get("BTC", Some(L2OrderBookParams { - timestamp: Some(1704067200000_i64.into()), + timestamp: Some(start.into()), depth: Some(50), })).await?; -// Get L2 orderbook history +// Get L2 orderbook history, 50 levels per side let l2_history = client.hyperliquid.l2_orderbook.history("BTC", L2HistoryParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: start.into(), + end: end.into(), cursor: None, - limit: Some(1000), + limit: Some(100), + depth: Some(50), }).await?; -// Get L2 tick-level diffs +// Get L2 tick-level diffs from the last ten minutes let l2_diffs = client.hyperliquid.l2_orderbook.diffs("BTC", L2DiffsParams { - start: 1704067200000_i64.into(), - end: 1704153600000_i64.into(), + start: (end - Duration::minutes(10)).into(), + end: end.into(), cursor: None, limit: Some(1000), }).await?; // HIP-3 L2 orderbook -let hip3_l2 = client.hyperliquid.hip3.l2_orderbook.get("km:US500", None).await?; +let hip3_l2 = client.hyperliquid.hip3.l2_orderbook.get("xyz:XYZ100", None).await?; ``` ### L3 Orderbook (Lighter only) @@ -801,8 +977,13 @@ let hip3_l2 = client.hyperliquid.hip3.l2_orderbook.get("km:US500", None).await?; Order-level L3 orderbook data showing individual orders. Available on `client.lighter.l3_orderbook`. ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::l3_orderbook::{L3HistoryParams, L3OrderBookParams}; +// L3 is served from 2026-03-05 03:33 UTC; read the last hour +let end = Utc::now(); +let start = end - Duration::hours(1); + // Get current L3 orderbook let l3 = client.lighter.l3_orderbook.get("BTC", None).await?; @@ -811,15 +992,15 @@ let l3 = client.lighter.l3_orderbook.get("BTC", Some(20)).await?; // A point-in-time snapshot, filtered to one account's resting orders let mine = client.lighter.l3_orderbook.get_with_params("BTC", L3OrderBookParams { - timestamp: Some(1790553600000_i64.into()), + timestamp: Some(start.into()), account: Some(726714), depth: None, }).await?; // Get paginated L3 orderbook history (optionally one account's orders) let history = client.lighter.l3_orderbook.history("BTC", L3HistoryParams { - start: 1772668800000_i64.into(), // 2026-03-05 00:00 UTC - end: 1772755200000_i64.into(), // 2026-03-06 00:00 UTC + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), account: None, @@ -834,16 +1015,37 @@ inputs, use the bare numeric form (`"0"`, `"1"`, `"55850"`). Legacy `"#0"` inputs remain supported and are percent-encoded for transport; do not pre-encode them yourself. -HIP-4 candles and outcome-side OI are served from **2026-05-02**, with raw OI +HIP-4 trades and candles are served from **2026-05-02 08:00 UTC**, and the +order book and outcome-side OI from **2026-05-02 16:51 UTC**, with raw OI updates at ~10s. HIP-4 has **no funding rates and no liquidations**. `mark_price` on HIP-4 is an implied probability in `[0, 1]`, not a USD price. +Each side stops trading when its outcome settles, and new outcomes are listed +every day, so pick a side that is trading now. The example below takes the +Yes side of the open daily BTC outcome from the instrument list. + ```rust +use chrono::{Duration, Utc}; use oxarchive::exchanges::{Hip4HistoryRange, Hip4ListOutcomesParams, - Hip4ListQuestionsParams, Hip4OrderBookParams, Hip4TradesParams}; + Hip4ListQuestionsParams, Hip4OrderBookHistoryParams, Hip4OrderBookParams, Hip4TradesParams}; use oxarchive::resources::candles::CandleHistoryParams; use oxarchive::types::CandleInterval; +// Per-side instruments (`#0`, `#1`, ...); list_instruments() is the same call. +let insts = client.hyperliquid.hip4.get_instruments().await?; +let open = insts + .iter() + .find(|i| { + i.is_settled == Some(false) + && i.recurring_underlying.as_deref() == Some("BTC") + && i.side == 0 + }) + .expect("an open BTC outcome"); +// Path inputs use the bare numeric form: "#77440" becomes "77440". +let symbol = open.coin.trim_start_matches('#').to_string(); +let inst = client.hyperliquid.hip4.get_instrument(&symbol).await?; +println!("{}: {:?}", inst.coin, inst.display_title); + // Outcomes (per-outcome view, both sides combined) let outcomes = client.hyperliquid.hip4.list_outcomes(None).await?; for o in &outcomes.data { @@ -851,19 +1053,20 @@ for o in &outcomes.data { } // Filter by settlement state OR by slug -let live = client.hyperliquid.hip4.list_outcomes(Some(Hip4ListOutcomesParams { +let unsettled = client.hyperliquid.hip4.list_outcomes(Some(Hip4ListOutcomesParams { is_settled: Some(false), slug: None, cursor: None, limit: Some(50), })).await?; -let one = client.hyperliquid.hip4 - .get_outcome_by_slug("btc-above-78213-may-04-0600").await?; -println!("aggregated_oi: {:?}", one.aggregated_oi); +if let Some(slug) = &open.slug { + let one = client.hyperliquid.hip4.get_outcome_by_slug(slug).await?; + println!("aggregated_oi: {:?}", one.aggregated_oi); +} // Outcome detail (with aggregated_oi) -let detail = client.hyperliquid.hip4.get_outcome(5585).await?; +let detail = client.hyperliquid.hip4.get_outcome(open.outcome_id).await?; // Questions: a question groups binary outcomes, one named outcome per choice // plus a fallback outcome that resolves Yes when no named choice does. @@ -877,62 +1080,65 @@ for q in &questions.data { // Page with questions.next_cursor until it is None. let question = client.hyperliquid.hip4.get_question(1).await?; -// Per-side instruments (`#0`, `#1`, ...) -let insts = client.hyperliquid.hip4.get_instruments().await?; -let inst = client.hyperliquid.hip4.get_instrument("0").await?; +// The last six hours +let end = Utc::now(); +let start = end - Duration::hours(6); -// L2 orderbook -let ob = client.hyperliquid.hip4.get_orderbook("0", None).await?; -let ob_at = client.hyperliquid.hip4.get_orderbook("0", Some(Hip4OrderBookParams { - timestamp: Some(1777680000000_i64.into()), +// L2 orderbook: now, and as of a timestamp +let ob = client.hyperliquid.hip4.get_orderbook(&symbol, None).await?; +let ob_at = client.hyperliquid.hip4.get_orderbook(&symbol, Some(Hip4OrderBookParams { + timestamp: Some(start.into()), depth: Some(20), })).await?; -let ob_history = client.hyperliquid.hip4.get_orderbook_history("0", Hip4HistoryRange { - start: 1777680000000_i64.into(), - end: 1777766400000_i64.into(), +// History takes a Hip4HistoryRange, or Hip4OrderBookHistoryParams to set depth +let ob_history = client.hyperliquid.hip4.get_orderbook_history(&symbol, Hip4OrderBookHistoryParams { + start: start.into(), + end: end.into(), cursor: None, limit: Some(100), + depth: Some(5), }).await?; -// Trades (history + recent) -let trades = client.hyperliquid.hip4.get_trades("0", Hip4TradesParams { - start: 1777680000000_i64.into(), - end: 1777766400000_i64.into(), +// Trades (history + recent); get_trades_history() is the same call +let trades = client.hyperliquid.hip4.get_trades(&symbol, Hip4TradesParams { + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), + side: None, }).await?; -let recent = client.hyperliquid.hip4.get_trades_recent("0", Some(50)).await?; +let recent = client.hyperliquid.hip4.get_trades_recent(&symbol, Some(50)).await?; // Implied-probability OHLCV candles -let candles = client.hyperliquid.hip4.candles.history("0", CandleHistoryParams { - start: 1777680000000_i64.into(), - end: 1777766400000_i64.into(), +let candles = client.hyperliquid.hip4.candles.history(&symbol, CandleHistoryParams { + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), interval: Some(CandleInterval::OneHour), }).await?; -// Open interest (per-side history + latest) -let oi_hist = client.hyperliquid.hip4.get_open_interest("0", Hip4HistoryRange { - start: 1777680000000_i64.into(), - end: 1777766400000_i64.into(), +// Open interest (per-side history + latest); get_open_interest_history() is the same call +let oi_hist = client.hyperliquid.hip4.get_open_interest(&symbol, Hip4HistoryRange { + start: start.into(), + end: end.into(), cursor: None, limit: None, }).await?; -let oi_now = client.hyperliquid.hip4.get_open_interest_current("0").await?; +let oi_now = client.hyperliquid.hip4.get_open_interest_current(&symbol).await?; // mark_price on HIP-4 is an implied probability in [0, 1]. -// Summary, freshness, prices -let summary = client.hyperliquid.hip4.get_summary("0").await?; -let freshness = client.hyperliquid.hip4.get_freshness("0").await?; -let prices = client.hyperliquid.hip4.get_prices("0", - 1777680000000_i64, 1777766400000_i64, Some("1h"), Some(100), None).await?; +// Summary, freshness, prices (get_price_history() is the same call as get_prices()) +let summary = client.hyperliquid.hip4.get_summary(&symbol).await?; +let freshness = client.hyperliquid.hip4.get_freshness(&symbol).await?; +let prices = client.hyperliquid.hip4.get_prices(&symbol, + start, end, Some("1h"), Some(100), None).await?; // L4 (current snapshot, diffs, checkpoint history) -let l4_now = client.hyperliquid.hip4.get_l4_orderbook("0", None).await?; -let l4_diffs = client.hyperliquid.hip4.get_l4_diffs("0", Hip4HistoryRange { - start: 1777680000000_i64.into(), - end: 1777766400000_i64.into(), +let l4_now = client.hyperliquid.hip4.get_l4_orderbook(&symbol, None).await?; +let l4_diffs = client.hyperliquid.hip4.get_l4_diffs(&symbol, Hip4HistoryRange { + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), }).await?; @@ -945,12 +1151,20 @@ dashed canonical form (`HYPE-USDC`, `PURR-USDC`, ...). The server resolves the dashed form to the wire format (`PURR/USDC`, `@107`) internally. Spot has **no funding, no open interest, and no liquidations**. Trade history -backfills to 2025-03-22. Spot candle history starts exactly at -**2025-03-22T10:50:22Z**, supports `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, -and `1w`, and accepts at most 1,000 rows per page. Orderbook, L4, TWAP, and -freshness data are live-only from 2026-05-05. +is served from **2025-03-22T10:50:22Z**. Spot candle history starts exactly at +**2025-03-22T10:50:00Z**, supports `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, +and `1w`, and accepts at most 1,000 rows per page. The order book, L4, and TWAP have +no history before live capture began: TWAP from 2026-05-05 13:05 UTC, the +order book from 2026-05-05 19:56 UTC, and L4 and order history from +2026-05-05 22:57 UTC. + +`SpotPair` carries the pair's `symbol`, `base` and `quote` tokens, the +Hyperliquid wire-format pair (`wire_symbol`, such as `PURR/USDC` or `@107`), +and its index in the spot universe (`spot_index`). The remaining registry +fields, such as `is_canonical` and the token decimals, are in `extra`. ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::orderbook::{GetOrderBookParams, OrderBookHistoryParams}; use oxarchive::resources::l4_orderbook::{L4DiffsParams, L4HistoryParams, L4OrderBookParams}; use oxarchive::resources::orders::SpotOrderHistoryParams; @@ -962,55 +1176,65 @@ use oxarchive::types::CandleInterval; // Pair discovery (dashed canonical: HYPE-USDC, PURR-USDC, ...). let pairs = client.hyperliquid.spot.pairs.list().await?; let hype = client.hyperliquid.spot.pairs.get("HYPE-USDC").await?; +println!("{:?}/{:?} trades as {:?}", hype.base, hype.quote, hype.wire_symbol); // Current L2 orderbook. let ob = client.hyperliquid.spot.orderbook.get("HYPE-USDC", None).await?; println!("HYPE-USDC mid: {:?}", ob.mid_price); +// The last hour +let end = Utc::now(); +let start = end - Duration::hours(1); + // Historical L2 orderbook. let ob_history = client.hyperliquid.spot.orderbook.history("HYPE-USDC", OrderBookHistoryParams { - start: 1746489600000_i64.into(), // 2026-05-06 UTC - end: 1746576000000_i64.into(), + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), depth: None, granularity: None, }).await?; -// Candles (history starts exactly at 2025-03-22T10:50:22Z; max 1,000 rows). +// One-minute candles (max 1,000 rows per page). let candles = client.hyperliquid.spot.candles.history("HYPE-USDC", CandleHistoryParams { - start: 1742640622000_i64.into(), // 2025-03-22T10:50:22Z - end: 1742644222000_i64.into(), + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), interval: Some(CandleInterval::OneMinute), }).await?; -// Trades (history backfilled to 2025-03-22). -let trades = client.hyperliquid.spot.trades.list("PURR-USDC", GetTradesParams { - start: 1742601600000_i64.into(), // 2025-03-22 UTC - end: 1746576000000_i64.into(), +// Trades. +let trades = client.hyperliquid.spot.trades.history("PURR-USDC", GetTradesParams { + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), + side: None, }).await?; // L4 reconstruction. let l4_now = client.hyperliquid.spot.l4_orderbook.get("HYPE-USDC", None).await?; let l4_diffs = client.hyperliquid.spot.l4_orderbook.diffs("HYPE-USDC", L4DiffsParams { - start: 1746489600000_i64.into(), - end: 1746576000000_i64.into(), + start: (end - Duration::minutes(10)).into(), + end: end.into(), cursor: None, limit: Some(1000), }).await?; let l4_history = client.hyperliquid.spot.l4_orderbook.history("HYPE-USDC", L4HistoryParams { - start: 1746489600000_i64.into(), - end: 1746576000000_i64.into(), + start: start.into(), + end: end.into(), cursor: None, limit: Some(10), }).await?; // Order lifecycle history (Spot serves history only: no flow, TP/SL, or trigger levels). -let orders = client.hyperliquid.spot.orders.history("HYPE-USDC", SpotOrderHistoryParams::default()).await?; +let orders = client.hyperliquid.spot.orders.history("HYPE-USDC", SpotOrderHistoryParams { + start: Some((end - Duration::minutes(10)).into()), + end: Some(end.into()), + ..Default::default() +}).await?; // TWAP statuses by symbol or by user. let twap_sym = client.hyperliquid.spot.twap @@ -1029,14 +1253,13 @@ Robinhood Chain is the second Lighter deployment, served under mainnet `client.lighter` except the L3 order book, which is not captured on this deployment. -- **Markets:** 84 markets quoted in USDG: 57 perps with uppercase symbols - (`BTC`, `ETH`) and 27 spot pairs with dashed symbols (`AAPL-USDG`). List them - with `client.rh_lighter.instruments.list()`. Market ids and symbols are - separate from mainnet, so query each deployment through its own client. -- **Coverage:** trades and liquidations from 2026-06-26 20:10:26 UTC (the - venue launch); order book, open interest, and funding from - 2026-08-22 18:43 UTC; candles from 2026-06-26 once candle history is enabled - for this deployment. +- **Markets:** quoted in USDG: perps with uppercase symbols (`BTC`, `ETH`) and + spot pairs with dashed symbols (`AAPL-USDG`). List them with + `client.rh_lighter.instruments.list()`. Market ids and symbols are separate + from mainnet, so query each deployment through its own client. +- **Coverage:** candles from 2026-06-26 20:10 UTC; trades and liquidations + from 2026-06-26 20:10:26 UTC (the venue launch); order book, open interest, + funding, and price history from 2026-08-22 18:43 UTC. - **Trades:** as on mainnet, `trades.list()` returns final trades up to the finalization boundary (about a day behind), and `trades.recent()` serves the preliminary tier. `trades.list_with_meta()` and @@ -1044,17 +1267,32 @@ this deployment. [Trades](#trades). ```rust +use chrono::{Duration, Utc}; +use oxarchive::resources::candles::CandleHistoryParams; use oxarchive::resources::trades::GetTradesParams; +use oxarchive::types::CandleInterval; let instruments = client.rh_lighter.instruments.list().await?; let book = client.rh_lighter.orderbook.get("BTC", None).await?; let funding = client.rh_lighter.funding.current("BTC").await?; let oi = client.rh_lighter.open_interest.current("BTC").await?; + +// Final trades from the last seven days (the newest day is not final yet) +let end = Utc::now(); +let start = end - Duration::days(7); let trades = client.rh_lighter.trades.list("AAPL-USDG", GetTradesParams { - start: 1788220800000_i64.into(), // 2026-09-01 00:00 UTC - end: 1788307200000_i64.into(), // 2026-09-02 00:00 UTC + start: start.into(), + end: end.into(), cursor: None, limit: Some(1000), + side: None, +}).await?; +let candles = client.rh_lighter.candles.history("BTC", CandleHistoryParams { + start: start.into(), + end: end.into(), + cursor: None, + limit: None, + interval: Some(CandleInterval::OneHour), }).await?; let summary = client.rh_lighter.summary("BTC").await?; let freshness = client.rh_lighter.freshness("BTC").await?; @@ -1089,12 +1327,15 @@ Spot and HIP-4 positions are not included. | `client.lighter.accounts.by_l1(l1_address, cursor, limit)` | Lighter account indices owned by an L1 address (mainnet only) | ```rust +use chrono::{Duration, Utc}; use oxarchive::resources::positions::{ AccountHistoryParams, GetPositionsParams, MarketPositionsParams, MarketSummaryParams, PositionRangeParams, }; let wallet = "0x0000000000000000000000000000000000000001"; +let end = Utc::now(); +let day_ago = end - Duration::days(1); // Current positions, from the latest live snapshot let now = client.hyperliquid.positions.get(wallet, None).await?; @@ -1107,15 +1348,15 @@ if now.meta.stale == Some(true) { // As of an instant: the state after every event before it let then = client.hyperliquid.positions.get(wallet, Some(GetPositionsParams { - timestamp: Some(1790294400000_i64.into()), // 2026-09-25 00:00 UTC + timestamp: Some(day_ago.into()), ..Default::default() })).await?; println!("source={:?} quality={:?}", then.meta.source, then.meta.quality); -// Change log for one market, paged with next_cursor +// Change log for one market over the last day, paged with next_cursor let mut params = PositionRangeParams { symbol: Some("BTC".to_string()), - ..PositionRangeParams::new(1790294400000_i64, 1790380800000_i64) + ..PositionRangeParams::new(day_ago, end) }; loop { let page = client.hyperliquid.positions.changes(wallet, params.clone()).await?; @@ -1157,7 +1398,7 @@ let rh_summary = client.rh_lighter.positions // Lighter account summary (position aggregates): now, and one row per hour let lighter_now = client.lighter.positions.account(4521).await?; let rh_hourly = client.rh_lighter.positions - .account_history(4521, AccountHistoryParams::new("2026-09-01", "2026-09-02")).await?; + .account_history(4521, AccountHistoryParams::new(day_ago, end)).await?; ``` Semantics: @@ -1214,15 +1455,18 @@ reaches `total`. The snapshot date defaults to yesterday (UTC). Available on `client.hyperliquid.wallets` and `client.hyperliquid.hip3.wallets`. ```rust +use chrono::{Duration, Utc}; use oxarchive::WalletClassifyParams; +// Two days ago (the default, yesterday, is the latest snapshot) +let date = Utc::now().date_naive() - Duration::days(2); let page = client.hyperliquid.wallets.classify(WalletClassifyParams { min_orders: Some(1000), sort: Some("total_volume_usd".to_string()), order: Some("desc".to_string()), limit: Some(100), uses_twap: Some(true), - date: chrono::NaiveDate::from_ymd_opt(2026, 9, 28), + date: Some(date), ..Default::default() }).await?; println!("{} wallets match on {}", page.total, page.date); @@ -1244,19 +1488,19 @@ Check when each data type was last updated for a specific coin. let freshness = client.hyperliquid.freshness("BTC").await?; let lighter_freshness = client.lighter.freshness("BTC").await?; let rh_freshness = client.rh_lighter.freshness("BTC").await?; -let hip3_freshness = client.hyperliquid.hip3.freshness("km:US500").await?; +let hip3_freshness = client.hyperliquid.hip3.freshness("xyz:XYZ100").await?; let spot_freshness = client.hyperliquid.spot.freshness("HYPE-USDC").await?; ``` ### Summary -Combined market snapshot — mark/oracle price, funding rate, open interest, 24h volume, and liquidation volumes. +Combined market snapshot: mark/oracle price, funding rate, open interest, 24h volume, and liquidation volumes. ```rust let summary = client.hyperliquid.summary("BTC").await?; let lighter_summary = client.lighter.summary("BTC").await?; let rh_summary = client.rh_lighter.summary("BTC").await?; -let hip3_summary = client.hyperliquid.hip3.summary("km:US500").await?; +let hip3_summary = client.hyperliquid.hip3.summary("xyz:XYZ100").await?; ``` ### Price History @@ -1264,10 +1508,13 @@ let hip3_summary = client.hyperliquid.hip3.summary("km:US500").await?; Mark, oracle, and mid price history. Supports aggregation intervals. ```rust +// The last 24 hours, in Unix milliseconds +let end = chrono::Utc::now().timestamp_millis(); +let start = end - 24 * 60 * 60 * 1000; let prices = client.hyperliquid.price_history( "BTC", - 1704067200000_i64, // start - 1704153600000_i64, // end + start, + end, Some("1h"), // interval Some(100), // limit None, // cursor @@ -1278,38 +1525,44 @@ let prices = client.hyperliquid.price_history( Taker buy and sell notional per bucket, their difference (`delta`), and a running total (`cumulative_delta`), for Hyperliquid core and HIP-3 symbols. +Each bucket's `timestamp` is its open time as an RFC 3339 UTC string, with +`timestamp_ms` in Unix milliseconds. Intervals are `1m`, `5m`, `15m`, `30m`, `1h` (the default), `4h`, `1d`, and `1w`. Buckets are labelled by their open time in UTC and omitted when they hold no trades; `4h`, `1d`, and `1w` buckets open on UTC epoch boundaries, so `1w` buckets open on Thursdays. A page holds up to `limit` buckets (default 500, max 10,000). Follow -`next_cursor` with the same `start`, `end`, and `interval` until it is `None`; -below `1h` a page can be short and still carry a cursor. `cumulative_delta` +`next_cursor` with the same `start`, `end`, and `interval` while `has_more` +is `true`; below `1h` a page can be short and still have more. `cumulative_delta` restarts on every page, so rebuild it from `delta` when joining pages, and `meta.notice` says when a response is one page of several. Without `start` or `cursor`, the response is the newest `limit` buckets of the 24 hours before `end`. ```rust +use chrono::{Duration, Utc}; use oxarchive::CvdParams; use oxarchive::types::CandleInterval; +// The last 24 hours at five-minute buckets +let end = Utc::now(); +let start = end - Duration::days(1); let mut buckets = Vec::new(); let mut cursor = None; loop { let page = client.hyperliquid.cvd("BTC", CvdParams { - start: Some(1790553600000_i64.into()), // 2026-09-28 00:00 UTC - end: Some(1790640000000_i64.into()), // 2026-09-29 00:00 UTC + start: Some(start.into()), + end: Some(end.into()), interval: Some(CandleInterval::FiveMinutes), cursor, limit: None, }).await?; buckets.extend(page.data); - cursor = page.next_cursor; - if cursor.is_none() { + if !page.has_more { break; } + cursor = page.next_cursor; } // Rebuild the running total across pages from `delta`. let mut running = 0.0; @@ -1331,17 +1584,19 @@ Monitor data coverage, incidents, latency, and SLA compliance. let status = client.data_quality.status().await?; println!("System: {}", status.status); -// Data coverage +// Data coverage (status_coverage() is the public summary the status page shows) let coverage = client.data_quality.coverage().await?; +let public_coverage = client.data_quality.status_coverage().await?; // Symbol-specific coverage with gap detection let btc = client.data_quality.symbol_coverage("hyperliquid", "BTC").await?; -// Symbols keep their case and are percent-encoded (`km:US500`, `HYPE-USDC`, `#0`); +// Symbols keep their case and are percent-encoded (`xyz:XYZ100`, `HYPE-USDC`, `#0`); // choose the gap-detection window with symbol_coverage_with(). use oxarchive::resources::data_quality::{ListIncidentsParams, SymbolCoverageParams}; -let us500 = client.data_quality.symbol_coverage_with("hip3", "km:US500", SymbolCoverageParams { - from: Some(1788000000000_i64.into()), - to: Some(1788086400000_i64.into()), +let now = chrono::Utc::now(); +let xyz100 = client.data_quality.symbol_coverage_with("hip3", "xyz:XYZ100", SymbolCoverageParams { + from: Some((now - chrono::Duration::days(7)).into()), + to: Some(now.into()), }).await?; // Incidents @@ -1588,12 +1843,14 @@ let sub = client.web3.subscribe("build", "base64_payment_payload").await?; ## WebSocket Client Requires the `websocket` feature. Supports two modes on a single connection: -- **Live subscriptions**: supported Hyperliquid and Lighter.xyz live market channels, on both Lighter deployments +- **Live subscriptions**: supported Hyperliquid and Lighter live market channels, on both Lighter deployments - **Replay**: bounded historical data with timing preserved For large historical downloads, use the S3 Parquet bulk export in the [Data Catalog](https://www.0xarchive.io/data). Bulk streaming over WebSocket has been discontinued, and the deprecated `stream()` and `stream_stop()` methods now receive an error from the server. -> Lighter.xyz live subscriptions are available for `lighter_orderbook`, `lighter_trades`, `lighter_open_interest`, and `lighter_funding` on mainnet, and for `rh_lighter_orderbook`, `rh_lighter_trades`, `rh_lighter_open_interest`, and `rh_lighter_funding` on Robinhood Chain. `lighter_candles`, `lighter_l3_orderbook`, and `rh_lighter_candles` remain replay-only. Every Lighter channel on both deployments supports historical replay. +> Lighter live subscriptions are available for `lighter_orderbook`, `lighter_trades`, `lighter_open_interest`, and `lighter_funding` on mainnet, and for `rh_lighter_orderbook`, `rh_lighter_trades`, `rh_lighter_open_interest`, and `rh_lighter_funding` on Robinhood Chain. `lighter_candles`, `lighter_l3_orderbook`, and `rh_lighter_candles` remain replay-only. Every Lighter channel on both deployments supports historical replay. + +The client connects with `version=2026-10-01`. Under that version, error messages carry an `error_code` (see [Error Handling](#error-handling)), and Lighter replay rows have the same shapes as the live payloads. `client.capabilities()` lists which channels stream live and which replay. ### Real-time Streaming @@ -1612,7 +1869,7 @@ while let Some(msg) = rx.recv().await { ServerMsg::Data { channel, coin, data, .. } => { println!("{channel} {} update: {}", coin.unwrap_or_default(), data); } - ServerMsg::Error { message } => eprintln!("Error: {message}"), + ServerMsg::Error { message, error_code } => eprintln!("Error ({error_code:?}): {message}"), _ => {} } } @@ -1670,9 +1927,10 @@ while let Some(msg) = rx.recv().await { } Some(Err(e)) => eprintln!("Unexpected Lighter payload: {e}"), None => { - if let ServerMsg::Error { message } = msg { - // Lag notices do not always end the subscription (see below). - eprintln!("{message}"); + if let ServerMsg::Error { message, error_code } = msg { + // Lag notices carry `slow_consumer` and do not always end the + // subscription (see below). + eprintln!("{error_code:?}: {message}"); } } } @@ -1692,8 +1950,9 @@ including fields the live stream does not carry such as fees, is served by reconciled trades only; `client.lighter.trades.recent(...)` serves the preliminary tier. If your connection falls behind `lighter_trades`, `lighter_open_interest`, or -`lighter_funding`, the server sends an `error` notice such as -`Dropped ~N live lighter_trades messages for BTC: ...` and the subscription continues. +`lighter_funding`, the server sends an `error` notice with `error_code` +`slow_consumer`, such as +`Dropped ~N live lighter_trades messages for BTC: ...`, and the subscription continues. If the lag persists, a notice such as `Stopped the lighter_trades stream for BTC: ...` ends that subscription; subscribe again to resume. `lighter_orderbook` never sends an older book in place of a newer one. @@ -1703,21 +1962,45 @@ older book in place of a newer one. Replay a bounded historical window with original timing preserved. Every replay ends with a `replay_completed` server message. +Lighter order book, trades, open interest, and funding replay rows have the +live shapes and decode with `msg.lighter_live_data()`, like live data. A +replayed trades row is a single fill leg. + +Every L4 channel (`l4_diffs`, `l4_orders` and their `hip3_`, `spot_` and +`hip4_` counterparts) and both full-depth order book channels +(`orderbook_full`, `hip3_orderbook_full`) replay in bulk: the book at the +nearest L4 checkpoint at or before `start` arrives as an `l4_snapshot` frame +(`ServerMsg::L4Snapshot`), then the events follow as ordered `l4_batch` frames +(`ServerMsg::L4Batch`) as fast as the server sends them. `speed` is ignored +(pass `None`), `replay.seek` is refused, and these channels replay one at a time: +`replay_multi()` rejects them before sending. L4 replay starts at +2026-03-11 01:03 UTC for Hyperliquid, HIP-3 and Spot, and at +2026-05-02 07:47 UTC for HIP-4. + +A replay request on a channel without replay (`ticker`, `all_tickers`, and the +Spot `spot_orderbook` and `spot_trades` channels) is answered with an error +whose `error_code` is `unsupported_for_venue`. + ```rust use oxarchive::ws::{OxArchiveWs, ServerMsg, WsOptions}; +use oxarchive::LighterLiveData; let mut ws = OxArchiveWs::new(WsOptions::new("your-api-key")); ws.connect().await?; let mut rx = ws.rx.take().expect("receiver"); -// All six Lighter channels support replay. Replay rows keep their stored -// shapes, which differ from the live Lighter payloads above. +// The hour that ended ten minutes ago, in Unix milliseconds +let end = chrono::Utc::now().timestamp_millis() - 10 * 60 * 1000; +let start = end - 60 * 60 * 1000; + +// All six Lighter channels support replay. Book, trades, open interest and +// funding rows have the live shapes shown above. ws.replay( "lighter_orderbook", "BTC", - 1788048000000, // 2026-08-30 00:00 UTC - Some(1788051600000), // 2026-08-30 01:00 UTC - Some(100.0), + start, + Some(end), + Some(10.0), // 10x; the fastest speed depends on the plan (see Tier Limits) ).await?; while let Some(msg) = rx.recv().await { @@ -1726,8 +2009,10 @@ while let Some(msg) = rx.recv().await { println!("Replay complete: {channel}, {snapshots_sent:?} records"); break; } - ServerMsg::HistoricalData { channel, .. } => { - println!("Historical {channel} record"); + ServerMsg::HistoricalData { ref channel, .. } => { + if let Some(Ok(LighterLiveData::OrderBook(book))) = msg.lighter_live_data() { + println!("{channel}: {} bids, {} asks", book.bids().len(), book.asks().len()); + } } _ => {} } @@ -1736,7 +2021,7 @@ while let Some(msg) = rx.recv().await { // Control playback for the active bounded replay ws.replay_pause().await?; ws.replay_resume().await?; -ws.replay_seek(1788049800000).await?; +ws.replay_seek(start + 30 * 60 * 1000).await?; ws.replay_stop().await?; ``` @@ -1752,45 +2037,44 @@ ws.replay_stop().await?; | `funding` | Funding rate snapshots | Yes | Yes | | `ticker` | Price and 24h volume | Yes | No | | `all_tickers` | All market tickers | Yes | No | -| `orderbook_full` | Hyperliquid core full-depth L2 order book, aggregated from order-level data (every price level, no user attribution): an `l4_snapshot` frame (`ServerMsg::L4Snapshot`) with the whole book, then `l4_batch` frames (`ServerMsg::L4Batch`) of price-level changes (`side`, `px`, `sz`, `n`, `bn`; `sz` and `n` are `0` when a level is removed). For full-depth history, use the `l2_orderbook` REST resource. | Yes | No, live-only | -| `lighter_orderbook` | Lighter.xyz L2 order book | Yes | Yes | -| `lighter_trades` | Lighter.xyz trades | Yes | Yes | -| `lighter_candles` | Lighter.xyz candles | No | Yes | -| `lighter_open_interest` | Lighter.xyz open interest | Yes | Yes | -| `lighter_funding` | Lighter.xyz funding rates | Yes | Yes | -| `lighter_l3_orderbook` | Lighter.xyz L3 order-level orderbook | No | Yes | +| `orderbook_full` | Hyperliquid core full-depth L2 order book, aggregated from order-level data (every price level, no user attribution): an `l4_snapshot` frame (`ServerMsg::L4Snapshot`) with the whole book, then `l4_batch` frames (`ServerMsg::L4Batch`) of price-level changes (`side`, `px`, `sz`, `n`, `bn`; `sz` and `n` are `0` when a level is removed). | Yes | Yes, bulk and single-channel, from 2026-03-11 01:03 UTC | +| `lighter_orderbook` | Lighter L2 order book | Yes | Yes | +| `lighter_trades` | Lighter trades | Yes | Yes | +| `lighter_candles` | Lighter candles | No | Yes | +| `lighter_open_interest` | Lighter open interest | Yes | Yes | +| `lighter_funding` | Lighter funding rates | Yes | Yes | +| `lighter_l3_orderbook` | Lighter L3 order-level orderbook | No | Yes | | `rh_lighter_orderbook` | Lighter on Robinhood Chain L2 order book | Yes | Yes, from 2026-08-22 18:43 UTC | -| `rh_lighter_trades` | Lighter on Robinhood Chain trades | Yes | Yes, from 2026-06-26 | -| `rh_lighter_candles` | Lighter on Robinhood Chain candles | No | Yes, from 2026-06-26 once candle history is enabled | +| `rh_lighter_trades` | Lighter on Robinhood Chain trades | Yes | Yes, from 2026-06-26 20:10:26 UTC | +| `rh_lighter_candles` | Lighter on Robinhood Chain candles | No | Yes, from 2026-06-26 20:10 UTC | | `rh_lighter_open_interest` | Lighter on Robinhood Chain open interest | Yes | Yes, from 2026-08-22 18:43 UTC | | `rh_lighter_funding` | Lighter on Robinhood Chain funding rates | Yes | Yes, from 2026-08-22 18:43 UTC | | `hip3_orderbook` | HIP-3 L2 order book | Yes | Yes | | `hip3_trades` | HIP-3 trades | Yes | Yes | -| `hip3_candles` | HIP-3 candles | Yes | Yes | -| `hip3_open_interest` | HIP-3 open interest | No | Yes | -| `hip3_funding` | HIP-3 funding rates | No | Yes | +| `hip3_candles` | HIP-3 candles | No | Yes | +| `hip3_open_interest` | HIP-3 open interest | Yes | Yes | +| `hip3_funding` | HIP-3 funding rates | Yes | Yes | | `hip3_liquidations` | HIP-3 liquidation events. Same wire shape as `liquidations`. | Yes | Yes | -| `hip3_orderbook_full` | HIP-3 full-depth L2 order book. Same frames as `orderbook_full`. | Yes | No, live-only | +| `hip3_orderbook_full` | HIP-3 full-depth L2 order book. Same frames as `orderbook_full`. | Yes | Yes, bulk and single-channel | | `hip4_orderbook` | HIP-4 outcome-market L2 order book | No | Yes | | `hip4_trades` | HIP-4 trade/fill updates | Yes | Yes | | `hip4_open_interest` | HIP-4 open interest snapshots | No | Yes | -| `l4_diffs` | Hyperliquid core L4 orderbook diffs with user attribution | Yes | Yes, `l4_snapshot` then ordered `l4_batch` | -| `l4_orders` | Hyperliquid core L4 order lifecycle events | Yes | Yes, `l4_snapshot` then ordered `l4_batch` | -| `hip3_l4_diffs` | HIP-3 L4 orderbook diffs with user attribution | Yes | No, live-only | -| `hip3_l4_orders` | HIP-3 order lifecycle events | Yes | No, live-only | -| `hip4_l4_diffs` | HIP-4 L4 orderbook diffs with user attribution | Yes | No, live-only | -| `hip4_l4_orders` | HIP-4 order lifecycle events | Yes | No, live-only | +| `l4_diffs` | Hyperliquid core L4 orderbook diffs with user attribution | Yes | Yes, `l4_snapshot` then ordered `l4_batch`; bulk and single-channel | +| `l4_orders` | Hyperliquid core L4 order lifecycle events | Yes | Yes, `l4_snapshot` then ordered `l4_batch`; bulk and single-channel | +| `hip3_l4_diffs` | HIP-3 L4 orderbook diffs with user attribution | Yes | Yes, as `l4_diffs` | +| `hip3_l4_orders` | HIP-3 order lifecycle events | Yes | Yes, as `l4_orders` | +| `hip4_l4_diffs` | HIP-4 L4 orderbook diffs with user attribution | Yes | Yes, as `l4_diffs`, from 2026-05-02 07:47 UTC | +| `hip4_l4_orders` | HIP-4 order lifecycle events | Yes | Yes, as `l4_orders`, from 2026-05-02 07:47 UTC | | `spot_orderbook` | Hyperliquid Spot L2 order book | Yes | No | | `spot_trades` | Hyperliquid Spot trades | Yes | No | -| `spot_l4_diffs` | Hyperliquid Spot L4 orderbook diffs with user attribution | Yes | No, live-only | -| `spot_l4_orders` | Hyperliquid Spot order lifecycle events | Yes | No, live-only | -| `spot_twap` | Hyperliquid Spot TWAP execution updates | Yes | No | +| `spot_l4_diffs` | Hyperliquid Spot L4 orderbook diffs with user attribution | Yes | Yes, as `l4_diffs` | +| `spot_l4_orders` | Hyperliquid Spot order lifecycle events | Yes | Yes, as `l4_orders` | Current Lighter order books, trades, open interest, and funding are available as live subscriptions and through the Lighter REST resources; current Lighter candles and L3 order books are available through REST. Historical Lighter data is available through REST, WebSocket replay, or exports. The same holds for the Robinhood Chain deployment (`rh_lighter_*` channels, `client.rh_lighter`), which has no L3. A multi-channel replay cannot mix `lighter_*` and `rh_lighter_*` channels. -HIP-4 has no funding or liquidation channels. HIP-4 candles and current outcome-side OI are available over REST from 2026-05-02; 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. +HIP-4 has no funding or liquidation channels and no candle channel. `hip4_orderbook` and `hip4_open_interest` replay stored history from 2026-05-02 16:51 UTC but have no live subscription; read the current HIP-4 order book and outcome-side OI over REST. HIP-4 candles are served over REST from 2026-05-02 08:00 UTC. -Hyperliquid Spot has no funding, open-interest, or liquidation resources. Spot candle history is REST-served from 2025-03-22T10:50:22Z and has no dedicated WebSocket channel. Spot symbols use the dashed canonical form (`HYPE-USDC`, `PURR-USDC`). +Hyperliquid Spot has no funding, open-interest, or liquidation resources. Spot candles (from 2025-03-22 10:50 UTC) and Spot TWAP statuses (from 2026-05-05 13:05 UTC) are served over REST only, with no WebSocket channel. Spot symbols use the dashed canonical form (`HYPE-USDC`, `PURR-USDC`). ### Settlement Frame @@ -1819,7 +2103,7 @@ All self-serve tiers reach the published route families; Free covers the most re | Build | 500 | 3 | 50x | | Pro | 3,000 | 5 | 100x | | Scale | 20,000 | 16 | 300x | -| Enterprise | Custom | Custom | from 500x | +| Enterprise | Custom | Custom | 1000x | On every tier, each WebSocket connection accepts at most 10 subscribe operations per second; faster bursts are rejected with a "Subscription rate limit exceeded" error. @@ -1827,36 +2111,62 @@ faster bursts are rejected with a "Subscription rate limit exceeded" error. ## Timestamp Formats All time parameters accept the `Timestamp` enum, sent as Unix milliseconds. A -time without a time zone is UTC: `"2024-01-01"` is midnight UTC, -`"2024-01-01T12:00:00"` is noon UTC, and `chrono::NaiveDateTime` and +time without a time zone is UTC: `"2026-10-01"` is midnight UTC, +`"2026-10-01T12:00:00"` is noon UTC, and `chrono::NaiveDateTime` and `chrono::NaiveDate` convert as UTC. A string with an offset (`Z`, `+02:00`) keeps it. +Response times are RFC 3339 UTC strings. Where a response also gives the +instant as an integer, that field ends in `_ms` (`timestamp_ms`, +`snapshot_ts_ms`) and holds Unix milliseconds. + ```rust use oxarchive::types::Timestamp; -// Unix milliseconds (i64) -let ts: Timestamp = 1704067200000_i64.into(); +// Unix milliseconds (i64): 2026-10-01 00:00 UTC +let ts: Timestamp = 1790812800000_i64.into(); // ISO 8601 string: with an offset, or without one (UTC) -let ts: Timestamp = "2024-01-01T00:00:00Z".into(); -let ts: Timestamp = "2024-01-01T12:00:00".into(); -let ts: Timestamp = "2024-01-01".into(); +let ts: Timestamp = "2026-10-01T00:00:00Z".into(); +let ts: Timestamp = "2026-10-01T12:00:00".into(); +let ts: Timestamp = "2026-10-01".into(); // chrono::DateTime, or a naive date or date-time (UTC) let ts: Timestamp = chrono::Utc::now().into(); -let ts: Timestamp = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().into(); +let ts: Timestamp = chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap().into(); ``` ## Error Handling +An API error is `Error::Api`, with the HTTP status (`code`), a stable +`error_code`, the `request_id` to quote to support and, when the error is +about one parameter, `param` and the `valid_values` it accepts. Branch on +`error_code`; `message` is for people and its wording can change. +`ErrorCode` names every documented code and keeps any other as +`ErrorCode::Other`: + +| `error_code` | Meaning | +|--------------|---------| +| `invalid_parameter`, `invalid_symbol`, `invalid_interval`, `invalid_cursor`, `invalid_time_range` | The request is malformed. | +| `range_before_coverage` | The whole range is before the dataset's coverage begins. | +| `historical_range_exceeded`, `historical_depth_exceeded` | The span or the history window is beyond the plan's limit. | +| `unsupported_for_venue` | The venue does not offer this datatype, channel or mode; the message names where it is offered. | +| `route_not_found`, `not_found` | Unknown path, or unknown resource id. | +| `unauthorized`, `forbidden`, `insufficient_credits` | Credentials, access or credits. | +| `rate_limited`, `upstream_unavailable`, `internal_error` | Retry later. | +| `conflict` | The request conflicts with current state. | +| `slow_consumer`, `endpoint_unsupported` | WebSocket only: the connection fell behind and messages were dropped (subscribe again or restart the replay), or this endpoint does not serve the channel. | + ```rust -use oxarchive::Error; +use oxarchive::{Error, ErrorCode}; match client.hyperliquid.orderbook.get("BTC", None).await { Ok(ob) => println!("Mid price: {:?}", ob.mid_price), - Err(Error::Api { message, code, .. }) => { - eprintln!("API error ({code}): {message}"); + Err(Error::Api { error_code: Some(ErrorCode::RateLimited), .. }) => { + // back off and retry + } + Err(Error::Api { message, code, error_code, param, valid_values, request_id }) => { + eprintln!("API error {code} {error_code:?}: {message} (param {param:?}, valid {valid_values:?}, request {request_id:?})"); } Err(Error::Timeout) => eprintln!("Request timed out"), Err(Error::Http(e)) => eprintln!("HTTP error: {e}"), @@ -1864,6 +2174,10 @@ match client.hyperliquid.orderbook.get("BTC", None).await { } ``` +`Error` also has `error_code()`, `status()`, `request_id()`, `param()` and +`valid_values()` accessors. WebSocket `ServerMsg::Error` messages carry the +same `error_code`. + ## Examples Run the included examples: @@ -1880,6 +2194,9 @@ cargo run --example pagination # Hyperliquid Spot cargo run --example spot +# Liquidation levels, trigger levels, candles, and liquidations by user +cargo run --example levels_smoke + # WebSocket (requires websocket feature) cargo run --example websocket --features websocket ``` diff --git a/examples/basic.rs b/examples/basic.rs index 31c7518..f3dfc19 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -29,7 +29,7 @@ async fn main() -> oxarchive::Result<()> { let oi = client.hyperliquid.open_interest.current("BTC").await?; println!("BTC open interest: {}", oi.open_interest); - // Lighter.xyz + // Lighter let lighter = client.lighter.instruments.list().await?; println!("\nLighter: {} instruments", lighter.len()); diff --git a/examples/levels_smoke.rs b/examples/levels_smoke.rs index 79db6b0..2f6b611 100644 --- a/examples/levels_smoke.rs +++ b/examples/levels_smoke.rs @@ -1,4 +1,7 @@ -//! Quick live smoke test for the price-level, candle and coverage endpoints. +//! Price levels and liquidations: hourly candles, projected liquidation +//! levels and their history, HIP-3 trigger levels, and the liquidations of +//! one wallet. +//! //! Run: OXARCHIVE_API_KEY=... cargo run --example levels_smoke use oxarchive::resources::candles::CandleHistoryParams; @@ -6,20 +9,23 @@ use oxarchive::resources::liquidations::LiquidationsByUserParams; use oxarchive::types::{CandleInterval, Timestamp}; use oxarchive::{LevelsHistoryParams, LiquidationLevelsParams, OxArchive, TriggerLevelsParams}; +const HOUR_MS: i64 = 3_600_000; +const DAY_MS: i64 = 24 * HOUR_MS; + #[tokio::main] async fn main() -> Result<(), Box> { let key = std::env::var("OXARCHIVE_API_KEY")?; let client = OxArchive::new(&key)?; let now = chrono::Utc::now().timestamp_millis(); - // C13 regression: candles previously failed on every call (String vs number) + // Hourly BTC candles for the last six hours. let candles = client .hyperliquid .candles .history( "BTC", CandleHistoryParams { - start: Timestamp::Millis(now - 6 * 3_600_000), + start: Timestamp::Millis(now - 6 * HOUR_MS), end: Timestamp::Millis(now), cursor: None, limit: Some(5), @@ -27,22 +33,26 @@ async fn main() -> Result<(), Box> { }, ) .await?; - println!("candles: {} rows, first close {}", candles.data.len(), candles.data[0].close); + println!( + "candles: {} rows, first close {:?}", + candles.data.len(), + candles.data.first().map(|c| &c.close) + ); - // New: liquidation levels + // Projected liquidation levels, grouped into 12 price buckets. let liq = client .hyperliquid .liquidations .levels("BTC", LiquidationLevelsParams { buckets: Some(12), ..Default::default() }) .await?; println!( - "liq levels: snapshot {} buckets {} total_long {:.0}", + "liquidation levels: snapshot {}, {} buckets, total long {:.0}", liq.snapshot_ts, liq.levels.len(), liq.total_long ); - // New: liquidation levels history (summary) + // Liquidation level history in summary form (bucket detail left out). let hist = client .hyperliquid .liquidations @@ -52,29 +62,29 @@ async fn main() -> Result<(), Box> { ) .await?; println!( - "liq history: {} items, cursor {:?}, levels omitted: {}", + "liquidation level history: {} snapshots, next cursor {:?}, levels omitted: {}", hist.data.len(), hist.next_cursor, - hist.data[0].levels.is_none() + hist.data.iter().all(|item| item.levels.is_none()) ); - // New: hip3 trigger levels + // Pending HIP-3 trigger orders (stop-loss and take-profit), by price bucket. let trig = client .hyperliquid .hip3 .orders .trigger_levels("xyz:TSLA", TriggerLevelsParams { buckets: Some(10), ..Default::default() }) .await?; - println!("hip3 trigger: as_of {} buckets {}", trig.as_of, trig.levels.len()); + println!("HIP-3 trigger levels: as of {}, {} buckets", trig.as_of, trig.levels.len()); - // C12 regression: by_user previously hit the wrong path + // Liquidations of one wallet over the last seven days. let by_user = client .hyperliquid .liquidations .by_user( "0x32fe14732b5b54dc08c6eee46b9ba319ca38e9f4", LiquidationsByUserParams { - start: Timestamp::Millis(now - 200 * 86_400_000), + start: Timestamp::Millis(now - 7 * DAY_MS), end: Timestamp::Millis(now), coin: None, cursor: None, @@ -82,7 +92,7 @@ async fn main() -> Result<(), Box> { }, ) .await?; - println!("by_user: {} rows", by_user.data.len()); + println!("liquidations by user: {} rows", by_user.data.len()); Ok(()) } diff --git a/examples/pagination.rs b/examples/pagination.rs index 8f9292e..c9cfdf7 100644 --- a/examples/pagination.rs +++ b/examples/pagination.rs @@ -6,7 +6,9 @@ async fn main() -> oxarchive::Result<()> { let api_key = std::env::var("OXARCHIVE_API_KEY").expect("Set OXARCHIVE_API_KEY"); let client = OxArchive::new(api_key)?; - // Paginate through BTC trades for a 24-hour window + // Paginate through BTC trades for the last ten minutes + let end = chrono::Utc::now().timestamp_millis(); + let start = end - 10 * 60 * 1000; let mut all_trades = vec![]; let mut cursor = None; let mut page = 0u32; @@ -15,13 +17,14 @@ async fn main() -> oxarchive::Result<()> { let result = client .hyperliquid .trades - .list( + .history( "BTC", GetTradesParams { - start: 1704067200000_i64.into(), // 2024-01-01 00:00 UTC - end: 1704153600000_i64.into(), // 2024-01-02 00:00 UTC + start: start.into(), + end: end.into(), cursor, limit: Some(1000), + side: None, }, ) .await?; @@ -31,13 +34,14 @@ async fn main() -> oxarchive::Result<()> { all_trades.extend(result.data); println!("Page {page}: fetched {count} trades (total: {})", all_trades.len()); - cursor = result.next_cursor; - if cursor.is_none() { + // Follow next_cursor, with the same filters, while has_more is true. + if !result.has_more { break; } + cursor = result.next_cursor; } - println!("\nTotal BTC trades in 24h window: {}", all_trades.len()); + println!("\nTotal BTC trades in the last ten minutes: {}", all_trades.len()); Ok(()) } diff --git a/examples/spot.rs b/examples/spot.rs index 50ce3ea..8eb4d20 100644 --- a/examples/spot.rs +++ b/examples/spot.rs @@ -13,12 +13,18 @@ async fn main() -> oxarchive::Result<()> { let pairs = client.hyperliquid.spot.pairs.list().await?; println!("Spot: {} pairs", pairs.len()); for p in pairs.iter().take(5) { - println!(" {} (mark={:?}, mid={:?})", p.symbol, p.mark_price, p.mid_price); + println!( + " {}: base={:?}, quote={:?}, wire={:?}", + p.symbol, p.base, p.quote, p.wire_symbol + ); } // Get a single pair detail. let hype = client.hyperliquid.spot.pairs.get("HYPE-USDC").await?; - println!("\nHYPE-USDC: base={:?}, quote={:?}", hype.base, hype.quote); + println!( + "\nHYPE-USDC: base={:?}, quote={:?}, wire={:?}, index={:?}", + hype.base, hype.quote, hype.wire_symbol, hype.spot_index + ); // Current L2 orderbook for HYPE-USDC. let ob = client.hyperliquid.spot.orderbook.get("HYPE-USDC", None).await?; @@ -29,7 +35,8 @@ async fn main() -> oxarchive::Result<()> { ob.mid_price ); - // Recent trades (last 24h). Spot fills backfill to 2025-03-22. + // Trades from the last 24 hours. Spot trade history is served from + // 2025-03-22T10:50:22Z. let now_ms = chrono::Utc::now().timestamp_millis(); let day_ago_ms = now_ms - 24 * 60 * 60 * 1000; let trades = client @@ -43,12 +50,15 @@ async fn main() -> oxarchive::Result<()> { end: now_ms.into(), cursor: None, limit: Some(100), + side: None, }, ) .await?; println!("\nPURR-USDC trades (last 24h): {}", trades.data.len()); - // Candle history starts exactly at 2025-03-22T10:50:22Z; max 1,000 rows. + // One-minute candles for the last hour. Spot candle history is served + // from 2025-03-22T10:50:00Z; a page holds at most 1,000 rows. + let hour_ago_ms = now_ms - 60 * 60 * 1000; let candles = client .hyperliquid .spot @@ -56,15 +66,15 @@ async fn main() -> oxarchive::Result<()> { .history( "HYPE-USDC", CandleHistoryParams { - start: 1742640622000_i64.into(), - end: 1742644222000_i64.into(), + start: hour_ago_ms.into(), + end: now_ms.into(), cursor: None, limit: Some(1000), interval: Some(CandleInterval::OneMinute), }, ) .await?; - println!("HYPE-USDC candles: {} rows", candles.data.len()); + println!("HYPE-USDC candles (last hour): {} rows", candles.data.len()); // TWAP statuses for a symbol. let twap = client diff --git a/examples/websocket.rs b/examples/websocket.rs index ea46ebf..9769e2c 100644 --- a/examples/websocket.rs +++ b/examples/websocket.rs @@ -1,11 +1,11 @@ use oxarchive::ws::{OxArchiveWs, ServerMsg, WsOptions}; -use oxarchive::LighterLiveData; +use oxarchive::{ErrorCode, LighterLiveData}; #[tokio::main] async fn main() -> oxarchive::Result<()> { let api_key = std::env::var("OXARCHIVE_API_KEY").expect("Set OXARCHIVE_API_KEY"); - // --- Real-time streaming (orderbook + trades + liquidations + HIP-4 trades) --- + // --- Real-time streaming (order book, trades and liquidations) --- let mut ws = OxArchiveWs::new(WsOptions::new(&api_key)); ws.connect().await?; @@ -14,10 +14,6 @@ async fn main() -> oxarchive::Result<()> { // Liquidations now stream live (same wire shape as trades, with // `is_liquidation: true` on each fill row). ws.subscribe("liquidations", Some("BTC")).await?; - // HIP-4 outcome markets use the bare `#` symbol form. The orderbook - // and open-interest bridges are paused for live delivery; use replay for - // those stored channels. - ws.subscribe("hip4_trades", Some("#0")).await?; let mut rx = ws.rx.take().expect("receiver"); let mut count = 0u32; @@ -37,17 +33,11 @@ async fn main() -> oxarchive::Result<()> { break; } } - ServerMsg::OutcomeSettled { - coin, - settlement_value, - .. + ServerMsg::Error { + message, + error_code, } => { - println!("HIP-4 settled: {coin} -> {:?}", settlement_value); - // Server has already auto-unsubscribed our hip4_* subs for - // this coin. Treat as terminal for the coin. - } - ServerMsg::Error { message } => { - eprintln!("Error: {message}"); + eprintln!("Error ({error_code:?}): {message}"); break; } ServerMsg::Pong => println!("pong"), @@ -58,10 +48,9 @@ async fn main() -> oxarchive::Result<()> { ws.unsubscribe("orderbook", Some("BTC")).await?; ws.unsubscribe("trades", Some("ETH")).await?; ws.unsubscribe("liquidations", Some("BTC")).await?; - ws.unsubscribe("hip4_trades", Some("#0")).await?; ws.disconnect().await; - // --- Lighter.xyz live streaming (order book, trades, funding) --- + // --- Lighter live streaming (order book, trades, funding) --- // Served on the default wss://api.0xarchive.io/ws endpoint. // lighter_candles and lighter_l3_orderbook remain replay-only. let mut ws = OxArchiveWs::new(WsOptions::new(&api_key)); @@ -100,10 +89,19 @@ async fn main() -> oxarchive::Result<()> { } Some(Err(e)) => eprintln!("Unexpected Lighter payload: {e}"), None => { - if let ServerMsg::Error { message } = &msg { - // Lag notices ("Dropped ...") keep the subscription open; - // a "Stopped ..." notice ends it until you subscribe again. - eprintln!("Notice: {message}"); + if let ServerMsg::Error { + message, + error_code, + } = &msg + { + // Lag notices carry `slow_consumer`. "Dropped ..." keeps + // the subscription open; "Stopped ..." ends it until you + // subscribe again. + if error_code == &Some(ErrorCode::SlowConsumer) { + eprintln!("Fell behind: {message}"); + } else { + eprintln!("Notice: {message}"); + } } continue; } @@ -123,14 +121,12 @@ async fn main() -> oxarchive::Result<()> { let mut ws = OxArchiveWs::new(WsOptions::new(&api_key)); ws.connect().await?; - ws.replay( - "orderbook", - "BTC", - 1704067200000, // 2024-01-01 00:00 UTC - Some(1704070800000), // 2024-01-01 01:00 UTC - Some(100.0), // 100x speed - ) - .await?; + // Five minutes of order book snapshots that ended ten minutes ago, at 10x + // (the fastest speed on the Free plan; paid plans allow more). + let end = chrono::Utc::now().timestamp_millis() - 10 * 60 * 1000; + let start = end - 5 * 60 * 1000; + ws.replay("orderbook", "BTC", start, Some(end), Some(10.0)) + .await?; let mut rx = ws.rx.take().expect("receiver"); let mut snapshots = 0u32; @@ -148,7 +144,7 @@ async fn main() -> oxarchive::Result<()> { println!("Replay complete: {} snapshots", snapshots_sent.unwrap_or(0)); break; } - ServerMsg::Error { message } => { + ServerMsg::Error { message, .. } => { eprintln!("Replay error: {message}"); break; } diff --git a/src/client.rs b/src/client.rs index ac90c56..1dbf38b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,7 +6,9 @@ use crate::error::{self, Error}; use crate::exchanges::{HyperliquidClient, LighterClient, RhLighterClient}; use crate::http::{HttpClient, HttpConfig}; use crate::resources::{DataQualityResource, Web3Resource, WebhooksResource}; -use crate::types::{SymbolEntry, SymbolsResponse}; +use crate::types::{Capability, SymbolEntry, SymbolsResponse}; + +pub use crate::http::{API_VERSION, API_VERSION_HEADER}; /// Default base URL for the 0xArchive API. pub const DEFAULT_BASE_URL: &str = "https://api.0xarchive.io"; @@ -83,7 +85,7 @@ impl ClientBuilder { /// let ob = client.hyperliquid.orderbook.get("BTC", None).await?; /// println!("BTC mid price: {:?}", ob.mid_price); /// -/// // Access Lighter.xyz (mainnet) +/// // Access Lighter (mainnet) /// let lighter_instruments = client.lighter.instruments.list().await?; /// /// // Access Lighter on Robinhood Chain, the second Lighter deployment @@ -99,9 +101,9 @@ pub struct OxArchive { http: HttpClient, /// Hyperliquid exchange client (includes nested `.hip3` client). pub hyperliquid: HyperliquidClient, - /// Lighter.xyz client for the mainnet deployment (`/v1/lighter`). + /// Lighter client for the mainnet deployment (`/v1/lighter`). pub lighter: LighterClient, - /// Lighter.xyz client for the Robinhood Chain deployment + /// Lighter client for the Robinhood Chain deployment /// (`/v1/rh-lighter`). Same resources as `lighter` except the L3 order /// book. pub rh_lighter: RhLighterClient, @@ -171,6 +173,38 @@ impl OxArchive { /// `exchange` for one family. pub async fn symbols(&self) -> error::Result> { let body: SymbolsResponse = self.http.get("/v1/symbols", &[]).await?; - Ok(body.symbols) + Ok(body.into_symbols()) + } + + /// Alias of [`OxArchive::symbols`], named for the `list` verb that + /// catalog reads use. + pub async fn list_symbols(&self) -> error::Result> { + self.symbols().await + } + + /// What each venue serves (`GET /v1/capabilities`): one [`Capability`] + /// row per venue and datatype, with its REST routes, its WebSocket + /// channels and whether they stream live and replay history, the first + /// instant served, the cadence, the largest page and the accepted + /// intervals. + /// + /// The route is public and cached for five minutes, and it uses no + /// credits. Use it to decide which channels to replay, as the server + /// does: [`Capability::for_channel`] finds a channel's row. + /// + /// ```no_run + /// # use oxarchive::OxArchive; + /// # async fn example() -> oxarchive::Result<()> { + /// # let client = OxArchive::new("key")?; + /// for row in client.capabilities().await? { + /// if row.replay { + /// println!("{} {}: replay {:?} from {:?}", row.venue, row.datatype, row.ws_channels, row.available_from); + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn capabilities(&self) -> error::Result> { + self.http.get("/v1/capabilities", &[]).await } } diff --git a/src/error.rs b/src/error.rs index 5da3cd8..926dffa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,14 +1,214 @@ //! Error types for the 0xArchive SDK. +use std::fmt; + +/// The stable, machine-readable reason for an API or WebSocket error, from the +/// `error_code` field of the error body. +/// +/// Match on the named variants for the codes the API documents. A code this +/// SDK version does not know arrives as [`ErrorCode::Other`] with the code +/// string unchanged, so an older SDK keeps working when the API adds one. The +/// enum is `#[non_exhaustive]`: a `match` needs a wildcard arm, and a later +/// minor release may give a code that arrives as `Other` today its own +/// variant. +/// +/// ``` +/// use oxarchive::ErrorCode; +/// +/// assert_eq!(ErrorCode::from("invalid_symbol"), ErrorCode::InvalidSymbol); +/// assert_eq!(ErrorCode::InvalidSymbol.as_str(), "invalid_symbol"); +/// assert_eq!( +/// ErrorCode::from("a_future_code"), +/// ErrorCode::Other("a_future_code".to_string()), +/// ); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ErrorCode { + /// A parameter is malformed or has a value the route does not accept. + /// `param` and `valid_values` on [`Error::Api`] say which, when sent. + InvalidParameter, + /// The symbol does not exist on this venue. + InvalidSymbol, + /// The `interval` value is not one the route accepts. + InvalidInterval, + /// The `cursor` could not be read. Restart paging without a cursor. + InvalidCursor, + /// The time range is invalid: `start` after `end`, or a time that cannot + /// be parsed. + InvalidTimeRange, + /// The whole requested range is before the dataset's coverage begins. + RangeBeforeCoverage, + /// The requested span is longer than the plan allows per request. + HistoricalRangeExceeded, + /// The requested time is older than the plan's history window. + HistoricalDepthExceeded, + /// The venue does not offer this datatype, channel or mode. The message + /// names the venues that do. + UnsupportedForVenue, + /// No route matches the request path. + RouteNotFound, + /// The resource id is unknown. + NotFound, + /// Missing or invalid credentials. + Unauthorized, + /// The credentials do not grant access to this resource. + Forbidden, + /// The monthly credits are spent. + InsufficientCredits, + /// Too many requests or subscribe operations; retry later. + RateLimited, + /// The request conflicts with current state, for example a replay is + /// already running on the connection. + Conflict, + /// A backing store failed or timed out; retry later. + UpstreamUnavailable, + /// An unexpected server error. + InternalError, + /// WebSocket only: this endpoint does not serve the channel or operation. + /// The message names the endpoint that does. + EndpointUnsupported, + /// WebSocket only: the connection fell behind a stream and messages were + /// dropped. Subscribe again, or restart the replay, to resync. + SlowConsumer, + /// Account positions are temporarily unavailable. + PositionsUnavailable, + /// The account already holds the maximum number of API keys. + ApiKeyLimitReached, + /// The operation is not permitted for an OAuth-authorized client. + OauthNotPermitted, + /// A code this SDK version does not name, kept as sent. + Other(String), +} + +impl ErrorCode { + /// The wire value, for example `"invalid_symbol"`. + pub fn as_str(&self) -> &str { + match self { + ErrorCode::InvalidParameter => "invalid_parameter", + ErrorCode::InvalidSymbol => "invalid_symbol", + ErrorCode::InvalidInterval => "invalid_interval", + ErrorCode::InvalidCursor => "invalid_cursor", + ErrorCode::InvalidTimeRange => "invalid_time_range", + ErrorCode::RangeBeforeCoverage => "range_before_coverage", + ErrorCode::HistoricalRangeExceeded => "historical_range_exceeded", + ErrorCode::HistoricalDepthExceeded => "historical_depth_exceeded", + ErrorCode::UnsupportedForVenue => "unsupported_for_venue", + ErrorCode::RouteNotFound => "route_not_found", + ErrorCode::NotFound => "not_found", + ErrorCode::Unauthorized => "unauthorized", + ErrorCode::Forbidden => "forbidden", + ErrorCode::InsufficientCredits => "insufficient_credits", + ErrorCode::RateLimited => "rate_limited", + ErrorCode::Conflict => "conflict", + ErrorCode::UpstreamUnavailable => "upstream_unavailable", + ErrorCode::InternalError => "internal_error", + ErrorCode::EndpointUnsupported => "endpoint_unsupported", + ErrorCode::SlowConsumer => "slow_consumer", + ErrorCode::PositionsUnavailable => "positions_unavailable", + ErrorCode::ApiKeyLimitReached => "api_key_limit_reached", + ErrorCode::OauthNotPermitted => "oauth_not_permitted", + ErrorCode::Other(code) => code, + } + } +} + +impl From<&str> for ErrorCode { + fn from(code: &str) -> Self { + match code { + "invalid_parameter" => ErrorCode::InvalidParameter, + "invalid_symbol" => ErrorCode::InvalidSymbol, + "invalid_interval" => ErrorCode::InvalidInterval, + "invalid_cursor" => ErrorCode::InvalidCursor, + "invalid_time_range" => ErrorCode::InvalidTimeRange, + "range_before_coverage" => ErrorCode::RangeBeforeCoverage, + "historical_range_exceeded" => ErrorCode::HistoricalRangeExceeded, + "historical_depth_exceeded" => ErrorCode::HistoricalDepthExceeded, + "unsupported_for_venue" => ErrorCode::UnsupportedForVenue, + "route_not_found" => ErrorCode::RouteNotFound, + "not_found" => ErrorCode::NotFound, + "unauthorized" => ErrorCode::Unauthorized, + "forbidden" => ErrorCode::Forbidden, + "insufficient_credits" => ErrorCode::InsufficientCredits, + "rate_limited" => ErrorCode::RateLimited, + "conflict" => ErrorCode::Conflict, + "upstream_unavailable" => ErrorCode::UpstreamUnavailable, + "internal_error" => ErrorCode::InternalError, + "endpoint_unsupported" => ErrorCode::EndpointUnsupported, + "slow_consumer" => ErrorCode::SlowConsumer, + "positions_unavailable" => ErrorCode::PositionsUnavailable, + "api_key_limit_reached" => ErrorCode::ApiKeyLimitReached, + "oauth_not_permitted" => ErrorCode::OauthNotPermitted, + other => ErrorCode::Other(other.to_string()), + } + } +} + +impl From for ErrorCode { + fn from(code: String) -> Self { + ErrorCode::from(code.as_str()) + } +} + +impl std::str::FromStr for ErrorCode { + type Err = std::convert::Infallible; + + fn from_str(code: &str) -> std::result::Result { + Ok(ErrorCode::from(code)) + } +} + +impl fmt::Display for ErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl serde::Serialize for ErrorCode { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ErrorCode { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + let code = String::deserialize(deserializer)?; + Ok(ErrorCode::from(code)) + } +} + /// An error returned by the 0xArchive API or the SDK itself. #[derive(Debug, thiserror::Error)] pub enum Error { /// The API returned an error response. - #[error("{message} (HTTP {code})")] + /// + /// `error_code` is the stable reason to branch on; `message` is for + /// people and may change wording. Every field but `message` and `code` + /// is `None` when the response body did not carry it (for example a + /// plain-text error from a proxy). + #[error( + "{message} (HTTP {code}{})", + .error_code.as_ref().map(|c| format!(", {c}")).unwrap_or_default() + )] Api { + /// Human-readable description of the error. message: String, + /// HTTP status code. code: u16, + /// Request identifier, useful when contacting support. request_id: Option, + /// Stable machine-readable reason (`invalid_symbol`, `rate_limited`, ...). + error_code: Option, + /// The request parameter the error is about, when there is one. + param: Option, + /// The values `param` accepts, when the API lists them. Boxed to keep + /// `Error` small; it reads as a slice. + valid_values: Option>, }, /// The request timed out. @@ -33,5 +233,49 @@ pub enum Error { WebSocket(String), } +impl Error { + /// The API's `error_code` for an [`Error::Api`], or `None` for other + /// errors and for API errors whose body carried no code. + pub fn error_code(&self) -> Option<&ErrorCode> { + match self { + Error::Api { error_code, .. } => error_code.as_ref(), + _ => None, + } + } + + /// The HTTP status of an [`Error::Api`]. + pub fn status(&self) -> Option { + match self { + Error::Api { code, .. } => Some(*code), + _ => None, + } + } + + /// The request identifier of an [`Error::Api`], when the API sent one. + pub fn request_id(&self) -> Option<&str> { + match self { + Error::Api { request_id, .. } => request_id.as_deref(), + _ => None, + } + } + + /// The request parameter an [`Error::Api`] is about, when the API named + /// one. + pub fn param(&self) -> Option<&str> { + match self { + Error::Api { param, .. } => param.as_deref(), + _ => None, + } + } + + /// The values the parameter accepts, when the API listed them. + pub fn valid_values(&self) -> Option<&[String]> { + match self { + Error::Api { valid_values, .. } => valid_values.as_deref(), + _ => None, + } + } +} + /// Convenience alias used throughout the SDK. pub type Result = std::result::Result; diff --git a/src/exchanges.rs b/src/exchanges.rs index fc867ed..eea14be 100644 --- a/src/exchanges.rs +++ b/src/exchanges.rs @@ -4,6 +4,7 @@ use crate::error::Result; use crate::http::HttpClient; use crate::resources::cvd::fetch_cvd; +use crate::resources::trades::RecentTradesParams; use crate::resources::{ BreadthResource, CandlesResource, CvdParams, FundingResource, Hip3InstrumentsResource, Hip4InstrumentsResource, InstrumentsResource, L2OrderBookResource, L3OrderBookResource, @@ -16,7 +17,7 @@ use crate::resources::{ use crate::types::{ CoinFreshness, CoinSummary, CursorResponse, CvdBucket, Hip4OpenInterestRecord, Hip4Outcome, Hip4OutcomeAggregate, Hip4Question, L4DiffEntry, L4OrderBookSnapshot, MetaResponse, OrderBook, - OrderHistoryEntry, PriceSnapshot, Timestamp, Trade, + OrderHistoryEntry, PriceSnapshot, Timestamp, Trade, TradeSide, }; // --------------------------------------------------------------------------- @@ -118,11 +119,9 @@ impl HyperliquidClient { if let Some(c) = cursor { qp.push(("cursor", c.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("/v1/hyperliquid/prices/{}", symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get cumulative volume delta buckets for a symbol @@ -240,11 +239,9 @@ impl Hip3Client { if let Some(c) = cursor { qp.push(("cursor", c.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("/v1/hyperliquid/hip3/prices/{}", symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get cumulative volume delta buckets for a HIP-3 symbol @@ -385,6 +382,33 @@ pub struct Hip4HistoryRange { pub limit: Option, } +/// Range, page and depth for [`Hip4::get_orderbook_history`]. +/// +/// A [`Hip4HistoryRange`] converts into it with no depth, so existing calls +/// that pass a range keep working. +#[derive(Debug, Clone)] +pub struct Hip4OrderBookHistoryParams { + pub start: Timestamp, + pub end: Timestamp, + /// The previous page's `next_cursor`, passed through unchanged. + pub cursor: Option, + pub limit: Option, + /// Price levels per side in each snapshot. + pub depth: Option, +} + +impl From for Hip4OrderBookHistoryParams { + fn from(range: Hip4HistoryRange) -> Self { + Self { + start: range.start, + end: range.end, + cursor: range.cursor, + limit: range.limit, + depth: None, + } + } +} + /// Range for trade-history pagination. #[derive(Debug, Clone)] pub struct Hip4TradesParams { @@ -393,6 +417,9 @@ pub struct Hip4TradesParams { /// The previous page's `next_cursor`, passed through unchanged. pub cursor: Option, pub limit: Option, + /// Keep only buy-side (`side` `"B"`) or sell-side (`"A"`) rows. Keep it + /// unchanged while paging. + pub side: Option, } /// Range params for L4 checkpoint history pagination. @@ -405,6 +432,9 @@ pub struct Hip4L4HistoryParams { } /// Filters for order-history pagination. +/// +/// Built with a struct literal; start from `..Default::default()` so a +/// literal keeps compiling when fields are added. #[derive(Debug, Default, Clone)] pub struct Hip4OrderHistoryParams { pub start: Option, @@ -414,6 +444,9 @@ pub struct Hip4OrderHistoryParams { pub order_type: Option, pub cursor: Option, pub limit: Option, + /// `Some(true)` keeps only orders whose trigger fired (status + /// `triggered`); `Some(false)` leaves them out. + pub triggered: Option, } /// Filters for order-flow aggregation. @@ -506,11 +539,9 @@ impl Hip4 { if let Some(l) = p.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/outcomes", HIP4_PREFIX), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get a single outcome detail (includes `aggregated_oi`). @@ -553,11 +584,9 @@ impl Hip4 { if let Some(l) = p.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/questions", HIP4_PREFIX), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get one HIP-4 question by id @@ -575,6 +604,12 @@ impl Hip4 { self.instruments.list().await } + /// Alias of [`Hip4::get_instruments`], named for the `list` verb that + /// catalog reads use. + pub async fn list_instruments(&self) -> Result> { + self.get_instruments().await + } + /// Get a single per-side instrument by path symbol (e.g. `"0"`; legacy /// `"#0"` is also accepted). pub async fn get_instrument(&self, symbol: &str) -> Result { @@ -606,11 +641,15 @@ impl Hip4 { } /// Get paginated L2 orderbook history. + /// + /// Takes a [`Hip4OrderBookHistoryParams`] (to set `depth`) or a + /// [`Hip4HistoryRange`]. pub async fn get_orderbook_history( &self, symbol: &str, - params: Hip4HistoryRange, + params: impl Into, ) -> Result>> { + let params = params.into(); let mut qp = vec![ ("start", params.start.to_millis().to_string()), ("end", params.end.to_millis().to_string()), @@ -621,8 +660,10 @@ impl Hip4 { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + if let Some(d) = params.depth { + qp.push(("depth", d.to_string())); + } + self.http .get_with_cursor( &format!( "{}/orderbook/{}/history", @@ -631,8 +672,7 @@ impl Hip4 { ), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } // ---- Trades ----------------------------------------------------------- @@ -653,36 +693,60 @@ impl Hip4 { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + if let Some(s) = params.side { + qp.push(("side", s.as_str().to_string())); + } + self.http .get_with_cursor( &format!("{}/trades/{}", HIP4_PREFIX, hip4_encode(symbol)), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await + } + + /// Alias of [`Hip4::get_trades`], named for the `history` verb that + /// paged series use. + pub async fn get_trades_history( + &self, + symbol: &str, + params: Hip4TradesParams, + ) -> Result>> { + self.get_trades(symbol, params).await } /// Get the most recent trades for a HIP-4 coin. + /// + /// To filter by side, use [`Hip4::get_trades_recent_with`]. pub async fn get_trades_recent( &self, symbol: &str, limit: Option, ) -> Result> { - let mut qp = vec![]; - if let Some(l) = limit { - qp.push(("limit", l.to_string())); - } - self.http - .get( + Ok(self + .get_trades_recent_with(symbol, RecentTradesParams { limit, side: None }) + .await? + .data) + } + + /// Get the most recent trades for a HIP-4 coin with a side filter, and + /// the response's full `meta` block. + pub async fn get_trades_recent_with( + &self, + symbol: &str, + params: RecentTradesParams, + ) -> Result>> { + let (data, meta) = self + .http + .get_with_meta( &format!( "{}/trades/{}/recent", HIP4_PREFIX, hip4_encode(symbol) ), - &qp, + ¶ms.query(), ) - .await + .await?; + Ok(MetaResponse::new(data, meta)) } // ---- Open interest ---------------------------------------------------- @@ -703,14 +767,22 @@ impl Hip4 { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/openinterest/{}", HIP4_PREFIX, hip4_encode(symbol)), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await + } + + /// Alias of [`Hip4::get_open_interest`], named for the `history` verb + /// that paged series use. + pub async fn get_open_interest_history( + &self, + symbol: &str, + params: Hip4HistoryRange, + ) -> Result>> { + self.get_open_interest(symbol, params).await } /// Get the latest per-side open-interest snapshot. @@ -775,14 +847,27 @@ impl Hip4 { if let Some(c) = cursor { qp.push(("cursor", c.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/prices/{}", HIP4_PREFIX, hip4_encode(symbol)), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await + } + + /// Alias of [`Hip4::get_prices`], named like `price_history()` on the + /// other clients. + pub async fn get_price_history( + &self, + symbol: &str, + start: impl Into, + end: impl Into, + interval: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result>> { + self.get_prices(symbol, start, end, interval, limit, cursor) + .await } // ---- Order lifecycle -------------------------------------------------- @@ -809,14 +894,16 @@ impl Hip4 { if let Some(t) = params.order_type { qp.push(("order_type", t)); } + if let Some(t) = params.triggered { + qp.push(("triggered", t.to_string())); + } if let Some(c) = params.cursor { qp.push(("cursor", c)); } if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!( "{}/orders/{}/history", @@ -825,8 +912,7 @@ impl Hip4 { ), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get aggregated order flow for a HIP-4 coin, one page of time buckets. @@ -853,8 +939,7 @@ impl Hip4 { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!( "{}/orders/{}/flow", @@ -863,8 +948,7 @@ impl Hip4 { ), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get TP/SL orders for a HIP-4 coin. @@ -892,8 +976,7 @@ impl Hip4 { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!( "{}/orders/{}/tpsl", @@ -902,8 +985,7 @@ impl Hip4 { ), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } // ---- L4 --------------------------------------------------------------- @@ -946,8 +1028,7 @@ impl Hip4 { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!( "{}/orderbook/{}/l4/diffs", @@ -956,8 +1037,7 @@ impl Hip4 { ), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get paginated L4 checkpoint history (hard-capped at `limit=10` server-side). @@ -976,8 +1056,7 @@ impl Hip4 { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!( "{}/orderbook/{}/l4/history", @@ -986,8 +1065,7 @@ impl Hip4 { ), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } @@ -1084,10 +1162,9 @@ async fn venue_price_history( if let Some(c) = cursor { qp.push(("cursor", c.to_string())); } - let (data, next_cursor) = http - .get_with_cursor(&format!("{}/prices/{}", prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + http + .get_with_cursor(&format!("{}/prices/{}", prefix, symbol), &qp) + .await } /// Client for Lighter mainnet endpoints (`/v1/lighter`). diff --git a/src/http.rs b/src/http.rs index bd70f7f..48a0c90 100644 --- a/src/http.rs +++ b/src/http.rs @@ -6,8 +6,20 @@ use serde::Deserialize; use std::sync::Arc; pub(crate) use std::time::Duration; -use crate::error::Error; -use crate::types::{ApiEnvelope, MetaEnvelope, ResponseMeta}; +use crate::error::{Error, ErrorCode}; +use crate::types::{ApiEnvelope, MetaEnvelope, MetaResponse, ResponseMeta}; + +/// The API version this SDK is built for, sent on every request as the +/// `0xArchive-Version` header and on the WebSocket connection as `version`. +/// +/// It selects the response shapes the SDK types describe: the standard +/// `{success, data, meta}` envelope on every route, RFC 3339 times with +/// `*_ms` integers, the contract error codes and, on the WebSocket, Lighter +/// replay rows in the live shapes. +pub const API_VERSION: &str = "2026-10-01"; + +/// Name of the request header that carries [`API_VERSION`]. +pub const API_VERSION_HEADER: &str = "0xArchive-Version"; /// Configuration for [`HttpClient`]. #[derive(Debug, Clone)] @@ -36,6 +48,10 @@ impl HttpClient { Error::InvalidParam("API key contains invalid characters".into()) })?); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert( + reqwest::header::HeaderName::from_static("0xarchive-version"), + HeaderValue::from_static(API_VERSION), + ); let inner = reqwest::Client::builder() .default_headers(headers) @@ -89,29 +105,15 @@ impl HttpClient { self.handle_response(resp).await } - /// Send a GET request and return the full envelope including cursor metadata. + /// Send a GET request and return the data with its paging state + /// (`next_cursor`, `has_more`) and the full `meta` block. pub async fn get_with_cursor( &self, path: &str, params: &[(&str, String)], - ) -> crate::error::Result<(T, Option)> { - let url = format!("{}{}", self.config.base_url, path); - - let filtered: Vec<(&str, &str)> = params - .iter() - .filter(|(_, v)| !v.is_empty()) - .map(|(k, v)| (*k, v.as_str())) - .collect(); - - let resp = self.inner.get(&url).query(&filtered).send().await.map_err(|e| { - if e.is_timeout() { - Error::Timeout - } else { - Error::Http(e) - } - })?; - - self.handle_cursor_response(resp).await + ) -> crate::error::Result> { + let (data, meta) = self.get_with_meta(path, params).await?; + Ok(MetaResponse::new(data, meta)) } /// Send a GET request and return the data with the response's full @@ -234,60 +236,124 @@ impl HttpClient { } // Try unwrapping the API envelope first, fall back to direct deserialization. - if let Ok(envelope) = serde_json::from_str::>(&body) { - return Ok(envelope.data); - } + let envelope_error = match serde_json::from_str::>(&body) { + Ok(envelope) => return Ok(envelope.data), + Err(e) => e, + }; + + serde_json::from_str::(&body).map_err(|direct_error| { + // Report the error that describes the body: the envelope's when + // the body is an envelope, the direct one otherwise. + let is_envelope = serde_json::from_str::(&body) + .map(|v| v.get("data").is_some()) + .unwrap_or(false); + let e = if is_envelope { envelope_error } else { direct_error }; + Error::Deserialize(format!("{e}: {body}")) + }) + } - serde_json::from_str::(&body) - .map_err(|e| Error::Deserialize(format!("{e}: {body}"))) + fn parse_error(&self, code: u16, body: &str) -> Error { + parse_error(code, body) } - async fn handle_cursor_response( - &self, - resp: reqwest::Response, - ) -> crate::error::Result<(T, Option)> { - let status = resp.status(); - let body = resp.text().await.map_err(Error::Http)?; + /// Expose the base URL (used in WebSocket client). + pub fn base_url(&self) -> &str { + &self.config.base_url + } +} - if !status.is_success() { - return Err(self.parse_error(status.as_u16(), &body)); - } +/// Map a non-2xx response to [`Error::Api`], keeping the contract fields +/// (`error_code`, `request_id`, `param`, `valid_values`) when the body is the +/// JSON error envelope. +pub(crate) fn parse_error(code: u16, body: &str) -> Error { + #[derive(Deserialize, Default)] + struct ErrBody { + #[serde(default)] + error: Option, + #[serde(default)] + message: Option, + #[serde(default)] + request_id: Option, + #[serde(default)] + error_code: Option, + #[serde(default)] + param: Option, + #[serde(default)] + valid_values: Option>, + } - let envelope: ApiEnvelope = serde_json::from_str(&body) - .map_err(|e| Error::Deserialize(format!("{e}: {body}")))?; + let parsed: ErrBody = serde_json::from_str(body).unwrap_or_default(); - let cursor = envelope.meta.and_then(|m| m.next_cursor); - Ok((envelope.data, cursor)) + let message = parsed + .error + .or(parsed.message) + .unwrap_or_else(|| format!("Request failed with status {code}")); + + Error::Api { + message, + code, + request_id: parsed.request_id, + error_code: parsed.error_code, + param: parsed.param, + valid_values: parsed.valid_values.map(Vec::into_boxed_slice), } +} - fn parse_error(&self, code: u16, body: &str) -> Error { - #[derive(Deserialize)] - struct ErrBody { - error: Option, - message: Option, - request_id: Option, - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_error_stays_small() { + // Keeps `Result` under clippy's large-error threshold. + assert!(std::mem::size_of::() < 128, "{}", std::mem::size_of::()); + } - let parsed: ErrBody = serde_json::from_str(body).unwrap_or(ErrBody { - error: None, - message: None, - request_id: None, - }); - - let message = parsed - .error - .or(parsed.message) - .unwrap_or_else(|| format!("Request failed with status {code}")); - - Error::Api { - message, - code, - request_id: parsed.request_id, + #[test] + fn contract_error_body_keeps_every_field() { + let body = r#"{"code":400,"error":"Invalid side 'B'. Use buy or sell.","error_code":"invalid_parameter","param":"side","request_id":"req-1","success":false,"valid_values":["buy","sell"]}"#; + let err = parse_error(400, body); + assert_eq!(err.status(), Some(400)); + assert_eq!(err.error_code(), Some(&ErrorCode::InvalidParameter)); + assert_eq!(err.request_id(), Some("req-1")); + assert_eq!(err.param(), Some("side")); + assert_eq!(err.valid_values(), Some(&["buy".to_string(), "sell".to_string()][..])); + assert_eq!( + err.to_string(), + "Invalid side 'B'. Use buy or sell. (HTTP 400, invalid_parameter)" + ); + } + + #[test] + fn a_body_that_is_not_json_still_maps_to_an_api_error() { + let err = parse_error(502, "Bad Gateway"); + match err { + Error::Api { + message, + code, + request_id, + error_code, + param, + valid_values, + } => { + assert_eq!(message, "Request failed with status 502"); + assert_eq!(code, 502); + assert!(request_id.is_none()); + assert!(error_code.is_none()); + assert!(param.is_none()); + assert!(valid_values.is_none()); + } + other => panic!("expected an API error, got {other:?}"), } } - /// Expose the base URL (used in WebSocket client). - pub fn base_url(&self) -> &str { - &self.config.base_url + #[test] + fn an_unknown_code_is_kept_as_sent() { + let err = parse_error(409, r#"{"code":409,"error":"moved","error_code":"snapshot_advanced"}"#); + assert_eq!( + err.error_code(), + Some(&ErrorCode::Other("snapshot_advanced".to_string())) + ); + assert_eq!(err.to_string(), "moved (HTTP 409, snapshot_advanced)"); } } diff --git a/src/l4_reconstructor.rs b/src/l4_reconstructor.rs index 67e6bad..4fff93d 100644 --- a/src/l4_reconstructor.rs +++ b/src/l4_reconstructor.rs @@ -1,7 +1,7 @@ //! L4 order book reconstructor with matching engine. //! //! Reconstructs Hyperliquid and HIP-3 L4 order books from checkpoints and diffs. -//! The same struct works for both exchanges — the diff format is identical. +//! The same struct works for both exchanges: the diff format is identical. //! //! When a new order crosses the spread, the matching engine filled opposite-side //! orders at crossing prices. Without removing them, the reconstructed book will diff --git a/src/lib.rs b/src/lib.rs index b57fbb7..21cc82b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ //! candles, funding rates, open interest, liquidations, and account //! positions, and manage signed webhook delivery. Two venues are covered: //! Hyperliquid (core perps, Hyperliquid Spot, HIP-3 builder perps, and HIP-4 -//! outcome markets) and Lighter.xyz. +//! outcome markets) and Lighter. //! Lighter has two deployments, mainnet (`client.lighter`) and Robinhood //! Chain (`client.rh_lighter`). //! @@ -23,7 +23,7 @@ //! let ob = client.hyperliquid.orderbook.get("BTC", None).await?; //! println!("BTC mid price: {:?}", ob.mid_price); //! -//! // List Lighter.xyz instruments (mainnet) +//! // List Lighter instruments (mainnet) //! let instruments = client.lighter.instruments.list().await?; //! println!("Lighter has {} instruments", instruments.len()); //! @@ -39,33 +39,68 @@ //! } //! ``` //! +//! ## API version +//! +//! Every request sends the `0xArchive-Version: 2026-10-01` header +//! ([`API_VERSION`]), and the WebSocket connects with `version=2026-10-01`. +//! The version selects the response shapes this crate's types describe: the +//! standard `{success, data, meta}` envelope, RFC 3339 times with `*_ms` +//! integer twins, and the stable error codes. +//! //! ## Pagination //! -//! Historical endpoints return [`CursorResponse`] with an optional -//! `next_cursor`. Pass it back as the `cursor` parameter to fetch the -//! next page: +//! Historical endpoints return [`CursorResponse`]: the page's `data`, +//! `next_cursor`, `has_more` and the full `meta` block (including the +//! canonical `meta.symbol` and `meta.venue` on per-symbol routes). Pass +//! `next_cursor` back as the `cursor` parameter, with the other parameters +//! unchanged, while `has_more` is `true`: //! //! ```no_run //! # use oxarchive::{OxArchive, types::CursorResponse}; //! # use oxarchive::resources::trades::GetTradesParams; //! # async fn example() -> oxarchive::Result<()> { //! # let client = OxArchive::new("key")?; +//! // The last ten minutes +//! let end = chrono::Utc::now(); +//! let start = end - chrono::Duration::minutes(10); //! let mut all_trades = vec![]; //! let mut cursor = None; //! //! loop { -//! let result = client.hyperliquid.trades.list("BTC", GetTradesParams { -//! start: 1704067200000_i64.into(), -//! end: 1704153600000_i64.into(), +//! let page = client.hyperliquid.trades.history("BTC", GetTradesParams { +//! start: start.into(), +//! end: end.into(), //! cursor, //! limit: Some(1000), +//! side: None, //! }).await?; //! -//! all_trades.extend(result.data); -//! cursor = result.next_cursor; -//! if cursor.is_none() { +//! all_trades.extend(page.data); +//! if !page.has_more { //! break; //! } +//! cursor = page.next_cursor; +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Errors +//! +//! An API error is [`Error::Api`], which carries the HTTP status, the stable +//! [`ErrorCode`] (`invalid_symbol`, `rate_limited`, ...), the request id and, +//! for a bad parameter, its name and accepted values: +//! +//! ```no_run +//! # use oxarchive::{Error, ErrorCode, OxArchive}; +//! # async fn example() -> oxarchive::Result<()> { +//! # let client = OxArchive::new("key")?; +//! match client.hyperliquid.orderbook.get("NOT-A-MARKET", None).await { +//! Err(Error::Api { error_code: Some(ErrorCode::InvalidSymbol), message, .. }) => { +//! eprintln!("{message}"); +//! } +//! Err(e) => eprintln!("{e} ({:?})", e.error_code()), +//! Ok(book) => println!("{:?}", book.mid_price), //! } //! # Ok(()) //! # } @@ -123,8 +158,14 @@ //! channels on each Lighter deployment: `lighter_orderbook`, //! `lighter_trades`, `lighter_open_interest` and `lighter_funding` on //! mainnet, and the same four with the `rh_lighter_` prefix on Robinhood -//! Chain. Their payloads decode into [`LighterLiveData`]. `lighter_candles`, -//! `lighter_l3_orderbook` and `rh_lighter_candles` remain replay-only. +//! Chain. Their payloads, live and replayed, decode into [`LighterLiveData`]. +//! `lighter_candles`, `lighter_l3_orderbook` and `rh_lighter_candles` remain +//! replay-only. +//! +//! Every L4 channel (Hyperliquid core, HIP-3, Spot and HIP-4) and both +//! full-depth order book channels (`orderbook_full`, `hip3_orderbook_full`) +//! replay as bulk, single-channel streams. `client.capabilities()` lists +//! which channels stream live and which replay. //! //! Bulk streaming over WebSocket has been discontinued, so //! `OxArchiveWs::stream` is deprecated. For large historical downloads, use @@ -142,8 +183,8 @@ pub mod webhook_signature; pub mod ws; // Re-export the main entry points at the crate root. -pub use client::{ClientBuilder, OxArchive}; -pub use error::{Error, Result}; +pub use client::{ClientBuilder, OxArchive, API_VERSION, API_VERSION_HEADER}; +pub use error::{Error, ErrorCode, Result}; pub use exchanges::{Hip4, Hip4ListQuestionsParams, RhLighterClient}; pub use l4_reconstructor::{L4OrderBookReconstructor, L4Order, L4Diff, L2Level}; pub use orderbook_reconstructor::{ @@ -173,6 +214,8 @@ pub use resources::webhooks::{ CreateEndpointParams, CreateSubscriptionParams, DryRunParams, EstimateParams, UpdateSubscriptionParams, WebhooksResource, }; +pub use resources::trades::RecentTradesParams; +pub use types::{Capability, TradeSide}; pub use types::{ BreadthSnapshot, ClassifiedWallet, CvdBucket, Hip3OracleDiscoveryBounds, Hip3OracleExternalPrice, Hip4Question, SymbolEntry, WalletClassification, WalletMetrics, diff --git a/src/orderbook_reconstructor.rs b/src/orderbook_reconstructor.rs index 87fa64e..c68bb40 100644 --- a/src/orderbook_reconstructor.rs +++ b/src/orderbook_reconstructor.rs @@ -1,4 +1,4 @@ -//! Tick-level order book reconstructor for Lighter.xyz. +//! Tick-level order book reconstructor for Lighter. //! //! Maintains an in-memory representation of the order book and applies //! incremental deltas to produce full L2 snapshots at each tick. @@ -11,9 +11,10 @@ //! //! # async fn example() -> oxarchive::Result<()> { //! let client = OxArchive::new("your-api-key")?; -//! let tick_data = client.lighter.orderbook.history_tick( -//! "BTC", 1769904000000_i64, 1769907600000_i64, None, -//! ).await?; +//! // The last ten minutes, in Unix milliseconds +//! let end = chrono::Utc::now().timestamp_millis(); +//! let start = end - 10 * 60 * 1000; +//! let tick_data = client.lighter.orderbook.history_tick("BTC", start, end, None).await?; //! //! let mut reconstructor = OrderBookReconstructor::new(); //! let snapshots = reconstructor.reconstruct_all( @@ -258,7 +259,7 @@ impl OrderBookReconstructor { /// Reconstruct only the final state after applying all deltas. /// /// More efficient than [`reconstruct_all`](Self::reconstruct_all) when - /// intermediate snapshots are not needed — avoids sorting price levels + /// intermediate snapshots are not needed: it avoids sorting price levels /// for every delta. pub fn reconstruct_final( &mut self, diff --git a/src/resources/breadth.rs b/src/resources/breadth.rs index 7aedcaf..e0ea3bd 100644 --- a/src/resources/breadth.rs +++ b/src/resources/breadth.rs @@ -89,10 +89,8 @@ impl BreadthResource { params: BreadthHistoryParams, ) -> Result>> { let query = params.to_query()?; - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/breadth/above-vwap", self.prefix), &query) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } diff --git a/src/resources/candles.rs b/src/resources/candles.rs index 0694b39..fe89076 100644 --- a/src/resources/candles.rs +++ b/src/resources/candles.rs @@ -53,7 +53,7 @@ impl CandlesResource { // Allow 5 minutes of clock skew tolerance if start_ms > now_ms + 5 * 60 * 1000 { return Err(Error::InvalidParam( - "start timestamp is in the future — no candle data can exist yet".into(), + "start timestamp is in the future, so no candle data can exist yet".into(), )); } @@ -78,10 +78,8 @@ impl CandlesResource { qp.push(("interval", i.as_str().to_string())); } let symbol = (self.symbol_transform)(symbol); - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/candles/{}", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } diff --git a/src/resources/data_quality.rs b/src/resources/data_quality.rs index 0a18417..dc1ad5e 100644 --- a/src/resources/data_quality.rs +++ b/src/resources/data_quality.rs @@ -41,6 +41,9 @@ fn esc(segment: &str) -> String { const SLOW_ENDPOINT_TIMEOUT: Duration = Duration::from_secs(120); /// Access to data quality monitoring endpoints. +/// +/// Every response is the standard `{success, data, meta}` envelope under the +/// API version the SDK sends; the methods return the `data` member. #[derive(Debug, Clone)] pub struct DataQualityResource { http: HttpClient, @@ -71,6 +74,15 @@ impl DataQualityResource { .await } + /// Get the public coverage summary (`GET /v1/status/coverage`), the one + /// the status page shows. Same shape as [`coverage`](Self::coverage); + /// the route needs no API key and is cached for five minutes. + pub async fn status_coverage(&self) -> Result { + self.http + .get_with_timeout("/v1/status/coverage", &[], Some(SLOW_ENDPOINT_TIMEOUT)) + .await + } + /// Get data coverage for a single venue scope. pub async fn exchange_coverage(&self, exchange: &str) -> Result { self.http diff --git a/src/resources/funding.rs b/src/resources/funding.rs index 9b0eba7..89997bd 100644 --- a/src/resources/funding.rs +++ b/src/resources/funding.rs @@ -48,11 +48,9 @@ impl FundingResource { if let Some(i) = params.interval { qp.push(("interval", i.as_str().to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/funding/{}", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get the current funding rate. diff --git a/src/resources/instruments.rs b/src/resources/instruments.rs index fe3fbd1..519ce72 100644 --- a/src/resources/instruments.rs +++ b/src/resources/instruments.rs @@ -32,7 +32,7 @@ impl InstrumentsResource { } } -/// Lighter.xyz instruments resource (extended metadata). +/// Lighter instruments resource (extended metadata). #[derive(Debug, Clone)] pub struct LighterInstrumentsResource { http: HttpClient, diff --git a/src/resources/l2_orderbook.rs b/src/resources/l2_orderbook.rs index 72193d5..5249579 100644 --- a/src/resources/l2_orderbook.rs +++ b/src/resources/l2_orderbook.rs @@ -18,6 +18,8 @@ pub struct L2HistoryParams { pub end: Timestamp, pub cursor: Option, pub limit: Option, + /// Price levels per side in each snapshot (omit for every served level). + pub depth: Option, } /// Parameters for paginated L2 orderbook diffs. @@ -79,14 +81,15 @@ impl L2OrderBookResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + if let Some(d) = params.depth { + qp.push(("depth", d.to_string())); + } + self.http .get_with_cursor( &format!("{}/orderbook/{}/l2/history", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get paginated L2 tick-level diffs. @@ -105,13 +108,11 @@ impl L2OrderBookResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/orderbook/{}/l2/diffs", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } diff --git a/src/resources/l3_orderbook.rs b/src/resources/l3_orderbook.rs index f78362b..bb7d71d 100644 --- a/src/resources/l3_orderbook.rs +++ b/src/resources/l3_orderbook.rs @@ -24,7 +24,7 @@ pub struct L3OrderBookParams { pub depth: Option, } -/// Access to L3 (order-level) orderbook endpoints (Lighter.xyz only). +/// Access to L3 (order-level) orderbook endpoints (Lighter only). #[derive(Debug, Clone)] pub struct L3OrderBookResource { http: HttpClient, @@ -101,13 +101,11 @@ impl L3OrderBookResource { if let Some(a) = params.account { qp.push(("account", a.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/l3orderbook/{}/history", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } diff --git a/src/resources/l4_orderbook.rs b/src/resources/l4_orderbook.rs index 54b3d52..17924e3 100644 --- a/src/resources/l4_orderbook.rs +++ b/src/resources/l4_orderbook.rs @@ -79,14 +79,12 @@ impl L4OrderBookResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/orderbook/{}/l4/diffs", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get paginated L4 orderbook history for a symbol. @@ -105,13 +103,11 @@ impl L4OrderBookResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/orderbook/{}/l4/history", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } diff --git a/src/resources/liquidations.rs b/src/resources/liquidations.rs index 70b8c6f..1099c42 100644 --- a/src/resources/liquidations.rs +++ b/src/resources/liquidations.rs @@ -136,11 +136,9 @@ impl LiquidationsResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/liquidations/{}", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get paginated liquidations for a specific user address. @@ -162,14 +160,12 @@ impl LiquidationsResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/liquidations/user/{}", self.prefix, user_address), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get aggregated liquidation volume by time bucket. @@ -191,14 +187,12 @@ impl LiquidationsResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/liquidations/{}/volume", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get projected forced-liquidation levels for a symbol. @@ -244,14 +238,12 @@ impl LiquidationsResource { params: LevelsHistoryParams, ) -> Result>> { let qp = params.to_query(); - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/liquidations/{}/levels/history", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } @@ -349,11 +341,9 @@ impl LighterLiquidationsResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/liquidations/{}", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get liquidation volume by time bucket (`interval` defaults to `1h`). @@ -378,13 +368,11 @@ impl LighterLiquidationsResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/liquidations/{}/volume", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } diff --git a/src/resources/open_interest.rs b/src/resources/open_interest.rs index 7607bfd..d1b85ab 100644 --- a/src/resources/open_interest.rs +++ b/src/resources/open_interest.rs @@ -46,11 +46,9 @@ impl OpenInterestResource { if let Some(i) = params.interval { qp.push(("interval", i.as_str().to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/openinterest/{}", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get the current open interest. diff --git a/src/resources/orderbook.rs b/src/resources/orderbook.rs index 4474ee4..38ad87d 100644 --- a/src/resources/orderbook.rs +++ b/src/resources/orderbook.rs @@ -3,7 +3,7 @@ use crate::http::HttpClient; use crate::orderbook_reconstructor::OrderBookReconstructor; use crate::types::{ CursorResponse, LighterGranularity, OrderBook, OrderbookDelta, ReconstructOptions, - ReconstructedOrderBook, TickData, Timestamp, + ReconstructedOrderBook, ResponseMeta, TickData, Timestamp, }; /// Parameters for fetching a single orderbook snapshot. @@ -22,11 +22,80 @@ pub struct OrderBookHistoryParams { pub end: Timestamp, pub cursor: Option, pub limit: Option, + /// Price levels per side in each snapshot, on every venue (Hyperliquid, + /// HIP-3, Spot and both Lighter deployments). pub depth: Option, - /// Lighter.xyz only: snapshot granularity. + /// Lighter only: snapshot granularity. pub granularity: Option, } +/// Range, depth and position for +/// [`OrderBookResource::history_tick_page`]. +#[derive(Debug, Clone)] +pub struct TickPageParams { + /// Start of the range. The first page opens at the checkpoint at or + /// before it. Keep it unchanged while paging. + pub start: Timestamp, + /// End of the range. Keep it unchanged while paging. + pub end: Timestamp, + /// Price levels per side on the checkpoint. + pub depth: Option, + /// The previous page's `next_cursor`; `None` for the first page. + pub cursor: Option, + /// The previous page's `next_cursor_seq`, sent with `cursor`. + pub cursor_seq: Option, +} + +impl TickPageParams { + /// The first page of a range, with no depth limit. + pub fn new(start: impl Into, end: impl Into) -> Self { + Self { + start: start.into(), + end: end.into(), + depth: None, + cursor: None, + cursor_seq: None, + } + } + + /// The same range and depth, positioned after `page`. + pub fn after(self, page: &TickPage) -> Self { + Self { + cursor: page.next_cursor.clone(), + cursor_seq: page.next_cursor_seq, + ..self + } + } +} + +/// One page of tick-level order book data, from +/// [`OrderBookResource::history_tick_page`]. +#[derive(Debug, Clone)] +pub struct TickPage { + /// The full book at the checkpoint the range opens at. Present on the + /// first page only; later pages carry deltas to apply on top of the + /// running book. + pub checkpoint: Option, + /// Incremental changes, in `(timestamp, sequence)` order. + pub deltas: Vec, + /// Pass back as `cursor`, with `next_cursor_seq`, for the next page. + pub next_cursor: Option, + /// Pass back as `cursor_seq` for the next page. + pub next_cursor_seq: Option, + /// `true` while more deltas follow in the range. + pub has_more: bool, + /// The full `meta` block. + pub meta: ResponseMeta, +} + +fn no_tick_data() -> Error { + Error::InvalidParam( + "Tick-level orderbook data was not returned for this request. \ + Check the symbol and time range, or use a different granularity." + .into(), + ) +} + /// Access to order book endpoints for a specific exchange. #[derive(Debug, Clone)] pub struct OrderBookResource { @@ -79,20 +148,20 @@ impl OrderBookResource { if let Some(g) = params.granularity { qp.push(("granularity", g.as_str().to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/orderbook/{}/history", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } - /// Fetch tick-level orderbook data (checkpoint + deltas). + /// Fetch tick-level orderbook data (checkpoint + deltas): the first page + /// of the range. /// - /// Returns a full L2 checkpoint at the start of the range plus every - /// incremental delta within the range (up to ~1000 deltas per request). - /// Use this with [`OrderBookReconstructor`] for maximum control, or call - /// [`history_reconstructed`](Self::history_reconstructed) for a one-shot - /// convenience method. + /// Returns the full L2 checkpoint at or before `start` plus the first + /// page of incremental deltas after it (100 by default). Use this with + /// [`OrderBookReconstructor`] for maximum control, call + /// [`history_tick_page`](Self::history_tick_page) to page through the + /// rest of the range, or [`collect_tick_history`](Self::collect_tick_history) + /// to fetch and reconstruct all of it. /// /// # Errors /// @@ -105,49 +174,118 @@ impl OrderBookResource { end: impl Into, depth: Option, ) -> Result { + let page = self + .history_tick_page( + symbol, + TickPageParams { + start: start.into(), + end: end.into(), + depth, + cursor: None, + cursor_seq: None, + }, + ) + .await?; + let checkpoint = page.checkpoint.ok_or_else(no_tick_data)?; + Ok(TickData { + checkpoint, + deltas: page.deltas, + }) + } + + /// Fetch one page of tick-level orderbook data with its paging state. + /// + /// The first page (no `cursor`) carries the checkpoint at or before + /// `start` and the first deltas after it. While `has_more` is `true`, + /// request the next page with the same `start`, `end` and `depth` and the + /// page's `next_cursor` and `next_cursor_seq`; continuation pages carry + /// deltas only, to apply on top of the running book. A page holds a + /// bounded number of deltas (100 by default), so a short page is not the + /// end of the range; `has_more` is. + /// + /// ```no_run + /// # use oxarchive::OxArchive; + /// # use oxarchive::resources::orderbook::TickPageParams; + /// # async fn example() -> oxarchive::Result<()> { + /// # let client = OxArchive::new("key")?; + /// // The last minute, in Unix milliseconds + /// let end = chrono::Utc::now().timestamp_millis(); + /// let mut params = TickPageParams::new(end - 60_000, end); + /// loop { + /// let page = client.lighter.orderbook.history_tick_page("BTC", params.clone()).await?; + /// println!("{} deltas", page.deltas.len()); + /// if !page.has_more { + /// break; + /// } + /// params = params.after(&page); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn history_tick_page( + &self, + symbol: &str, + params: TickPageParams, + ) -> Result { let mut qp = vec![ - ("start", start.into().to_millis().to_string()), - ("end", end.into().to_millis().to_string()), + ("start", params.start.to_millis().to_string()), + ("end", params.end.to_millis().to_string()), ("granularity", "tick".to_string()), ]; - if let Some(d) = depth { + if let Some(d) = params.depth { qp.push(("depth", d.to_string())); } + if let Some(c) = params.cursor { + qp.push(("cursor", c)); + } + if let Some(seq) = params.cursor_seq { + qp.push(("cursor_seq", seq.to_string())); + } - let value: serde_json::Value = self + let (value, meta): (serde_json::Value, ResponseMeta) = self .http - .get(&format!("{}/orderbook/{}/history", self.prefix, symbol), &qp) + .get_with_meta(&format!("{}/orderbook/{}/history", self.prefix, symbol), &qp) .await?; // Tick-level responses are objects with "checkpoint" + "deltas". // Some requests receive an array of snapshots instead. - let obj = value.as_object().ok_or_else(|| { - Error::InvalidParam( - "Tick-level orderbook data was not returned for this request. \ - Check the symbol and time range, or use a different granularity." - .into(), - ) - })?; - - if !obj.contains_key("checkpoint") { - return Err(Error::InvalidParam( - "Tick-level orderbook data was not returned for this request. \ - Check the symbol and time range, or use a different granularity." - .into(), - )); + let obj = value.as_object().ok_or_else(no_tick_data)?; + if !obj.contains_key("checkpoint") && !obj.contains_key("deltas") { + return Err(no_tick_data()); } - let checkpoint: OrderBook = serde_json::from_value(obj["checkpoint"].clone()) - .map_err(|e| Error::Deserialize(format!("Failed to parse checkpoint: {e}")))?; + let checkpoint: Option = match obj.get("checkpoint") { + None | Some(serde_json::Value::Null) => None, + Some(c) => Some( + serde_json::from_value(c.clone()) + .map_err(|e| Error::Deserialize(format!("Failed to parse checkpoint: {e}")))?, + ), + }; - let deltas: Vec = obj - .get("deltas") - .and_then(|d| serde_json::from_value(d.clone()).ok()) - .unwrap_or_default(); + let deltas: Vec = match obj.get("deltas") { + None | Some(serde_json::Value::Null) => Vec::new(), + Some(d) => serde_json::from_value(d.clone()) + .map_err(|e| Error::Deserialize(format!("Failed to parse deltas: {e}")))?, + }; - Ok(TickData { + let next_cursor = obj + .get("next_cursor") + .and_then(|v| match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .or_else(|| meta.next_cursor.clone()); + let next_cursor_seq = obj.get("next_cursor_seq").and_then(|v| v.as_i64()); + let has_more = meta.has_more.unwrap_or(next_cursor.is_some()); + + Ok(TickPage { checkpoint, deltas, + next_cursor, + next_cursor_seq, + has_more, + meta, }) } @@ -179,13 +317,14 @@ impl OrderBookResource { /// Fetch and reconstruct tick-level orderbook history with automatic /// pagination. /// - /// Fetches up to ~1000 deltas per API call, automatically advancing the - /// cursor until the entire time range is covered. Returns all - /// reconstructed snapshots (one per tick) as a single `Vec`. + /// Starts from the checkpoint at or before `start` and follows the + /// response's cursor until `has_more` is `false`, applying every delta in + /// order. Returns all reconstructed snapshots (the checkpoint, then one + /// per delta) as a single `Vec`. /// /// For very large time ranges this may use significant memory. Consider - /// using [`history_tick`](Self::history_tick) in a manual loop for - /// streaming-style processing. + /// using [`history_tick_page`](Self::history_tick_page) in a manual loop + /// for streaming-style processing. pub async fn collect_tick_history( &self, symbol: &str, @@ -193,54 +332,44 @@ impl OrderBookResource { end: impl Into, depth: Option, ) -> Result> { - let start_ts = start.into().to_millis(); - let end_ts = end.into().to_millis(); let depth_usize = depth.map(|d| d as usize); - let max_deltas_per_page = 1000; + let mut params = TickPageParams { + depth, + ..TickPageParams::new(start, end) + }; - let mut cursor = start_ts; let mut reconstructor = OrderBookReconstructor::new(); let mut all_snapshots = Vec::new(); let mut is_first_page = true; - while cursor < end_ts { - let tick_data = self.history_tick(symbol, cursor, end_ts, depth).await?; + loop { + let page = self.history_tick_page(symbol, params.clone()).await?; - if tick_data.deltas.is_empty() { - if is_first_page { - reconstructor.initialize(&tick_data.checkpoint); - all_snapshots.push(reconstructor.get_snapshot(depth_usize)); - } - break; - } - - // Re-initialize from this page's checkpoint. - reconstructor.initialize(&tick_data.checkpoint); - - let mut sorted_deltas: Vec<&OrderbookDelta> = tick_data.deltas.iter().collect(); - sorted_deltas.sort_by_key(|d| d.sequence); - - // On the first page, include the initial checkpoint snapshot. - // On subsequent pages, skip it to avoid duplicates. if is_first_page { + let checkpoint = page.checkpoint.as_ref().ok_or_else(no_tick_data)?; + reconstructor.initialize(checkpoint); all_snapshots.push(reconstructor.get_snapshot(depth_usize)); + is_first_page = false; } - for delta in &sorted_deltas { + let mut sorted_deltas: Vec<&OrderbookDelta> = page.deltas.iter().collect(); + sorted_deltas.sort_by_key(|d| (d.timestamp, d.sequence)); + for delta in sorted_deltas { reconstructor.apply_delta(delta); all_snapshots.push(reconstructor.get_snapshot(depth_usize)); } - is_first_page = false; - - // Advance cursor past the last delta's timestamp. - let last_delta = sorted_deltas.last().unwrap(); - cursor = last_delta.timestamp + 1; - - // Fewer than max deltas means we've reached the end of the range. - if tick_data.deltas.len() < max_deltas_per_page { + // Stop on the server's `has_more`, and on a page that would not + // move the cursor forward. + let next = params.clone().after(&page); + if !page.has_more + || page.deltas.is_empty() + || page.next_cursor.is_none() + || (next.cursor == params.cursor && next.cursor_seq == params.cursor_seq) + { break; } + params = next; } Ok(all_snapshots) diff --git a/src/resources/orders.rs b/src/resources/orders.rs index 6f5467b..a49f138 100644 --- a/src/resources/orders.rs +++ b/src/resources/orders.rs @@ -6,6 +6,9 @@ use crate::types::{ }; /// Parameters for paginated order history. +/// +/// Built with a struct literal; start from `..Default::default()` so a +/// literal keeps compiling when fields are added. #[derive(Debug, Default)] pub struct OrderHistoryParams { pub start: Option, @@ -15,6 +18,10 @@ pub struct OrderHistoryParams { pub order_type: Option, pub cursor: Option, pub limit: Option, + /// `Some(true)` keeps only orders whose trigger fired (status + /// `triggered`); `Some(false)` leaves them out. Keep it unchanged while + /// paging. + pub triggered: Option, } /// Parameters for order flow aggregation. @@ -105,11 +112,9 @@ impl SpotOrdersResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/orders/{}/history", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } @@ -151,17 +156,18 @@ impl OrdersResource { if let Some(ref t) = params.order_type { qp.push(("order_type", t.clone())); } + if let Some(t) = params.triggered { + qp.push(("triggered", t.to_string())); + } if let Some(ref c) = params.cursor { qp.push(("cursor", c.clone())); } if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/orders/{}/history", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get order flow aggregation for a symbol, one page of time buckets. @@ -189,11 +195,9 @@ impl OrdersResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/orders/{}/flow", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get TP/SL (take-profit / stop-loss) orders for a symbol. @@ -221,11 +225,9 @@ impl OrdersResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/orders/{}/tpsl", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get the pending trigger-order map for a symbol. @@ -269,13 +271,11 @@ impl OrdersResource { params: LevelsHistoryParams, ) -> Result>> { let qp = params.to_query(); - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/orders/{}/trigger-levels/history", self.prefix, symbol), &qp, ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } diff --git a/src/resources/spot.rs b/src/resources/spot.rs index 0e39e38..44acb3c 100644 --- a/src/resources/spot.rs +++ b/src/resources/spot.rs @@ -78,11 +78,9 @@ impl SpotTwapResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/twap/{}", self.prefix, symbol), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } /// Get TWAP execution statuses for a user address (across every spot symbol). @@ -104,10 +102,8 @@ impl SpotTwapResource { if let Some(l) = params.limit { qp.push(("limit", l.to_string())); } - let (data, next_cursor) = self - .http + self.http .get_with_cursor(&format!("{}/twap/user/{}", self.prefix, user), &qp) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await } } diff --git a/src/resources/trades.rs b/src/resources/trades.rs index d4b0716..4e20501 100644 --- a/src/resources/trades.rs +++ b/src/resources/trades.rs @@ -1,6 +1,6 @@ use crate::error::{Error, Result}; use crate::http::HttpClient; -use crate::types::{CursorResponse, MetaResponse, Timestamp, Trade}; +use crate::types::{CursorResponse, MetaResponse, Timestamp, Trade, TradeSide}; /// Parameters for paginated trade history. #[derive(Debug)] @@ -10,6 +10,10 @@ pub struct GetTradesParams { /// The previous page's `next_cursor`, passed through unchanged. pub cursor: Option, pub limit: Option, + /// Keep only buy-side (`side` `"B"`) or sell-side (`"A"`) rows. The + /// filter applies before paging, so a full page still holds `limit` + /// matching rows; keep it unchanged while paging. + pub side: Option, } impl GetTradesParams { @@ -24,6 +28,32 @@ impl GetTradesParams { if let Some(l) = self.limit { qp.push(("limit", l.to_string())); } + if let Some(s) = self.side { + qp.push(("side", s.as_str().to_string())); + } + qp + } +} + +/// Parameters for the most recent trades, from +/// [`TradesResource::recent_with`] and `hip4.get_trades_recent_with()`. +#[derive(Debug, Default, Clone)] +pub struct RecentTradesParams { + /// Number of trades to return. + pub limit: Option, + /// Keep only buy-side (`side` `"B"`) or sell-side (`"A"`) rows. + pub side: Option, +} + +impl RecentTradesParams { + pub(crate) fn query(&self) -> Vec<(&'static str, String)> { + let mut qp = vec![]; + if let Some(l) = self.limit { + qp.push(("limit", l.to_string())); + } + if let Some(s) = self.side { + qp.push(("side", s.as_str().to_string())); + } qp } } @@ -45,29 +75,41 @@ impl TradesResource { /// Get paginated historical trades. /// + /// Page with `next_cursor` while `has_more` is `true`. The response's + /// `meta` carries the canonical `symbol` and the `venue`. + /// /// On both Lighter deployments the server clamps `end` to the /// finalization boundary, so a range that reaches past it ends early with - /// `next_cursor` `None`. Use [`TradesResource::list_with_meta`] to see - /// the boundary and whether the range was clamped. + /// `has_more` `false`; `meta.finalized_through`, `meta.requested_end` and + /// `meta.clamped_to` say where and why. pub async fn list( &self, symbol: &str, params: GetTradesParams, ) -> Result>> { - let (data, next_cursor) = self - .http + self.http .get_with_cursor( &format!("{}/trades/{}", self.prefix, symbol), ¶ms.query(), ) - .await?; - Ok(CursorResponse { data, next_cursor }) + .await + } + + /// Alias of [`TradesResource::list`], named for the `history` verb that + /// paged series use. + pub async fn history( + &self, + symbol: &str, + params: GetTradesParams, + ) -> Result>> { + self.list(symbol, params).await } /// Get paginated historical trades with the response's full `meta` block. /// - /// Sends the same request as [`TradesResource::list`]. On both Lighter - /// deployments `list` serves final trades only: + /// Sends the same request as [`TradesResource::list`] and returns the + /// same value, since every paged response now carries `meta`. On both + /// Lighter deployments `list` serves final trades only: /// /// - `meta.finalized_through` is the finalization boundary, about a day /// behind. Every trade before it is final. @@ -82,30 +124,61 @@ impl TradesResource { symbol: &str, params: GetTradesParams, ) -> Result>> { - let (data, meta) = self - .http - .get_with_meta( - &format!("{}/trades/{}", self.prefix, symbol), - ¶ms.query(), - ) - .await?; - Ok(MetaResponse::new(data, meta)) + self.list(symbol, params).await } /// Get recent trades. /// - /// Only available on Lighter.xyz (`/v1/lighter` and `/v1/rh-lighter`), - /// where it serves the preliminary tier, and HIP-3 - /// (`/v1/hyperliquid/hip3`). The Hyperliquid base namespace + /// Available on HIP-3 (`/v1/hyperliquid/hip3`), Hyperliquid Spot and both + /// Lighter deployments (`/v1/lighter` and `/v1/rh-lighter`), where it + /// serves the preliminary tier. The Hyperliquid base namespace /// (`/v1/hyperliquid`) does **not** expose a `/recent` endpoint; /// calling `client.hyperliquid.trades.recent(...)` returns /// [`Error::InvalidParam`] without a network round-trip. Use /// [`TradesResource::list`] with a time range instead. + /// + /// To filter by side, use [`TradesResource::recent_with`]. pub async fn recent(&self, symbol: &str, limit: Option) -> Result> { - let qp = self.recent_query(limit)?; - self.http - .get(&format!("{}/trades/{}/recent", self.prefix, symbol), &qp) - .await + Ok(self + .recent_with(symbol, RecentTradesParams { limit, side: None }) + .await? + .data) + } + + /// Get recent trades with a side filter, and the response's full `meta` + /// block. + /// + /// Same venues and rules as [`TradesResource::recent`]. On both Lighter + /// deployments `meta.preliminary_row_count` is the number of rows that are + /// not final yet and `meta.finalized_through` is the boundary. + /// + /// ```no_run + /// # use oxarchive::OxArchive; + /// # use oxarchive::resources::trades::RecentTradesParams; + /// # use oxarchive::types::TradeSide; + /// # async fn example() -> oxarchive::Result<()> { + /// # let client = OxArchive::new("key")?; + /// let sells = client.hyperliquid.hip3.trades.recent_with("xyz:XYZ100", RecentTradesParams { + /// limit: Some(100), + /// side: Some(TradeSide::Sell), + /// }).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn recent_with( + &self, + symbol: &str, + params: RecentTradesParams, + ) -> Result>> { + self.check_recent()?; + let (data, meta) = self + .http + .get_with_meta( + &format!("{}/trades/{}/recent", self.prefix, symbol), + ¶ms.query(), + ) + .await?; + Ok(MetaResponse::new(data, meta)) } /// Get recent trades with the response's full `meta` block. @@ -120,15 +193,11 @@ impl TradesResource { symbol: &str, limit: Option, ) -> Result>> { - let qp = self.recent_query(limit)?; - let (data, meta) = self - .http - .get_with_meta(&format!("{}/trades/{}/recent", self.prefix, symbol), &qp) - .await?; - Ok(MetaResponse::new(data, meta)) + self.recent_with(symbol, RecentTradesParams { limit, side: None }) + .await } - fn recent_query(&self, limit: Option) -> Result> { + fn check_recent(&self) -> Result<()> { // Reject the Hyperliquid base prefix: backend only exposes /recent // for HIP-3 (`/v1/hyperliquid/hip3`), Spot and Lighter. Match exactly // on `/v1/hyperliquid` to avoid catching the nested prefixes. @@ -137,10 +206,6 @@ impl TradesResource { "trades.recent() is not available on Hyperliquid; use trades.list() with a time range".to_string(), )); } - let mut qp = vec![]; - if let Some(l) = limit { - qp.push(("limit", l.to_string())); - } - Ok(qp) + Ok(()) } } diff --git a/src/types.rs b/src/types.rs index d4a0632..4efea09 100644 --- a/src/types.rs +++ b/src/types.rs @@ -64,9 +64,14 @@ where // --------------------------------------------------------------------------- /// Metadata returned with every API response. +/// +/// Not returned by any method; [`ResponseMeta`] is the full `meta` block that +/// [`MetaResponse`] carries. #[derive(Debug, Clone, Deserialize)] pub struct ApiMeta { + #[serde(default)] pub count: usize, + #[serde(default)] pub request_id: String, pub next_cursor: Option, /// Coverage start date (ISO 8601), present when the requested window ends @@ -80,29 +85,23 @@ pub struct ApiMeta { #[derive(Debug, Clone, Deserialize)] pub(crate) struct ApiEnvelope { pub data: T, - pub meta: Option, -} - -/// A paginated response containing data and an optional cursor for the next page. -#[derive(Debug, Clone)] -pub struct CursorResponse { - /// The response data. - pub data: T, - /// Pass this value as the `cursor` parameter to fetch the next page. - /// `None` means there are no more pages. - pub next_cursor: Option, + #[serde(default)] + #[allow(dead_code)] + pub meta: Option, } /// The full `meta` block of a response. /// -/// Returned by the methods that give snapshot or finalization context with -/// their data, such as the account positions resources. Every field is -/// optional on the wire and is `None` (or `0` / empty for `count` and -/// `request_id`) when the server did not send it. Instants are RFC 3339 UTC -/// strings. The positions routes and the trades finalization fields always -/// send milliseconds (`2026-09-25T00:00:00.000Z`), while instants in data -/// rows carry a fraction only when it is not zero (`2026-09-25T00:00:00Z`), -/// so parse both before comparing them. +/// Every paged method returns it on [`MetaResponse::meta`] (a +/// [`CursorResponse`] is the same type), as do the methods that give snapshot +/// or finalization context with their data, such as the account positions +/// resources. Every field is optional on the wire and is `None` (or `0` / +/// empty for `count` and `request_id`) when the server did not send it. +/// Instants are RFC 3339 UTC strings. The positions routes and the trades +/// finalization fields always send milliseconds +/// (`2026-09-25T00:00:00.000Z`), while instants in data rows carry a +/// fraction only when it is not zero (`2026-09-25T00:00:00Z`), so parse both +/// before comparing them. /// /// New fields may be added in minor releases, so the struct cannot be built /// with a literal outside this crate; start from `ResponseMeta::default()`. @@ -116,8 +115,23 @@ pub struct ResponseMeta { #[serde(default)] pub request_id: String, /// Pass this value as the `cursor` parameter to fetch the next page. + /// Present exactly when `has_more` is `true`. #[serde(default)] pub next_cursor: Option, + /// `true` while more pages may follow, `false` on the last page. Every + /// cursor-paged route sends it; a final page can be empty when the page + /// before it was exactly full. + #[serde(default)] + pub has_more: Option, + /// The canonical public symbol a per-symbol route answered for (`BTC`, + /// `km:US500`, `#0`, `HYPE-USDC`), which can differ from the path input: + /// a HIP-4 `"0"` is answered as `#0`. + #[serde(default)] + pub symbol: Option, + /// The venue a per-symbol route answered for: `hyperliquid`, `hip3`, + /// `hip4`, `spot`, `lighter` or `rh-lighter`. + #[serde(default)] + pub venue: Option, /// Instant the returned state describes: the snapshot tick, the hour, or /// the requested as-of time. Taken from the data, never the request time. #[serde(default)] @@ -190,8 +204,38 @@ pub(crate) struct MetaEnvelope { pub meta: Option, } -/// A response with its data, the cursor for the next page, and the full -/// `meta` block. +/// A response with its data, the paging state and the full `meta` block. +/// +/// Every paged method returns one (as [`CursorResponse`], the same type). +/// To page, pass `next_cursor` back as the `cursor` parameter with unchanged +/// filters while `has_more` is `true`: +/// +/// ```no_run +/// # use oxarchive::OxArchive; +/// # use oxarchive::resources::trades::GetTradesParams; +/// # async fn example() -> oxarchive::Result<()> { +/// # let client = OxArchive::new("key")?; +/// // The last ten minutes +/// let end = chrono::Utc::now(); +/// let start = end - chrono::Duration::minutes(10); +/// let mut cursor = None; +/// loop { +/// let page = client.hyperliquid.trades.history("BTC", GetTradesParams { +/// start: start.into(), +/// end: end.into(), +/// cursor, +/// limit: Some(1000), +/// side: None, +/// }).await?; +/// println!("{} trades for {:?} on {:?}", page.data.len(), page.meta.symbol, page.meta.venue); +/// if !page.has_more { +/// break; +/// } +/// cursor = page.next_cursor; +/// } +/// # Ok(()) +/// # } +/// ``` #[derive(Debug, Clone)] pub struct MetaResponse { /// The response data. @@ -199,15 +243,26 @@ pub struct MetaResponse { /// Pass this value as the `cursor` parameter to fetch the next page. /// `None` means there are no more pages. pub next_cursor: Option, - /// Snapshot context, finalization boundaries and notices. + /// `true` while more pages may follow. It is `meta.has_more` when the + /// server sent it, and otherwise whether `next_cursor` is set. Stop + /// paging when it is `false`. + pub has_more: bool, + /// The full `meta` block: `symbol` and `venue` on per-symbol routes, + /// snapshot context, finalization boundaries and notices. pub meta: ResponseMeta, } +/// A paginated response: the data, the cursor for the next page, `has_more` +/// and the full `meta` block. The same type as [`MetaResponse`]. +pub type CursorResponse = MetaResponse; + impl MetaResponse { pub(crate) fn new(data: T, meta: ResponseMeta) -> Self { + let has_more = meta.has_more.unwrap_or(meta.next_cursor.is_some()); Self { data, next_cursor: meta.next_cursor.clone(), + has_more, meta, } } @@ -348,7 +403,7 @@ pub struct OrderBook { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Trade { pub coin: String, - /// `"A"` (ask/sell) or `"B"` (bid/buy) — taker side. + /// `"A"` (ask/sell) or `"B"` (bid/buy): the side of this fill. pub side: String, pub price: String, pub size: String, @@ -383,7 +438,7 @@ pub struct Trade { /// positive for the taker side. Present only on HIP-3 fills. pub deployer_fee: Option, /// 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 + /// 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. pub priority_gas: Option, /// Client order ID. @@ -407,7 +462,7 @@ pub struct Instrument { pub is_active: bool, } -/// A Lighter.xyz instrument with fee and precision metadata. +/// A Lighter instrument with fee and precision metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LighterInstrument { pub symbol: String, @@ -448,21 +503,53 @@ pub struct Hip3Instrument { /// wire format (`PURR/USDC`, `@107`) internally. Spot pairs have no funding /// rate, no open interest, and no liquidations: those are perp-only /// constructs. +/// +/// The pairs routes send the base and quote as `base_token_name` and +/// `quote_token_name`, the wire-format pair as `name` and the spot index as +/// `pair_index`; they fill `base`, `quote`, `wire_symbol` and `spot_index`. +/// The other registry fields (`is_canonical`, token ids, size and wei +/// decimals, `base_token_address`, `deployer_fee_share`, `first_seen_at`, +/// `last_updated_at`) are kept in `extra`. For prices, read the pair's order +/// book or trades. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpotPair { /// Dashed canonical symbol (e.g. `HYPE-USDC`). pub symbol: String, - /// Base asset (e.g. `HYPE`). + /// Base token (e.g. `HYPE`). Sent as `base_token_name`. + #[serde(alias = "base_token_name")] pub base: Option, - /// Quote asset (e.g. `USDC`). + /// Quote token (e.g. `USDC`). Sent as `quote_token_name`. + #[serde(alias = "quote_token_name")] pub quote: Option, - /// Hyperliquid wire-format pair (e.g. `PURR/USDC` or `@107`). + /// Hyperliquid wire-format pair (e.g. `PURR/USDC` or `@107`). Sent as + /// `name`. + #[serde(alias = "name")] pub wire_symbol: Option, - /// Hyperliquid spot index (the `@N` form), when applicable. + /// Index of the pair in Hyperliquid's spot universe (the `N` of `@N`). + /// Sent as `pair_index`. + #[serde(alias = "pair_index")] pub spot_index: Option, + /// Not returned by the pairs routes; always `None`. + #[deprecated( + since = "1.12.0", + note = "the pairs routes do not return prices; read the order book or trades" + )] pub mark_price: Option, + /// Not returned by the pairs routes; always `None`. + #[deprecated( + since = "1.12.0", + note = "the pairs routes do not return prices; read the order book or trades" + )] pub mid_price: Option, + /// Not returned by the pairs routes; always `None`. The registry's own + /// times are `first_seen_at` and `last_updated_at` in `extra`. + #[deprecated( + since = "1.12.0", + note = "the pairs routes do not return it; see first_seen_at and last_updated_at in extra" + )] pub latest_timestamp: Option, + /// Not returned by the pairs routes; always `None`. + #[deprecated(since = "1.12.0", note = "the pairs routes do not return it")] pub is_active: Option, #[serde(default, flatten)] pub extra: std::collections::HashMap, @@ -477,7 +564,11 @@ pub struct SpotTwapStatus { pub user_address: Option, pub side: Option, pub status: Option, + /// Executed size as a decimal string; the API may send a number. + #[serde(default, deserialize_with = "deserialize_opt_number_or_string")] pub executed_size: Option, + /// Executed notional as a decimal string; the API may send a number. + #[serde(default, deserialize_with = "deserialize_opt_number_or_string")] pub executed_notional: Option, pub minutes: Option, pub randomize: Option, @@ -755,6 +846,31 @@ impl CandleInterval { } } +/// Side filter for trade queries (`side=buy` or `side=sell`). +/// +/// `Buy` keeps the rows whose `side` is `"B"` and `Sell` the rows whose +/// `side` is `"A"`. The filter reads each row's own side: where a trade is +/// returned as one fill per side (maker and taker), `Buy` keeps the buying +/// fill of each trade, whichever of the two was the taker. The filter applies +/// before paging, so a full page still holds `limit` matching rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TradeSide { + /// Rows with `side` `"B"`. + Buy, + /// Rows with `side` `"A"`. + Sell, +} + +impl TradeSide { + /// The wire value: `"buy"` or `"sell"`. + pub fn as_str(&self) -> &'static str { + match self { + TradeSide::Buy => "buy", + TradeSide::Sell => "sell", + } + } +} + // --------------------------------------------------------------------------- // Liquidations // --------------------------------------------------------------------------- @@ -814,8 +930,10 @@ pub struct LiquidationVolume { pub struct LighterLiquidation { /// Market symbol, uppercase for perps (`BTC`). pub symbol: String, + /// Trade time, as an RFC 3339 UTC string with milliseconds. + pub timestamp: String, /// Trade time in Unix milliseconds. - pub timestamp: i64, + pub timestamp_ms: i64, /// Transaction time in microseconds, for ordering within a block. #[serde(default)] pub transaction_time_us: Option, @@ -897,8 +1015,10 @@ pub struct LighterLiquidation { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LighterLiquidationVolume { pub symbol: String, + /// Bucket start, as an RFC 3339 UTC string with milliseconds. + pub timestamp: String, /// Bucket start in Unix milliseconds. - pub timestamp: i64, + pub timestamp_ms: i64, /// Total liquidated notional in the quote asset, as a decimal string. #[serde(deserialize_with = "deserialize_number_or_string")] pub total_usd: String, @@ -939,7 +1059,7 @@ impl OiFundingInterval { // Lighter orderbook granularity // --------------------------------------------------------------------------- -/// Lighter.xyz orderbook snapshot granularity. +/// Lighter orderbook snapshot granularity. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LighterGranularity { Checkpoint, @@ -1039,11 +1159,13 @@ pub struct LighterLiveTrade { pub crossed: bool, /// Always `None` in live messages. pub dir: Option, - /// Always `None` in live messages. Fees are on the finalized REST record. + /// Always `None` in live messages. Fees are on the finalized REST record, + /// and replayed rows from it carry them. pub fee: Option, /// Always `None` in live messages. pub fee_token: Option, - /// Always `None` in live messages. + /// Always `None` in live messages; set on replayed rows from the + /// finalized record. pub closed_pnl: Option, /// This account's signed position before the trade, as a decimal string. pub start_position: Option, @@ -1105,9 +1227,9 @@ pub struct LighterLiveAssetCtx { /// `channel` tells the deployments apart. /// /// Decode with [`LighterLiveData::decode`], or with `ServerMsg::lighter_live_data` -/// when the `websocket` feature is enabled. Replay messages keep their -/// existing replay row shapes, which differ from these live payloads, so they -/// are not decoded by this type. +/// when the `websocket` feature is enabled. Replay rows (`historical_data` and +/// `replay_snapshot`) have the same shapes under the API version the SDK +/// requests, so they decode alike; a replayed trades row is a single fill. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LighterLiveData { /// A `lighter_orderbook` or `rh_lighter_orderbook` book. @@ -1122,7 +1244,8 @@ pub enum LighterLiveData { } impl LighterLiveData { - /// Decode the `data` field of a live `data` message. + /// Decode the `data` field of a live `data` message or of a replay row + /// (`historical_data`, `replay_snapshot`). /// /// Returns `None` when `channel` is not one of the live Lighter channels /// (`lighter_orderbook`, `lighter_trades`, `lighter_open_interest`, @@ -1437,12 +1560,68 @@ impl Default for ReconstructOptions { /// A single order in an L4 orderbook snapshot. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(from = "RawL4OrderEntry")] pub struct L4OrderEntry { pub oid: u64, pub user_address: String, pub side: String, pub price: f64, pub size: f64, + /// When the order took its place in the queue, as an RFC 3339 UTC string + /// with milliseconds. `None` when the queue time is unknown or the row + /// does not carry it. + pub timestamp: Option, + /// `timestamp` in Unix milliseconds. + pub timestamp_ms: Option, +} + +/// Wire form of [`L4OrderEntry`]: the queue time arrives as an RFC 3339 +/// string with `timestamp_ms` on current and point-in-time snapshots, and as +/// integer milliseconds on checkpoint history rows. +#[derive(Deserialize)] +struct RawL4OrderEntry { + oid: u64, + user_address: String, + side: String, + price: f64, + size: f64, + #[serde(default)] + timestamp: Option, + #[serde(default)] + timestamp_ms: Option, +} + +impl From for L4OrderEntry { + fn from(raw: RawL4OrderEntry) -> Self { + let (timestamp, timestamp_ms) = match raw.timestamp { + Some(serde_json::Value::String(s)) => { + let ms = raw.timestamp_ms.or_else(|| parse_timestamp_str(&s)); + (Some(s), ms) + } + Some(serde_json::Value::Number(n)) => match n.as_i64().filter(|ms| *ms > 0) { + // Zero means the queue time is unknown. + Some(ms) => (rfc3339_millis(ms), Some(ms)), + None => (None, None), + }, + _ => (None, None), + }; + L4OrderEntry { + oid: raw.oid, + user_address: raw.user_address, + side: raw.side, + price: raw.price, + size: raw.size, + timestamp, + timestamp_ms, + } + } +} + +/// Unix milliseconds as an RFC 3339 UTC string with milliseconds, the form +/// the API writes. +fn rfc3339_millis(ms: i64) -> Option { + chrono::DateTime::from_timestamp_millis(ms) + .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) } /// L4 orderbook snapshot with individual orders and user attribution. @@ -1509,8 +1688,12 @@ pub struct LiquidationLevelBucket { pub struct LiquidationLevels { /// Mark price at the snapshot, center of the requested range. pub mid_price: f64, - /// UTC snapshot time the levels reflect. + /// UTC snapshot time the levels reflect, as an RFC 3339 string with + /// milliseconds. pub snapshot_ts: String, + /// `snapshot_ts` in Unix milliseconds. + #[serde(default)] + pub snapshot_ts_ms: Option, /// Hyperliquid block height the snapshot reflects. pub block_number: u64, /// Total long notional at risk across the whole book. @@ -1527,7 +1710,11 @@ pub struct LiquidationLevels { /// history was requested with `summary = true`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LiquidationLevelsHistoryItem { + /// UTC snapshot time, as an RFC 3339 string with milliseconds. pub snapshot_ts: String, + /// `snapshot_ts` in Unix milliseconds. + #[serde(default)] + pub snapshot_ts_ms: Option, pub block_number: u64, pub mid_price: f64, pub total_long: f64, @@ -1577,7 +1764,11 @@ pub struct TriggerLevels { /// `None` when the history was requested with `summary = true`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TriggerLevelsHistoryItem { + /// UTC snapshot time, as an RFC 3339 string with milliseconds. pub snapshot_ts: String, + /// `snapshot_ts` in Unix milliseconds. + #[serde(default)] + pub snapshot_ts_ms: Option, pub mid_price: f64, pub total_bid_size: f64, pub total_ask_size: f64, @@ -1643,11 +1834,20 @@ pub struct OrderHistoryEntry { pub orig_size: f64, pub status: String, pub order_type: String, + /// Time in force. Empty on rows that carry none, such as `triggered` + /// rows. + #[serde(default)] pub tif: String, pub reduce_only: bool, pub is_trigger: bool, pub is_position_tpsl: bool, pub cloid: Option, + /// Trigger condition of a trigger order, on `triggered` rows. + #[serde(default)] + pub trigger_condition: Option, + /// Trigger price of a trigger order, on `triggered` rows. + #[serde(default)] + pub trigger_price: Option, } // --------------------------------------------------------------------------- @@ -2098,10 +2298,97 @@ pub struct SymbolEntry { pub extra: std::collections::BTreeMap, } -/// Body of `GET /v1/symbols`, which is not wrapped in `data`. +/// Body of `GET /v1/symbols` (internal use). With the API version the SDK +/// sends, the symbols arrive as the envelope's `data` array; the older body +/// carried them in a `symbols` member. #[derive(Debug, Clone, Deserialize)] -pub(crate) struct SymbolsResponse { - pub symbols: Vec, +#[serde(untagged)] +pub(crate) enum SymbolsResponse { + List(Vec), + Legacy { symbols: Vec }, +} + +impl SymbolsResponse { + pub(crate) fn into_symbols(self) -> Vec { + match self { + SymbolsResponse::List(symbols) | SymbolsResponse::Legacy { symbols } => symbols, + } + } +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +/// One venue and datatype the API serves, from `client.capabilities()` +/// (`GET /v1/capabilities`). +/// +/// A row lists the REST routes and WebSocket channels for the datatype on +/// that venue, whether the channels stream live and replay history, the +/// first instant served, the cadence, the largest page and the accepted +/// intervals. It is the source for which channels can be replayed: see +/// [`Capability::for_channel`]. +/// +/// New fields may be added in minor releases, so the struct cannot be built +/// with a literal outside this crate. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Capability { + /// `hyperliquid`, `hip3`, `hip4`, `spot`, `lighter` or `rh-lighter`. + pub venue: String, + /// Datatype id, for example `trades`, `l4_diffs` or `l2_full_depth`. + pub datatype: String, + /// REST route templates, for example `/v1/hyperliquid/trades/{symbol}`. + #[serde(default)] + pub rest_routes: Vec, + /// WebSocket channels for this venue and datatype. + #[serde(default)] + pub ws_channels: Vec, + /// `true` when a WebSocket subscription streams it live. + #[serde(default)] + pub live: bool, + /// `true` when a WebSocket replay serves its history. + #[serde(default)] + pub replay: bool, + /// First instant served, as an RFC 3339 UTC string, when the datatype has + /// a fixed start. + #[serde(default)] + pub available_from: Option, + /// `event`, `snapshot`, `sample`, `interval` or `reference`. + #[serde(default)] + pub cadence: Option, + /// Largest `limit` one page accepts. + #[serde(default)] + pub page_limit: Option, + /// Values `interval` accepts, empty when the datatype has none. + #[serde(default)] + pub intervals: Vec, + /// Further detail, such as how replay behaves. + #[serde(default)] + pub notes: Option, +} + +impl Capability { + /// Whether this row lists the WebSocket `channel`. + pub fn has_channel(&self, channel: &str) -> bool { + self.ws_channels.iter().any(|c| c == channel) + } + + /// The row that lists the WebSocket `channel`, if any. + /// + /// ```no_run + /// # use oxarchive::{Capability, OxArchive}; + /// # async fn example() -> oxarchive::Result<()> { + /// # let client = OxArchive::new("key")?; + /// let rows = client.capabilities().await?; + /// let replayable = Capability::for_channel(&rows, "spot_l4_diffs").map(|row| row.replay); + /// println!("spot_l4_diffs replay: {replayable:?}"); + /// # Ok(()) + /// # } + /// ``` + pub fn for_channel<'a>(rows: &'a [Capability], channel: &str) -> Option<&'a Capability> { + rows.iter().find(|row| row.has_channel(channel)) + } } // --------------------------------------------------------------------------- @@ -2115,8 +2402,10 @@ pub(crate) struct SymbolsResponse { /// no trades. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CvdBucket { - /// Bucket open time, in Unix milliseconds (UTC). - pub timestamp: i64, + /// Bucket open time, as an RFC 3339 UTC string with milliseconds. + pub timestamp: String, + /// Bucket open time, in Unix milliseconds. + pub timestamp_ms: i64, /// Taker buy notional in the bucket. pub buy_volume: f64, /// Taker sell notional in the bucket. @@ -2146,8 +2435,10 @@ pub struct Hip3OracleExternalPrice { pub mark_price: Option, /// Source block number. pub block_number: i64, + /// Source timestamp, as an RFC 3339 UTC string with milliseconds. + pub timestamp: String, /// Source timestamp, in Unix milliseconds. - pub timestamp: i64, + pub timestamp_ms: i64, } /// Instantaneous discovery bounds of a HIP-3 market, from @@ -2175,8 +2466,10 @@ pub struct Hip3OracleDiscoveryBounds { pub upper_bound: f64, /// Source block number. pub block_number: i64, + /// Source timestamp, as an RFC 3339 UTC string with milliseconds. + pub timestamp: String, /// Source timestamp, in Unix milliseconds. - pub timestamp: i64, + pub timestamp_ms: i64, } // --------------------------------------------------------------------------- diff --git a/src/ws/client.rs b/src/ws/client.rs index bc0b621..53c7b47 100644 --- a/src/ws/client.rs +++ b/src/ws/client.rs @@ -16,7 +16,8 @@ use std::time::Duration; use tokio::sync::{mpsc, Mutex}; use tokio_tungstenite::{connect_async, tungstenite::Message}; -use crate::error::{Error, Result}; +use crate::error::{Error, ErrorCode, Result}; +use crate::http::API_VERSION; use crate::types::LighterLiveData; // --------------------------------------------------------------------------- @@ -184,52 +185,83 @@ fn validate_subscribe_interval(channel: &str, interval_ms: u32) -> Result<()> { Ok(()) } -/// Hyperliquid core L4 channels with checkpoint-anchored replay support. +/// Hyperliquid core L4 channels. Like every L4 channel they support +/// checkpoint-anchored replay; see [`L4_REPLAY_CHANNELS`] for all eight. pub const CORE_L4_REPLAY_CHANNELS: [&str; 2] = ["l4_diffs", "l4_orders"]; -/// L4 channel families that are live-only and must not inherit core replay. -pub const LIVE_ONLY_L4_CHANNELS: [&str; 6] = [ +/// Every L4 channel: Hyperliquid core, HIP-3, Spot and HIP-4 order book +/// diffs and order lifecycle events. All of them stream live and replay. +/// +/// Replay is checkpoint-anchored and bulk: the nearest L4 checkpoint at or +/// before `start` arrives as an `l4_snapshot` frame, then the events follow +/// as ordered `l4_batch` frames, as fast as the server can send them +/// (`speed` is ignored). It is single-channel only and `replay.seek` is not +/// supported. +pub const L4_REPLAY_CHANNELS: [&str; 8] = [ + "l4_diffs", + "l4_orders", "hip3_l4_diffs", "hip3_l4_orders", - "hip4_l4_diffs", - "hip4_l4_orders", "spot_l4_diffs", "spot_l4_orders", + "hip4_l4_diffs", + "hip4_l4_orders", ]; -/// Return whether a channel supports the core Hyperliquid L4 replay contract. +/// Return whether a channel is a Hyperliquid core L4 channel. pub fn is_core_l4_replay_channel(channel: &str) -> bool { CORE_L4_REPLAY_CHANNELS.contains(&channel) } -/// Return whether an L4 channel is explicitly live-only. -pub fn is_live_only_l4_channel(channel: &str) -> bool { - LIVE_ONLY_L4_CHANNELS.contains(&channel) +/// Return whether a channel is one of the eight L4 channels. +pub fn is_l4_channel(channel: &str) -> bool { + L4_REPLAY_CHANNELS.contains(&channel) } -const LIVE_ONLY_L4_REPLAY_ERROR: &str = - "HIP-3, HIP-4, and Spot L4 channels are live-only; replay is supported only for Hyperliquid core l4_diffs and l4_orders."; - -/// Full-depth L2 order book channels. Both are live-only. -pub const LIVE_ONLY_FULL_DEPTH_CHANNELS: [&str; 2] = ["orderbook_full", "hip3_orderbook_full"]; +/// The HIP-3, HIP-4 and Spot L4 channels, which were live-only before +/// replay was added for them. Every one of them now replays. +#[deprecated( + since = "1.12.0", + note = "every L4 channel now supports replay; use L4_REPLAY_CHANNELS" +)] +pub const LIVE_ONLY_L4_CHANNELS: [&str; 6] = [ + "hip3_l4_diffs", + "hip3_l4_orders", + "hip4_l4_diffs", + "hip4_l4_orders", + "spot_l4_diffs", + "spot_l4_orders", +]; -/// Return whether a channel is a live-only full-depth L2 order book channel. -pub fn is_live_only_full_depth_channel(channel: &str) -> bool { - LIVE_ONLY_FULL_DEPTH_CHANNELS.contains(&channel) +/// Always `false`: no L4 channel is live-only any more. +#[deprecated( + since = "1.12.0", + note = "every L4 channel now supports replay; this always returns false" +)] +pub fn is_live_only_l4_channel(_channel: &str) -> bool { + false } -/// Error returned when a replay names a full-depth order book channel. -pub const FULL_DEPTH_REPLAY_ERROR: &str = - "orderbook_full and hip3_orderbook_full are live-only. For full-depth history, use the l2_orderbook REST resource."; +/// The full-depth L2 order book channels, aggregated from L4: +/// `orderbook_full` (Hyperliquid core) and `hip3_orderbook_full` (HIP-3). +/// +/// Both stream live and replay. Replay reads the L4 history: the book at the +/// nearest L4 checkpoint at or before `start` arrives as an `l4_snapshot` +/// frame, then price-level changes as `l4_batch` frames, one per 100 ms of +/// event time, as on the live stream. Replay is bulk (`speed` is ignored) +/// and single-channel only. +pub const FULL_DEPTH_CHANNELS: [&str; 2] = ["orderbook_full", "hip3_orderbook_full"]; + +/// Return whether a channel is a full-depth L2 order book channel. +pub fn is_full_depth_channel(channel: &str) -> bool { + FULL_DEPTH_CHANNELS.contains(&channel) +} -fn validate_replay_channel(channel: &str) -> Result<()> { - if is_live_only_l4_channel(channel) { - return Err(Error::InvalidParam(LIVE_ONLY_L4_REPLAY_ERROR.to_string())); - } - if is_live_only_full_depth_channel(channel) { - return Err(Error::InvalidParam(FULL_DEPTH_REPLAY_ERROR.to_string())); - } - Ok(()) +/// Return whether a channel replays only on its own: the eight L4 channels +/// and the two full-depth channels. [`OxArchiveWs::replay_multi`] rejects +/// them before sending; use [`OxArchiveWs::replay`]. +pub fn is_single_channel_replay(channel: &str) -> bool { + is_l4_channel(channel) || is_full_depth_channel(channel) } // --------------------------------------------------------------------------- @@ -254,20 +286,28 @@ pub enum ClientMsg { Unsubscribe { channel: String, symbol: Option }, #[serde(rename = "ping")] Ping, + /// A single-channel replay. `end` and `speed` are left out of the + /// request when `None`; the server then replays up to now at 1x. #[serde(rename = "replay")] Replay { channel: String, symbol: String, start: i64, + #[serde(skip_serializing_if = "Option::is_none")] end: Option, + #[serde(skip_serializing_if = "Option::is_none")] speed: Option, }, + /// A multi-channel replay. `end` and `speed` are left out of the request + /// when `None`, as on [`ClientMsg::Replay`]. #[serde(rename = "replay")] ReplayMulti { channels: Vec, symbol: String, start: i64, + #[serde(skip_serializing_if = "Option::is_none")] end: Option, + #[serde(skip_serializing_if = "Option::is_none")] speed: Option, }, #[serde(rename = "replay.pause")] @@ -322,9 +362,21 @@ pub enum ServerMsg { /// `Stopped the lighter_trades stream for BTC: ...` means the server ended /// that subscription; subscribe again to resume. `lighter_orderbook` and /// `rh_lighter_orderbook` send the newest book at most once per interval - /// and never send an older book in place of a newer one. + /// and never send an older book in place of a newer one. Those lag + /// notices carry [`ErrorCode::SlowConsumer`]. + /// + /// `error_code` is the stable reason to branch on, the same codes as REST + /// errors plus two for the WebSocket: `slow_consumer` (the connection fell + /// behind and messages were dropped; subscribe again or restart the + /// replay to resync) and `endpoint_unsupported` (this endpoint does not + /// serve the channel or operation; the message names the one that does). + /// A channel that does not offer a mode (live, replay, `replay.seek`) + /// answers `unsupported_for_venue`. Error { message: String, + /// Stable machine-readable reason, `None` when the server sent none. + #[serde(default)] + error_code: Option, }, /// A live data message. /// @@ -336,17 +388,18 @@ pub enum ServerMsg { symbol: Option, data: serde_json::Value, }, - /// Initial order-level L4 state for a live subscription or core replay. + /// Initial order-level L4 state for a live subscription or a replay. /// - /// For core Hyperliquid `l4_diffs` and `l4_orders` replay, this checkpoint - /// frame is emitted first and is followed by ordered [`ServerMsg::L4Batch`] - /// frames. `data` contains the full `bids`/`asks` book plus checkpoint - /// metadata; large symbols can be tens of MB of JSON. HIP-3, HIP-4, and - /// Spot L4 channels remain live-only and never use this replay sequence. + /// For an L4 replay (every channel in [`L4_REPLAY_CHANNELS`]), this + /// checkpoint frame is emitted first and is followed by ordered + /// [`ServerMsg::L4Batch`] frames. `data` contains the full `bids`/`asks` + /// book plus checkpoint metadata; large symbols can be tens of MB of + /// JSON. /// - /// The full-depth L2 channels, `orderbook_full` and `hip3_orderbook_full` - /// (live-only), also open with this frame: `data` holds the aggregated - /// `bids` and `asks` at every price level, without user attribution. + /// The full-depth L2 channels, `orderbook_full` and `hip3_orderbook_full`, + /// also open with this frame, live and in replay: `data` holds the + /// aggregated `bids` and `asks` at every price level, without user + /// attribution. L4Snapshot { channel: String, coin: String, @@ -356,10 +409,10 @@ pub enum ServerMsg { timestamp: i64, data: serde_json::Value, }, - /// Ordered L4 event batch for a live stream or core replay. + /// Ordered L4 event batch for a live stream or a replay. /// - /// In core Hyperliquid replay, apply every item in each batch in array - /// order after [`ServerMsg::L4Snapshot`]. Diff and order-lifecycle items + /// In an L4 replay, apply every item in each batch in array order after + /// [`ServerMsg::L4Snapshot`]. Diff and order-lifecycle items /// have channel-specific fields, so the payload remains JSON while the /// envelope and event ordering are typed by this enum. /// @@ -464,16 +517,36 @@ pub enum ServerMsg { } impl ServerMsg { - /// Decode a live Lighter `data` message into a typed payload. Covers the - /// mainnet `lighter_*` and the Robinhood Chain `rh_lighter_*` channels. + /// Decode a Lighter order book, trades, open interest or funding payload + /// into a typed value. Covers the mainnet `lighter_*` and the Robinhood + /// Chain `rh_lighter_*` channels. /// - /// Returns `None` for every other message, including Lighter replay - /// messages (`historical_data`, `replay_snapshot`), whose rows keep their - /// existing replay shapes. Returns `Some(Err(..))` when a live Lighter + /// Live `data` messages and replay rows (`historical_data` and + /// `replay_snapshot`) decode alike: the connection asks for the current + /// API version, under which replay rows have the live shapes. A replayed + /// `lighter_trades` row is one fill leg, so it decodes to a one-element + /// `Trades` value. + /// + /// Returns `None` for every other message and channel, including + /// `lighter_candles`, `lighter_l3_orderbook` and `rh_lighter_candles`, + /// which have no live shape. Returns `Some(Err(..))` when a Lighter /// payload does not match the expected shape. pub fn lighter_live_data(&self) -> Option> { match self { - ServerMsg::Data { channel, data, .. } => LighterLiveData::decode(channel, data), + ServerMsg::Data { channel, data, .. } + | ServerMsg::HistoricalData { channel, data, .. } + | ServerMsg::ReplaySnapshot { channel, data, .. } => { + LighterLiveData::decode(channel, data) + } + _ => None, + } + } + + /// The `error_code` of an [`ServerMsg::Error`], or `None` for other + /// messages and for errors the server sent without a code. + pub fn error_code(&self) -> Option<&ErrorCode> { + match self { + ServerMsg::Error { error_code, .. } => error_code.as_ref(), _ => None, } } @@ -483,6 +556,16 @@ impl ServerMsg { // WebSocket client // --------------------------------------------------------------------------- +/// The connection URL: the endpoint with the API key and the API version on +/// its query string. +fn connect_url(ws_url: &str, api_key: &str) -> String { + let separator = if ws_url.contains('?') { '&' } else { '?' }; + format!( + "{ws_url}{separator}apiKey={}&version={API_VERSION}", + urlencoding::encode(api_key) + ) +} + type WsSink = futures_util::stream::SplitSink>, Message>; @@ -515,8 +598,13 @@ impl OxArchiveWs { /// /// Returns a receiver for server messages. The connection is maintained /// in a background task that handles pings and reconnection. + /// + /// The connection asks for API version [`API_VERSION`] (`version` on the + /// query string), which selects the message shapes this SDK decodes: + /// `error_code` on error messages and, on Lighter replay, the live row + /// shapes. pub async fn connect(&mut self) -> Result<()> { - let url = format!("{}?apiKey={}", self.options.ws_url, self.options.api_key); + let url = connect_url(&self.options.ws_url, &self.options.api_key); let (ws_stream, _) = connect_async(&url) .await .map_err(|e| Error::WebSocket(e.to_string()))?; @@ -647,13 +735,20 @@ impl OxArchiveWs { /// Start a bounded historical replay on a single channel. /// /// All six `lighter_*` channels and all five `rh_lighter_*` channels - /// support replay. Lighter replay rows keep their stored shapes, which - /// differ from the live Lighter payloads decoded by - /// [`ServerMsg::lighter_live_data`]. Hyperliquid - /// core `l4_diffs` and `l4_orders` replay as `l4_snapshot` followed by - /// ordered `l4_batch` frames, and ignore `speed`. HIP-3, HIP-4, and Spot L4 - /// channels and the full-depth `orderbook_full` and `hip3_orderbook_full` - /// channels are live-only and are rejected before a request is sent. A + /// support replay. Lighter order book, trades, open interest and funding + /// rows arrive in the live shapes and decode with + /// [`ServerMsg::lighter_live_data`]. + /// + /// Every L4 channel ([`L4_REPLAY_CHANNELS`]: Hyperliquid core, HIP-3, + /// Spot and HIP-4) and both full-depth channels + /// ([`FULL_DEPTH_CHANNELS`]) replay as an `l4_snapshot` followed by + /// ordered `l4_batch` frames. That replay is bulk: `speed` is ignored and + /// `replay.seek` is refused. + /// + /// Which channels replay, and from when, is listed by + /// `client.capabilities()`. A channel without replay (for example + /// `ticker` or `spot_trades`) is answered with a + /// [`ServerMsg::Error`] whose `error_code` is `unsupported_for_venue`. A /// successful replay terminates with a `replay_completed` server message. pub async fn replay( &self, @@ -663,7 +758,6 @@ impl OxArchiveWs { end: Option, speed: Option, ) -> Result<()> { - validate_replay_channel(channel)?; self.send(ClientMsg::Replay { channel: channel.to_string(), symbol: symbol.to_string(), @@ -679,11 +773,12 @@ impl OxArchiveWs { /// All channels are replayed together with data interleaved chronologically, /// including the Lighter replay channels. Every channel must belong to one /// venue family: the mainnet `lighter_*` and the Robinhood Chain - /// `rh_lighter_*` channels are separate families. Core L4 replay is single- - /// channel and cannot be included here. HIP-3, HIP-4, and Spot L4 channels - /// remain live-only. Initial `replay_snapshot` messages provide each - /// standard channel's state at `start`; the server terminates the bounded - /// replay with `replay_completed`. + /// `rh_lighter_*` channels are separate families. L4 and full-depth replay + /// is single-channel: a list that names one of those channels + /// ([`is_single_channel_replay`]) is rejected before a request is sent; + /// use [`replay`](Self::replay) for it. Initial `replay_snapshot` messages + /// provide each channel's state at `start`; the server terminates the + /// bounded replay with `replay_completed`. pub async fn replay_multi( &self, channels: &[&str], @@ -692,14 +787,10 @@ impl OxArchiveWs { end: Option, speed: Option, ) -> Result<()> { - for channel in channels { - validate_replay_channel(channel)?; - if is_core_l4_replay_channel(channel) { - return Err(Error::InvalidParam( - "Hyperliquid core L4 replay is single-channel; use replay() for l4_diffs or l4_orders." - .to_string(), - )); - } + if let Some(channel) = channels.iter().find(|c| is_single_channel_replay(c)) { + return Err(Error::InvalidParam(format!( + "{channel} supports single-channel replay only; use replay() for it." + ))); } self.send(ClientMsg::ReplayMulti { channels: channels.iter().map(|c| c.to_string()).collect(), @@ -912,11 +1003,21 @@ mod lighter_live_tests { } #[test] - fn only_live_lighter_data_messages_decode() { - // Replay rows keep their stored shapes and are not decoded as live data. - let replay = r#"{"type":"historical_data","channel":"lighter_orderbook","coin":"BTC","symbol":"BTC","timestamp":1790294171459,"data":{"bids":[],"asks":[]}}"#; + fn only_lighter_book_trades_and_stats_messages_decode() { + // Replay rows arrive in the live shapes and decode like live data. + let replay = r#"{"type":"historical_data","channel":"lighter_orderbook","coin":"BTC","symbol":"BTC","timestamp":1790521300928,"data":{"coin":"BTC","levels":[[{"n":1,"px":"84465.1","sz":"1.3517"}],[{"n":1,"px":"84469.7","sz":"0.019"}]],"time":1790521300928}}"#; let msg: ServerMsg = serde_json::from_str(replay).unwrap(); - assert!(msg.lighter_live_data().is_none()); + let Some(Ok(LighterLiveData::OrderBook(book))) = msg.lighter_live_data() else { + panic!("expected a decoded replayed lighter_orderbook row, got {msg:?}"); + }; + assert_eq!(book.bids()[0].px, "84465.1"); + assert_eq!(book.asks()[0].px, "84469.7"); + + // A replay row in the shape older API versions sent is reported, not + // silently dropped. + let legacy = r#"{"type":"historical_data","channel":"lighter_orderbook","coin":"BTC","symbol":"BTC","timestamp":1790294171459,"data":{"bids":[],"asks":[]}}"#; + let msg: ServerMsg = serde_json::from_str(legacy).unwrap(); + assert!(matches!(msg.lighter_live_data(), Some(Err(_)))); // Hyperliquid live data is not a Lighter payload. let hl = r#"{"type":"data","channel":"orderbook","coin":"BTC","symbol":"BTC","data":{"coin":"BTC","time":1,"levels":[[],[]]}}"#; @@ -998,7 +1099,60 @@ mod lighter_live_tests { #[cfg(test)] mod tests { - use super::ServerMsg; + use super::{connect_url, ClientMsg, ServerMsg}; + + #[test] + fn replay_requests_leave_out_unset_end_and_speed() { + // The server reads a missing `speed` as 1x but does not accept + // `"speed": null`, so an unset value must not be sent at all. + let single = serde_json::to_value(ClientMsg::Replay { + channel: "hip3_l4_diffs".into(), + symbol: "xyz:XYZ100".into(), + start: 1, + end: None, + speed: None, + }) + .unwrap(); + assert_eq!( + single, + serde_json::json!({"op": "replay", "channel": "hip3_l4_diffs", "symbol": "xyz:XYZ100", "start": 1}) + ); + let multi = serde_json::to_value(ClientMsg::ReplayMulti { + channels: vec!["orderbook".into(), "trades".into()], + symbol: "BTC".into(), + start: 1, + end: Some(2), + speed: None, + }) + .unwrap(); + assert_eq!( + multi, + serde_json::json!({"op": "replay", "channels": ["orderbook", "trades"], "symbol": "BTC", "start": 1, "end": 2}) + ); + let paced = serde_json::to_value(ClientMsg::Replay { + channel: "orderbook".into(), + symbol: "BTC".into(), + start: 1, + end: Some(2), + speed: Some(10.0), + }) + .unwrap(); + assert_eq!(paced["speed"], serde_json::json!(10.0)); + assert_eq!(paced["end"], serde_json::json!(2)); + } + + #[test] + fn the_connection_asks_for_the_api_version() { + assert_eq!( + connect_url("wss://api.0xarchive.io/ws", "0xa_key"), + "wss://api.0xarchive.io/ws?apiKey=0xa_key&version=2026-10-01" + ); + // An endpoint that already has a query keeps it. + assert_eq!( + connect_url("wss://example.test/ws?region=eu", "k+1"), + "wss://example.test/ws?region=eu&apiKey=k%2B1&version=2026-10-01" + ); + } #[test] fn l4_snapshot_deserializes() { diff --git a/src/ws/mod.rs b/src/ws/mod.rs index 4a3ca66..276ed1c 100644 --- a/src/ws/mod.rs +++ b/src/ws/mod.rs @@ -1,3 +1,15 @@ +//! WebSocket client for live subscriptions and historical replay: +//! `OxArchiveWs`, its options (`WsOptions`), the messages it sends +//! (`ClientMsg`) and receives (`ServerMsg`), and the channel lists. +//! +//! Requires the `websocket` feature: +//! ```toml +//! oxarchive = { version = "1.12", features = ["websocket"] } +//! ``` +//! +//! For large historical downloads, use the S3 Parquet bulk export at +//! . + #[cfg(feature = "websocket")] mod client; diff --git a/tests/api_contract.rs b/tests/api_contract.rs new file mode 100644 index 0000000..1ff1aa2 --- /dev/null +++ b/tests/api_contract.rs @@ -0,0 +1,1268 @@ +//! The 2026-10 API contract as the SDK sees it: the version header, the +//! envelope and time shapes it selects, typed error codes, `has_more` paging, +//! `meta.symbol` / `meta.venue`, `/v1/capabilities`, the filters the API +//! honours, and the verb aliases. + +use oxarchive::exchanges::{ + Hip4HistoryRange, Hip4OrderBookHistoryParams, Hip4OrderHistoryParams, Hip4TradesParams, +}; +use oxarchive::resources::l2_orderbook::L2HistoryParams; +use oxarchive::resources::l4_orderbook::L4OrderBookParams; +use oxarchive::resources::orderbook::{OrderBookHistoryParams, TickPageParams}; +use oxarchive::resources::orders::OrderHistoryParams; +use oxarchive::resources::trades::GetTradesParams; +use oxarchive::{ + Capability, Error, ErrorCode, LevelsHistoryParams, LiquidationLevelsParams, OxArchive, + RecentTradesParams, TradeSide, API_VERSION, +}; +use serde_json::{json, Value}; +use wiremock::matchers::{header, method, path, query_param, query_param_is_missing}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn client(server: &MockServer) -> OxArchive { + OxArchive::builder("test-key") + .base_url(server.uri()) + .build() + .unwrap() +} + +fn ok(data: Value, meta: Value) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(json!({"success": true, "data": data, "meta": meta})) +} + +fn trades_params() -> GetTradesParams { + GetTradesParams { + start: 1790553600000_i64.into(), + end: 1790557200000_i64.into(), + cursor: None, + limit: Some(2), + side: None, + } +} + +fn trade(side: &str) -> Value { + json!({ + "coin": "BTC", "symbol": "BTC", "side": side, "price": "84289", "size": "0.02554", + "timestamp": "2026-09-29T13:57:55.246Z", "trade_id": 166, "crossed": true + }) +} + +// --------------------------------------------------------------------------- +// Version selector +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn every_request_sends_the_api_version() { + assert_eq!(API_VERSION, "2026-10-01"); + let server = MockServer::start().await; + Mock::given(header("0xArchive-Version", "2026-10-01")) + .respond_with(ok( + json!([]), + json!({"count": 0, "request_id": "r", "has_more": false}), + )) + .expect(3) + .mount(&server) + .await; + let c = client(&server); + c.hyperliquid + .trades + .list("BTC", trades_params()) + .await + .unwrap(); + c.lighter.trades.recent("BTC", Some(1)).await.unwrap(); + c.data_quality.positions_freshness().await.unwrap(); +} + +// --------------------------------------------------------------------------- +// Envelope outliers: data quality, /v1/status/coverage, /v1/symbols +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn data_quality_status_and_coverage_read_the_envelope() { + let server = MockServer::start().await; + // The versioned bodies: `data` is the old body, `meta` has no `count`. + Mock::given(method("GET")) + .and(path("/v1/data-quality/status")) + .respond_with(ok( + json!({"status": "operational", "updated_at": "2026-09-29T14:56:01Z", + "exchanges": {"hyperliquid": {"status": "operational"}}, + "data_types": {}, "active_incidents": 0}), + json!({"request_id": "req-status"}), + )) + .expect(1) + .mount(&server) + .await; + let coverage = json!({"exchanges": [{"exchange": "hyperliquid", "data_types": { + "orderbook": {"earliest": "2023-04-15T00:00:07Z", "latest": "2026-09-29T14:56:01Z", + "total_records": 28098760700_i64, "completeness": 100.0}}}]}); + for route in ["/v1/data-quality/coverage", "/v1/status/coverage"] { + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ok(coverage.clone(), json!({"request_id": "req-cov"}))) + .expect(1) + .mount(&server) + .await; + } + let c = client(&server); + let status = c.data_quality.status().await.unwrap(); + assert_eq!(status.status, "operational"); + assert_eq!(status.active_incidents, Some(0)); + for cov in [ + c.data_quality.coverage().await.unwrap(), + c.data_quality.status_coverage().await.unwrap(), + ] { + assert_eq!(cov.exchanges.len(), 1); + assert_eq!(cov.exchanges[0].exchange, "hyperliquid"); + assert_eq!( + cov.exchanges[0].data_types["orderbook"].total_records, + Some(28098760700) + ); + } +} + +#[tokio::test] +async fn data_quality_still_reads_the_unversioned_body() { + let server = MockServer::start().await; + // Without the version the body is unchanged apart from `success` and a + // trailing `meta`. + Mock::given(method("GET")) + .and(path("/v1/data-quality/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, "status": "degraded", "exchanges": {}, "data_types": {}, + "meta": {"request_id": "req-1"} + }))) + .expect(1) + .mount(&server) + .await; + let status = client(&server).data_quality.status().await.unwrap(); + assert_eq!(status.status, "degraded"); +} + +#[tokio::test] +async fn symbols_read_the_envelope_data_array() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/symbols")) + .respond_with(ok( + json!([ + {"symbol": "#0", "exchange": "hip4", "coverage_from": "2026-05-02T00:00:00Z", + "data_types": ["trades"], "coverage_by_type": {"trades": "2026-05-02T00:00:00Z"}, + "size_per_day": {"trades": 3.93}, "is_settled": true}, + {"symbol": "BTC", "exchange": "hyperliquid", "data_types": ["orderbook"]} + ]), + json!({"count": 2, "request_id": "req-sym"}), + )) + .expect(2) + .mount(&server) + .await; + let c = client(&server); + let symbols = c.symbols().await.unwrap(); + assert_eq!(symbols.len(), 2); + assert_eq!(symbols[0].symbol, "#0"); + assert_eq!(symbols[0].is_settled, Some(true)); + assert_eq!(symbols[1].exchange, "hyperliquid"); + assert_eq!(c.list_symbols().await.unwrap().len(), 2); +} + +// --------------------------------------------------------------------------- +// Time outliers: RFC 3339 with *_ms twins +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn l4_snapshot_orders_carry_queue_time() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/orderbook/BTC/l4")) + .and(query_param("depth", "2")) + .respond_with(ok( + json!({ + "coin": "BTC", "timestamp": "2026-09-29T14:55:29.345Z", + "checkpoint_timestamp": "2026-09-29T14:55:29.345Z", "diffs_applied": 0, + "last_block_number": 1165511696_u64, "bid_count": 1, "ask_count": 1, + "total_bid_size": 0.1, "total_ask_size": 6.5, + "bids": [{"oid": 560239056846_u64, "price": 83849.0, "side": "B", "size": 0.1, + "timestamp": "2026-09-29T14:55:26.174Z", "timestamp_ms": 1790693726174_i64, + "user_address": "0x6540"}], + "asks": [{"oid": 560233980357_u64, "price": 83850.0, "side": "A", "size": 6.5, + "timestamp": null, "timestamp_ms": null, "user_address": "0x66f8"}] + }), + json!({"count": 2, "request_id": "r", "symbol": "BTC", "venue": "hyperliquid"}), + )) + .expect(1) + .mount(&server) + .await; + let book = client(&server) + .hyperliquid + .l4_orderbook + .get( + "BTC", + Some(L4OrderBookParams { + timestamp: None, + depth: Some(2), + }), + ) + .await + .unwrap(); + assert_eq!( + book.bids[0].timestamp.as_deref(), + Some("2026-09-29T14:55:26.174Z") + ); + assert_eq!(book.bids[0].timestamp_ms, Some(1790693726174)); + // Queue time unknown: null, never the epoch. + assert_eq!(book.asks[0].timestamp, None); + assert_eq!(book.asks[0].timestamp_ms, None); +} + +#[tokio::test] +async fn l4_checkpoint_history_orders_read_integer_queue_times() { + let server = MockServer::start().await; + // Checkpoint history rows carry the queue time as integer milliseconds. + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/orderbook/BTC/l4/history")) + .respond_with(ok( + json!([{ + "coin": "BTC", "timestamp": "2026-09-29T13:42:26.211Z", + "checkpoint_timestamp": "2026-09-29T13:42:26.211Z", "diffs_applied": 0, + "last_block_number": 1165450000_u64, "bid_count": 1, "ask_count": 1, + "total_bid_size": 0.0022, "total_ask_size": 0.05934, + "bids": [{"oid": 560154109569_u64, "user_address": "0x31de", "side": "B", + "price": 84218.0, "size": 0.0022, "timestamp": 1790689430487_i64}], + "asks": [{"oid": 560154121856_u64, "user_address": "0xa1d2", "side": "A", + "price": 84219.0, "size": 0.05934, "timestamp": 0}] + }]), + json!({"count": 1, "request_id": "r", "has_more": false}), + )) + .expect(1) + .mount(&server) + .await; + let page = client(&server) + .hyperliquid + .l4_orderbook + .history( + "BTC", + oxarchive::resources::l4_orderbook::L4HistoryParams { + start: 1.into(), + end: 2.into(), + cursor: None, + limit: Some(1), + }, + ) + .await + .unwrap(); + let bid = &page.data[0].bids[0]; + assert_eq!(bid.timestamp.as_deref(), Some("2026-09-29T13:43:50.487Z")); + assert_eq!(bid.timestamp_ms, Some(1790689430487)); + let ask = &page.data[0].asks[0]; + assert_eq!(ask.timestamp, None); + assert_eq!(ask.timestamp_ms, None); +} + +#[tokio::test] +async fn levels_and_trigger_levels_carry_snapshot_ts_ms() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/liquidations/BTC/levels")) + .respond_with(ok( + json!({"mid_price": 83787.0, "snapshot_ts": "2026-09-29T14:51:47.000Z", + "snapshot_ts_ms": 1790693507000_i64, "block_number": 1165450000_u64, + "total_long": 1.0, "total_short": 2.0, "flagged_notional": 0.5, + "source": "raw", "levels": []}), + json!({"count": 0, "request_id": "r", "symbol": "BTC", "venue": "hyperliquid"}), + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/liquidations/BTC/levels/history")) + .respond_with(ok( + json!([{"snapshot_ts": "2026-09-29T12:56:47.000Z", "snapshot_ts_ms": 1790686607000_i64, + "block_number": 1165370000_u64, "mid_price": 84332.1, "total_long": 1.0, + "total_short": 2.0, "flagged_notional": 0.5}]), + json!({"count": 1, "request_id": "r", "has_more": true, + "next_cursor": "1790686909000", "symbol": "BTC", "venue": "hyperliquid"}), + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/orders/BTC/trigger-levels/history")) + .respond_with(ok( + json!([{"snapshot_ts": "2026-09-29T13:15:01.000Z", "snapshot_ts_ms": 1790687701000_i64, + "mid_price": 84374.0, "total_bid_size": 3.0, "total_ask_size": 2.0}]), + json!({"count": 1, "request_id": "r", "has_more": false}), + )) + .expect(1) + .mount(&server) + .await; + let c = client(&server); + let now = c + .hyperliquid + .liquidations + .levels("BTC", LiquidationLevelsParams::default()) + .await + .unwrap(); + assert_eq!(now.snapshot_ts, "2026-09-29T14:51:47.000Z"); + assert_eq!(now.snapshot_ts_ms, Some(1790693507000)); + + let history = c + .hyperliquid + .liquidations + .levels_history("BTC", LevelsHistoryParams::default()) + .await + .unwrap(); + assert_eq!(history.data[0].snapshot_ts_ms, Some(1790686607000)); + assert!(history.has_more); + assert_eq!(history.meta.symbol.as_deref(), Some("BTC")); + + let triggers = c + .hyperliquid + .orders + .trigger_levels_history("BTC", LevelsHistoryParams::default()) + .await + .unwrap(); + assert_eq!(triggers.data[0].snapshot_ts, "2026-09-29T13:15:01.000Z"); + assert_eq!(triggers.data[0].snapshot_ts_ms, Some(1790687701000)); + assert!(!triggers.has_more); +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn api_errors_expose_the_contract_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/trades/BTC")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "success": false, "code": 400, "error_code": "invalid_parameter", + "error": "Invalid side 'B'. Use buy or sell.", "request_id": "req-400", + "param": "side", "valid_values": ["buy", "sell"] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/hip4/orders/0/flow")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "success": false, "code": 404, "error_code": "unsupported_for_venue", + "error": "Order flow is not offered on HIP-4.", "request_id": "req-404", + "datatype": "order_flow", "venue": "hip4", + "available_on": [{"route": "/v1/hyperliquid/orders/{symbol}/flow", "venue": "hyperliquid"}] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/funding/BTC/current")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "success": false, "code": 401, "error_code": "unauthorized", + "error": "Missing authentication credentials.", "request_id": "req-401" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/openinterest/BTC/current")) + .respond_with(ResponseTemplate::new(429).set_body_json(json!({ + "success": false, "code": 429, "error_code": "rate_limited", "error": "Slow down" + }))) + .mount(&server) + .await; + let c = client(&server); + + match c + .hyperliquid + .trades + .list("BTC", trades_params()) + .await + .unwrap_err() + { + Error::Api { + message, + code, + request_id, + error_code, + param, + valid_values, + } => { + assert_eq!(code, 400); + assert_eq!(error_code, Some(ErrorCode::InvalidParameter)); + assert_eq!(request_id.as_deref(), Some("req-400")); + assert_eq!(param.as_deref(), Some("side")); + assert_eq!( + valid_values.as_deref(), + Some(&["buy".to_string(), "sell".to_string()][..]) + ); + assert!(message.contains("buy or sell")); + } + other => panic!("expected an API error, got {other:?}"), + } + + let unsupported = c + .hyperliquid + .hip4 + .get_order_flow("0", Default::default()) + .await + .unwrap_err(); + assert_eq!( + unsupported.error_code(), + Some(&ErrorCode::UnsupportedForVenue) + ); + assert_eq!(unsupported.status(), Some(404)); + assert!(unsupported + .to_string() + .ends_with("(HTTP 404, unsupported_for_venue)")); + + let unauthorized = c.hyperliquid.funding.current("BTC").await.unwrap_err(); + assert_eq!(unauthorized.error_code(), Some(&ErrorCode::Unauthorized)); + assert_eq!(unauthorized.request_id(), Some("req-401")); + assert_eq!(unauthorized.param(), None); + + let limited = c + .hyperliquid + .open_interest + .current("BTC") + .await + .unwrap_err(); + assert!(matches!( + limited, + Error::Api { + error_code: Some(ErrorCode::RateLimited), + code: 429, + .. + } + )); +} + +#[test] +fn every_contract_code_round_trips() { + let codes = [ + ("invalid_parameter", ErrorCode::InvalidParameter), + ("invalid_symbol", ErrorCode::InvalidSymbol), + ("invalid_interval", ErrorCode::InvalidInterval), + ("invalid_cursor", ErrorCode::InvalidCursor), + ("invalid_time_range", ErrorCode::InvalidTimeRange), + ("range_before_coverage", ErrorCode::RangeBeforeCoverage), + ( + "historical_range_exceeded", + ErrorCode::HistoricalRangeExceeded, + ), + ( + "historical_depth_exceeded", + ErrorCode::HistoricalDepthExceeded, + ), + ("unsupported_for_venue", ErrorCode::UnsupportedForVenue), + ("route_not_found", ErrorCode::RouteNotFound), + ("not_found", ErrorCode::NotFound), + ("unauthorized", ErrorCode::Unauthorized), + ("forbidden", ErrorCode::Forbidden), + ("insufficient_credits", ErrorCode::InsufficientCredits), + ("rate_limited", ErrorCode::RateLimited), + ("conflict", ErrorCode::Conflict), + ("upstream_unavailable", ErrorCode::UpstreamUnavailable), + ("internal_error", ErrorCode::InternalError), + ("endpoint_unsupported", ErrorCode::EndpointUnsupported), + ("slow_consumer", ErrorCode::SlowConsumer), + ("positions_unavailable", ErrorCode::PositionsUnavailable), + ("api_key_limit_reached", ErrorCode::ApiKeyLimitReached), + ("oauth_not_permitted", ErrorCode::OauthNotPermitted), + ]; + for (wire, code) in codes { + assert_eq!(ErrorCode::from(wire), code); + assert_eq!(code.as_str(), wire); + assert_eq!(code.to_string(), wire); + assert_eq!(serde_json::to_value(&code).unwrap(), json!(wire)); + let parsed: ErrorCode = serde_json::from_value(json!(wire)).unwrap(); + assert_eq!(parsed, code); + assert_eq!(wire.parse::().unwrap(), code); + } + let unknown: ErrorCode = serde_json::from_value(json!("invalid_query_params")).unwrap(); + assert_eq!( + unknown, + ErrorCode::Other("invalid_query_params".to_string()) + ); + assert_eq!(unknown.as_str(), "invalid_query_params"); +} + +#[test] +fn non_api_errors_have_no_contract_fields() { + let err = Error::InvalidParam("bad".to_string()); + assert_eq!(err.error_code(), None); + assert_eq!(err.status(), None); + assert_eq!(err.request_id(), None); + assert_eq!(err.param(), None); + assert_eq!(err.valid_values(), None); +} + +// --------------------------------------------------------------------------- +// Paging: has_more, next_cursor, meta.symbol and meta.venue +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn paging_follows_has_more_and_exposes_symbol_and_venue() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/hip4/trades/0")) + .and(query_param_is_missing("cursor")) + .respond_with(ok( + json!([trade("B"), trade("A")]), + json!({"count": 2, "request_id": "r1", "has_more": true, + "next_cursor": "1790536551191_470887163636273", + "symbol": "#0", "venue": "hip4"}), + )) + .expect(1) + .mount(&server) + .await; + // The page after an exactly full one can be empty; has_more is false. + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/hip4/trades/0")) + .and(query_param("cursor", "1790536551191_470887163636273")) + .respond_with(ok( + json!([]), + json!({"count": 0, "request_id": "r2", "has_more": false, + "symbol": "#0", "venue": "hip4"}), + )) + .expect(1) + .mount(&server) + .await; + + let c = client(&server); + let mut cursor = None; + let mut rows = 0; + let mut pages = 0; + loop { + let page = c + .hyperliquid + .hip4 + .get_trades( + "0", + Hip4TradesParams { + start: 1790553600000_i64.into(), + end: 1790640000000_i64.into(), + cursor, + limit: Some(2), + side: None, + }, + ) + .await + .unwrap(); + pages += 1; + rows += page.data.len(); + // The path input "0" is answered as the canonical "#0". + assert_eq!(page.meta.symbol.as_deref(), Some("#0")); + assert_eq!(page.meta.venue.as_deref(), Some("hip4")); + assert_eq!(page.has_more, page.next_cursor.is_some()); + assert_eq!(page.meta.has_more, Some(page.has_more)); + if !page.has_more { + break; + } + cursor = page.next_cursor; + } + assert_eq!((pages, rows), (2, 2)); +} + +#[tokio::test] +async fn has_more_falls_back_to_the_cursor_when_absent() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/trades/BTC")) + .respond_with(ok( + json!([trade("B")]), + json!({"count": 1, "request_id": "r", "next_cursor": "c-2"}), + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/lighter/trades/BTC")) + .respond_with(ok( + json!([trade("A")]), + json!({"count": 1, "request_id": "r"}), + )) + .expect(1) + .mount(&server) + .await; + let c = client(&server); + let with_cursor = c + .hyperliquid + .trades + .list("BTC", trades_params()) + .await + .unwrap(); + assert!(with_cursor.has_more); + assert_eq!(with_cursor.meta.has_more, None); + let without = c.lighter.trades.list("BTC", trades_params()).await.unwrap(); + assert!(!without.has_more); + assert_eq!(without.next_cursor, None); +} + +#[tokio::test] +async fn tick_history_collection_follows_the_cursor_until_has_more_is_false() { + let server = MockServer::start().await; + let checkpoint = json!({"coin": "BTC", "timestamp": "2026-09-29T14:54:36.787Z", + "bids": [{"px": "83774.9", "sz": "3.9", "n": 1}], + "asks": [{"px": "83775.9", "sz": "0.1", "n": 1}]}); + let delta = |ts: i64, seq: i64| json!({"timestamp": ts, "side": "bid", "price": 83775.8, "size": 0.2, "sequence": seq}); + // The first page: the checkpoint and a page of deltas shorter than + // 1,000, with more to follow. + Mock::given(method("GET")) + .and(path("/v1/lighter/orderbook/BTC/history")) + .and(query_param("granularity", "tick")) + .and(query_param("start", "1790693600000")) + .and(query_param_is_missing("cursor")) + .respond_with(ok( + json!({"checkpoint": checkpoint, "deltas": [delta(1790693599100, 1), delta(1790693600200, 2)], + "granularity": "tick", "next_cursor": "1790693600200", "next_cursor_seq": 2}), + json!({"count": 3, "request_id": "r1", "has_more": true, "next_cursor": "1790693600200"}), + )) + .expect(2) + .mount(&server) + .await; + // The next page keeps `start` and sends the cursor; it has no checkpoint. + Mock::given(method("GET")) + .and(path("/v1/lighter/orderbook/BTC/history")) + .and(query_param("granularity", "tick")) + .and(query_param("start", "1790693600000")) + .and(query_param("cursor", "1790693600200")) + .and(query_param("cursor_seq", "2")) + .respond_with(ok( + json!({"checkpoint": null, "deltas": [delta(1790693600200, 3)], "granularity": "tick", + "next_cursor": null, "next_cursor_seq": null}), + json!({"count": 1, "request_id": "r2", "has_more": false}), + )) + .expect(2) + .mount(&server) + .await; + let book = &client(&server).lighter.orderbook; + let snapshots = book + .collect_tick_history("BTC", 1790693600000_i64, 1790693700000_i64, None) + .await + .unwrap(); + // The checkpoint plus one snapshot per delta across both pages. + assert_eq!(snapshots.len(), 4); + + // The same pages by hand. + let params = TickPageParams::new(1790693600000_i64, 1790693700000_i64); + let first = book.history_tick_page("BTC", params.clone()).await.unwrap(); + assert!(first.checkpoint.is_some()); + assert!(first.has_more); + assert_eq!(first.next_cursor.as_deref(), Some("1790693600200")); + assert_eq!(first.next_cursor_seq, Some(2)); + let second = book + .history_tick_page("BTC", params.after(&first)) + .await + .unwrap(); + assert!(second.checkpoint.is_none()); + assert_eq!(second.deltas.len(), 1); + assert!(!second.has_more); + assert_eq!(second.next_cursor, None); +} + +#[tokio::test] +async fn triggered_order_rows_decode() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/orders/BTC/history")) + .and(query_param("triggered", "true")) + .respond_with(ok( + json!([{"block_number": 1164345047_u64, "block_time": "2026-09-28T15:37:54.359Z", + "coin": "BTC", "is_position_tpsl": false, "is_trigger": true, + "limit_price": 84000.0, "oid": 1_u64, "order_type": "Stop Market", + "orig_size": 0.1, "reduce_only": true, "seq": 2165, "side": "A", "size": 0.1, + "status": "triggered", "timestamp": "2026-09-28T15:37:54.359Z", + "trigger_condition": "Price below 84000", "trigger_price": 84000.0, + "user_address": "0xbda3"}]), + json!({"count": 1, "request_id": "r", "has_more": false}), + )) + .expect(1) + .mount(&server) + .await; + let page = client(&server) + .hyperliquid + .orders + .history( + "BTC", + OrderHistoryParams { + triggered: Some(true), + ..Default::default() + }, + ) + .await + .unwrap(); + let row = &page.data[0]; + assert_eq!(row.status, "triggered"); + assert_eq!(row.tif, ""); + assert_eq!(row.cloid, None); + assert_eq!(row.trigger_condition.as_deref(), Some("Price below 84000")); + assert_eq!(row.trigger_price, Some(84000.0)); +} + +#[tokio::test] +async fn spot_twap_reads_numeric_sizes() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/spot/twap/HYPE-USDC")) + .respond_with(ok( + json!([{"timestamp": "2026-09-28T15:44:41.426Z", "block_time": "2026-09-28T15:44:41.426Z", + "block_number": 1164350670_u64, "twap_id": 2264348, "status": "activated", + "coin": "HYPE-USDC", "user_address": "0xa41b", "side": "A", "size": 20.0, + "executed_size": 2.86, "executed_notional": 0.0, "minutes": 10, + "reduce_only": false, "randomize": false, + "started_at": "2026-09-28T15:44:41.426Z"}]), + json!({"count": 1, "request_id": "r", "has_more": false}), + )) + .expect(1) + .mount(&server) + .await; + let page = client(&server) + .hyperliquid + .spot + .twap + .by_symbol("HYPE-USDC", Default::default()) + .await + .unwrap(); + assert_eq!(page.data[0].executed_size.as_deref(), Some("2.86")); + assert_eq!(page.data[0].executed_notional.as_deref(), Some("0")); + assert_eq!(page.data[0].twap_id, 2264348); +} + +/// A spot pair row as the pairs routes send it. +fn spot_pair_row(symbol: &str, index: i64, name: &str, base: &str) -> Value { + json!({ + "coin": symbol, "symbol": symbol, "pair_index": index, "name": name, + "is_canonical": index == 0, "base_token_id": 150, "quote_token_id": 0, + "base_token_name": base, "quote_token_name": "USDC", + "base_sz_decimals": 2, "base_wei_decimals": 8, + "quote_sz_decimals": 8, "quote_wei_decimals": 8, + "deployer_fee_share": 0.0, + "first_seen_at": "2026-10-04T23:30:49.940Z", + "last_updated_at": "2026-10-04T23:30:49.940Z" + }) +} + +#[tokio::test] +async fn spot_pairs_read_the_route_field_names() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/spot/pairs")) + .respond_with(ok( + json!([ + spot_pair_row("PURR-USDC", 0, "PURR/USDC", "PURR"), + spot_pair_row("HYPE-USDC", 107, "@107", "HYPE"), + ]), + json!({"count": 2, "request_id": "r"}), + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/spot/pairs/HYPE-USDC")) + .respond_with(ok( + spot_pair_row("HYPE-USDC", 107, "@107", "HYPE"), + json!({"count": 1, "request_id": "r", "symbol": "HYPE-USDC", "venue": "spot"}), + )) + .expect(1) + .mount(&server) + .await; + + let spot = &client(&server).hyperliquid.spot; + let pairs = spot.pairs.list().await.unwrap(); + assert_eq!(pairs.len(), 2); + let purr = &pairs[0]; + assert_eq!(purr.symbol, "PURR-USDC"); + assert_eq!(purr.base.as_deref(), Some("PURR")); + assert_eq!(purr.quote.as_deref(), Some("USDC")); + assert_eq!(purr.wire_symbol.as_deref(), Some("PURR/USDC")); + assert_eq!(purr.spot_index, Some(0)); + + let hype = spot.pairs.get("HYPE-USDC").await.unwrap(); + assert_eq!(hype.base.as_deref(), Some("HYPE")); + assert_eq!(hype.quote.as_deref(), Some("USDC")); + assert_eq!(hype.wire_symbol.as_deref(), Some("@107")); + assert_eq!(hype.spot_index, Some(107)); + // Fields without a typed home stay readable in `extra`; the mapped ones + // are not repeated there. + assert_eq!(hype.extra["is_canonical"], json!(false)); + assert_eq!(hype.extra["base_sz_decimals"], json!(2)); + for mapped in ["base_token_name", "quote_token_name", "name", "pair_index"] { + assert!(!hype.extra.contains_key(mapped), "{mapped} left in extra"); + } + + // The SDK's own serialized form reads back the same. + let round_trip: oxarchive::types::SpotPair = + serde_json::from_value(serde_json::to_value(&hype).unwrap()).unwrap(); + assert_eq!(round_trip.base.as_deref(), Some("HYPE")); + assert_eq!(round_trip.wire_symbol.as_deref(), Some("@107")); + assert_eq!(round_trip.spot_index, Some(107)); +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn capabilities_are_typed_rows() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/capabilities")) + .respond_with(ok( + json!([ + {"venue": "hyperliquid", "datatype": "l2_full_depth", + "rest_routes": ["/v1/hyperliquid/orderbook/{symbol}/l2", + "/v1/hyperliquid/orderbook/{symbol}/l2/history"], + "ws_channels": ["orderbook_full"], "live": true, "replay": true, + "available_from": "2026-03-11T01:03:00.000Z", "cadence": "snapshot", + "page_limit": 100, "intervals": [], + "notes": "Derived from L4. WebSocket replay is bulk (speed is ignored) and single-channel only, anchored at L4 checkpoints."}, + {"venue": "spot", "datatype": "trades", + "rest_routes": ["/v1/hyperliquid/spot/trades/{symbol}"], + "ws_channels": ["spot_trades"], "live": true, "replay": false, + "available_from": "2025-03-22T10:50:22.000Z", "cadence": "event", + "page_limit": 1000, "intervals": [], "notes": null}, + {"venue": "hyperliquid", "datatype": "candles", "rest_routes": ["/v1/hyperliquid/candles/{symbol}"], + "ws_channels": ["candles"], "live": false, "replay": true, + "available_from": "2025-03-22T12:00:00.000Z", "cadence": "interval", + "page_limit": 10000, "intervals": ["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"], + "notes": null, "a_future_field": 1}, + {"venue": "hyperliquid", "datatype": "summary", "rest_routes": ["/v1/hyperliquid/summary/{symbol}"], + "ws_channels": [], "live": false, "replay": false, "available_from": null, + "cadence": "snapshot", "page_limit": null, "intervals": [], "notes": null} + ]), + json!({"count": 4, "request_id": "r"}), + )) + .expect(1) + .mount(&server) + .await; + let rows = client(&server).capabilities().await.unwrap(); + assert_eq!(rows.len(), 4); + let full = &rows[0]; + assert_eq!(full.venue, "hyperliquid"); + assert_eq!(full.datatype, "l2_full_depth"); + assert_eq!(full.rest_routes.len(), 2); + assert!(full.live && full.replay); + assert_eq!( + full.available_from.as_deref(), + Some("2026-03-11T01:03:00.000Z") + ); + assert_eq!(full.page_limit, Some(100)); + assert!(full.notes.as_deref().unwrap().contains("single-channel")); + assert_eq!(rows[2].intervals.len(), 8); + assert_eq!(rows[3].available_from, None); + assert_eq!(rows[3].page_limit, None); + + let spot = Capability::for_channel(&rows, "spot_trades").unwrap(); + assert!(spot.live && !spot.replay); + assert!( + Capability::for_channel(&rows, "orderbook_full") + .unwrap() + .replay + ); + assert!(Capability::for_channel(&rows, "nope").is_none()); +} + +// --------------------------------------------------------------------------- +// Filters the API honours +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn trades_side_is_sent_on_every_venue_and_on_recent() { + let server = MockServer::start().await; + for prefix in [ + "/v1/hyperliquid", + "/v1/hyperliquid/hip3", + "/v1/hyperliquid/spot", + "/v1/lighter", + "/v1/rh-lighter", + ] { + Mock::given(method("GET")) + .and(path(format!("{prefix}/trades/SYM"))) + .and(query_param("side", "buy")) + .respond_with(ok( + json!([trade("B")]), + json!({"count": 1, "request_id": "r", "has_more": false}), + )) + .expect(2) + .mount(&server) + .await; + } + for prefix in [ + "/v1/hyperliquid/hip3", + "/v1/hyperliquid/spot", + "/v1/lighter", + "/v1/rh-lighter", + ] { + Mock::given(method("GET")) + .and(path(format!("{prefix}/trades/SYM/recent"))) + .and(query_param("side", "sell")) + .and(query_param("limit", "5")) + .respond_with(ok( + json!([trade("A")]), + json!({"count": 1, "request_id": "r"}), + )) + .expect(1) + .mount(&server) + .await; + } + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/hip4/trades/0")) + .and(query_param("side", "sell")) + .respond_with(ok( + json!([trade("A")]), + json!({"count": 1, "request_id": "r"}), + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/hip4/trades/0/recent")) + .and(query_param("side", "buy")) + .respond_with(ok( + json!([trade("B")]), + json!({"count": 1, "request_id": "r"}), + )) + .expect(1) + .mount(&server) + .await; + + let c = client(&server); + let buys = || GetTradesParams { + side: Some(TradeSide::Buy), + ..trades_params() + }; + let venues = [ + &c.hyperliquid.trades, + &c.hyperliquid.hip3.trades, + &c.hyperliquid.spot.trades, + &c.lighter.trades, + &c.rh_lighter.trades, + ]; + for trades in venues { + assert_eq!(trades.list("SYM", buys()).await.unwrap().data[0].side, "B"); + assert_eq!( + trades.history("SYM", buys()).await.unwrap().data[0].side, + "B" + ); + } + for trades in &venues[1..] { + let sells = trades + .recent_with( + "SYM", + RecentTradesParams { + limit: Some(5), + side: Some(TradeSide::Sell), + }, + ) + .await + .unwrap(); + assert_eq!(sells.data[0].side, "A"); + } + // Hyperliquid core has no recent route, with or without a filter. + assert!(matches!( + c.hyperliquid + .trades + .recent_with("BTC", RecentTradesParams::default()) + .await, + Err(Error::InvalidParam(_)) + )); + + let hip4 = c + .hyperliquid + .hip4 + .get_trades( + "0", + Hip4TradesParams { + start: 1.into(), + end: 2.into(), + cursor: None, + limit: None, + side: Some(TradeSide::Sell), + }, + ) + .await + .unwrap(); + assert_eq!(hip4.data[0].side, "A"); + let hip4_recent = c + .hyperliquid + .hip4 + .get_trades_recent_with( + "0", + RecentTradesParams { + limit: None, + side: Some(TradeSide::Buy), + }, + ) + .await + .unwrap(); + assert_eq!(hip4_recent.data[0].side, "B"); + assert_eq!(TradeSide::Buy.as_str(), "buy"); + assert_eq!(TradeSide::Sell.as_str(), "sell"); +} + +#[tokio::test] +async fn order_history_sends_triggered() { + let server = MockServer::start().await; + for (route, value) in [ + ("/v1/hyperliquid/orders/BTC/history", "true"), + ("/v1/hyperliquid/hip3/orders/xyz:XYZ100/history", "false"), + ("/v1/hyperliquid/hip4/orders/0/history", "true"), + ] { + Mock::given(method("GET")) + .and(path(route)) + .and(query_param("triggered", value)) + .respond_with(ok( + json!([]), + json!({"count": 0, "request_id": "r", "has_more": false}), + )) + .expect(1) + .mount(&server) + .await; + } + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/orders/ETH/history")) + .and(query_param_is_missing("triggered")) + .respond_with(ok( + json!([]), + json!({"count": 0, "request_id": "r", "has_more": false}), + )) + .expect(1) + .mount(&server) + .await; + let c = client(&server); + c.hyperliquid + .orders + .history( + "BTC", + OrderHistoryParams { + triggered: Some(true), + ..Default::default() + }, + ) + .await + .unwrap(); + c.hyperliquid + .hip3 + .orders + .history( + "xyz:XYZ100", + OrderHistoryParams { + triggered: Some(false), + ..Default::default() + }, + ) + .await + .unwrap(); + c.hyperliquid + .hip4 + .get_order_history( + "0", + Hip4OrderHistoryParams { + triggered: Some(true), + ..Default::default() + }, + ) + .await + .unwrap(); + c.hyperliquid + .orders + .history("ETH", OrderHistoryParams::default()) + .await + .unwrap(); +} + +#[tokio::test] +async fn order_book_histories_send_depth() { + let server = MockServer::start().await; + for route in [ + "/v1/hyperliquid/orderbook/BTC/l2/history", + "/v1/hyperliquid/hip3/orderbook/xyz:XYZ100/l2/history", + "/v1/hyperliquid/hip3/orderbook/xyz:XYZ100/history", + "/v1/hyperliquid/spot/orderbook/HYPE-USDC/history", + "/v1/hyperliquid/hip4/orderbook/0/history", + ] { + Mock::given(method("GET")) + .and(path(route)) + .and(query_param("depth", "3")) + .respond_with(ok( + json!([]), + json!({"count": 0, "request_id": "r", "has_more": false}), + )) + .expect(1) + .mount(&server) + .await; + } + // A plain range still works for HIP-4 and sends no depth. + Mock::given(method("GET")) + .and(path("/v1/hyperliquid/hip4/orderbook/1/history")) + .and(query_param_is_missing("depth")) + .respond_with(ok( + json!([]), + json!({"count": 0, "request_id": "r", "has_more": false}), + )) + .expect(1) + .mount(&server) + .await; + let c = client(&server); + let l2 = || L2HistoryParams { + start: 1.into(), + end: 2.into(), + cursor: None, + limit: Some(1), + depth: Some(3), + }; + c.hyperliquid + .l2_orderbook + .history("BTC", l2()) + .await + .unwrap(); + c.hyperliquid + .hip3 + .l2_orderbook + .history("xyz:XYZ100", l2()) + .await + .unwrap(); + let book = || OrderBookHistoryParams { + start: 1.into(), + end: 2.into(), + cursor: None, + limit: Some(1), + depth: Some(3), + granularity: None, + }; + c.hyperliquid + .hip3 + .orderbook + .history("xyz:XYZ100", book()) + .await + .unwrap(); + c.hyperliquid + .spot + .orderbook + .history("HYPE-USDC", book()) + .await + .unwrap(); + c.hyperliquid + .hip4 + .get_orderbook_history( + "0", + Hip4OrderBookHistoryParams { + start: 1.into(), + end: 2.into(), + cursor: None, + limit: Some(1), + depth: Some(3), + }, + ) + .await + .unwrap(); + c.hyperliquid + .hip4 + .get_orderbook_history( + "1", + Hip4HistoryRange { + start: 1.into(), + end: 2.into(), + cursor: None, + limit: Some(1), + }, + ) + .await + .unwrap(); +} + +// --------------------------------------------------------------------------- +// Verb aliases +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn hip4_aliases_call_the_same_routes() { + let server = MockServer::start().await; + let empty_page = || { + ok( + json!([]), + json!({"count": 0, "request_id": "r", "has_more": false}), + ) + }; + for route in [ + "/v1/hyperliquid/hip4/trades/0", + "/v1/hyperliquid/hip4/openinterest/0", + "/v1/hyperliquid/hip4/prices/0", + "/v1/hyperliquid/hip4/instruments", + ] { + Mock::given(method("GET")) + .and(path(route)) + .respond_with(empty_page()) + .expect(2) + .mount(&server) + .await; + } + let hip4 = &client(&server).hyperliquid.hip4; + let trades = || Hip4TradesParams { + start: 1.into(), + end: 2.into(), + cursor: None, + limit: None, + side: None, + }; + hip4.get_trades("0", trades()).await.unwrap(); + hip4.get_trades_history("0", trades()).await.unwrap(); + let range = || Hip4HistoryRange { + start: 1.into(), + end: 2.into(), + cursor: None, + limit: None, + }; + hip4.get_open_interest("0", range()).await.unwrap(); + hip4.get_open_interest_history("0", range()).await.unwrap(); + hip4.get_prices("0", 1, 2, Some("1h"), None, None) + .await + .unwrap(); + hip4.get_price_history("0", 1, 2, Some("1h"), None, None) + .await + .unwrap(); + hip4.get_instruments().await.unwrap(); + hip4.list_instruments().await.unwrap(); +} + +// --------------------------------------------------------------------------- +// Venue name +// --------------------------------------------------------------------------- + +#[test] +fn the_venue_is_named_lighter() { + for (name, text) in [ + ("README.md", include_str!("../README.md")), + ("src/lib.rs", include_str!("../src/lib.rs")), + ("src/client.rs", include_str!("../src/client.rs")), + ("src/types.rs", include_str!("../src/types.rs")), + ("src/exchanges.rs", include_str!("../src/exchanges.rs")), + ( + "src/resources/trades.rs", + include_str!("../src/resources/trades.rs"), + ), + ( + "src/resources/orderbook.rs", + include_str!("../src/resources/orderbook.rs"), + ), + ( + "src/resources/instruments.rs", + include_str!("../src/resources/instruments.rs"), + ), + ( + "src/resources/l3_orderbook.rs", + include_str!("../src/resources/l3_orderbook.rs"), + ), + ( + "src/orderbook_reconstructor.rs", + include_str!("../src/orderbook_reconstructor.rs"), + ), + ("src/ws/client.rs", include_str!("../src/ws/client.rs")), + ("examples/basic.rs", include_str!("../examples/basic.rs")), + ( + "examples/websocket.rs", + include_str!("../examples/websocket.rs"), + ), + ] { + assert!( + !text.contains("Lighter.xyz"), + "{name} still says Lighter.xyz" + ); + } +} diff --git a/tests/coverage_contract.rs b/tests/coverage_contract.rs index 9cf023b..02d48fd 100644 --- a/tests/coverage_contract.rs +++ b/tests/coverage_contract.rs @@ -464,19 +464,24 @@ fn public_copy_keeps_family_specific_coverage() { assert!(readme.contains("2026-05-02")); assert!(readme.contains("~10s")); assert!(readme.contains("250 orders per side")); - assert!(readme.contains("March 5, 2026")); + assert!(readme.contains("2026-03-05 03:33 UTC")); assert!(!readme.contains("raw ~1 minute")); assert!(!readme.contains("no funding, no liquidations, and no candles")); assert!(readme.contains("exact starts vary by market")); - assert!(readme.contains("live HIP-4 order-book and OI bridges are paused")); - assert!(readme.contains("candles from exactly 2025-03-22T10:50:22Z")); + assert!(readme.contains( + "`hip4_orderbook` and `hip4_open_interest` replay stored history from 2026-05-02 16:51 UTC but have no live subscription" + )); + assert!(!readme.contains("bridges")); + assert!(readme.contains("Candles from 2025-03-22 10:50")); assert!(readme.contains("Spot candle history starts exactly at")); assert!(readme.contains("`1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`")); assert!(readme.contains("client.hyperliquid.spot.candles.history")); assert!(readme.contains("| `candles` | Yes | Yes | Yes | Yes |")); assert!(spot_example.contains(".spot\n .candles")); - assert!(spot_example.contains("2025-03-22T10:50:22Z")); - assert!(readme.contains("Candles from 2025-08-01")); + assert!(spot_example.contains("2025-03-22T10:50:00Z")); + assert!(readme.contains("candles from 2025-08-01")); + // Spot TWAP is REST only; the channel table lists only WebSocket channels. + assert!(!readme.contains("| `spot_twap` |")); assert!(!readme.contains("SDK passes `symbol` straight through to the URL path")); assert!(readme.contains("use the bare numeric form (`\"0\"`")); assert!(readme.contains("Legacy `\"#0\"`")); diff --git a/tests/rh_lighter_and_positions.rs b/tests/rh_lighter_and_positions.rs index 3de6bc7..101c30e 100644 --- a/tests/rh_lighter_and_positions.rs +++ b/tests/rh_lighter_and_positions.rs @@ -35,6 +35,14 @@ fn meta(count: usize) -> Value { json!({"count": count, "request_id": "req-1"}) } +/// A Unix-millisecond instant as the API writes it: RFC 3339 UTC with +/// milliseconds. +fn rfc3339(ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(ms) + .unwrap() + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + // --------------------------------------------------------------------------- // Lighter on Robinhood Chain // --------------------------------------------------------------------------- @@ -157,6 +165,7 @@ async fn rh_lighter_history_routes_send_the_same_params_as_mainnet() { end: 1788307200000_i64.into(), cursor: None, limit: Some(1000), + side: None, }, ) .await @@ -252,6 +261,7 @@ async fn trades_with_meta_expose_the_lighter_finalization_boundary() { end: 1790380800000_i64.into(), cursor: None, limit: Some(1000), + side: None, }, ) .await @@ -301,7 +311,8 @@ async fn hyperliquid_recent_with_meta_is_rejected_before_sending() { fn liquidation_row(timestamp: i64, source: &str, raw_json: &str) -> Value { json!({ "symbol": "BTC", - "timestamp": timestamp, + "timestamp": rfc3339(timestamp), + "timestamp_ms": timestamp, "transaction_time_us": timestamp * 1000 + 456, "trade_id": 987654321, "liquidation_type": "liquidation", @@ -359,7 +370,7 @@ async fn both_lighter_clients_expose_liquidation_history_and_volume() { .and(path(format!("{prefix}/liquidations/BTC/volume"))) .and(query_param("interval", "1h")) .respond_with(ok( - json!([{"symbol": "BTC", "timestamp": 1788220800000_i64, "total_usd": 27062.625, "count": 1}]), + json!([{"symbol": "BTC", "timestamp": "2026-09-01T00:00:00.000Z", "timestamp_ms": 1788220800000_i64, "total_usd": 27062.625, "count": 1}]), meta(1), )) .expect(1) @@ -386,7 +397,8 @@ async fn both_lighter_clients_expose_liquidation_history_and_volume() { let backfilled = &page.data[0]; assert_eq!(backfilled.source.as_deref(), Some("bucket")); assert_eq!(backfilled.raw_json, ""); - assert_eq!(backfilled.timestamp, 1782602083534); + assert_eq!(backfilled.timestamp, "2026-06-27T23:14:43.534Z"); + assert_eq!(backfilled.timestamp_ms, 1782602083534); assert_eq!(backfilled.price, "108250.5"); assert_eq!(backfilled.size, "0.25"); assert_eq!(backfilled.usd_amount.as_deref(), Some("27062.625")); @@ -409,6 +421,8 @@ async fn both_lighter_clients_expose_liquidation_history_and_volume() { .await .unwrap(); assert_eq!(volume.data[0].total_usd, "27062.625"); + assert_eq!(volume.data[0].timestamp, "2026-09-01T00:00:00.000Z"); + assert_eq!(volume.data[0].timestamp_ms, 1788220800000); assert_eq!(volume.data[0].count, 1); } } @@ -1421,9 +1435,9 @@ fn public_copy_describes_robinhood_chain_as_a_lighter_deployment() { assert!(normalized.contains( "Robinhood Chain history at the venue launch, 2026-06-26 20:10:26 UTC; a `start` before that returns an error" )); - assert!( - normalized.contains("Trades and liquidations from 2026-06-26 20:10:26 UTC (venue launch)") - ); + assert!(normalized.contains("trades and liquidations from 2026-06-26 20:10:26 (venue launch)")); + assert!(normalized.contains("candles from 2026-06-26 20:10 UTC")); + assert!(!normalized.contains("once candle history is enabled")); let changelog = include_str!("../CHANGELOG.md") .split_whitespace() .collect::>() diff --git a/tests/surface_parity.rs b/tests/surface_parity.rs index e9b30f6..0e1a54e 100644 --- a/tests/surface_parity.rs +++ b/tests/surface_parity.rs @@ -93,9 +93,18 @@ async fn symbols_parse_the_unwrapped_response() { // Cumulative volume delta // --------------------------------------------------------------------------- +/// A Unix-millisecond instant as the API writes it: RFC 3339 UTC with +/// milliseconds. +fn rfc3339(ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(ms) + .unwrap() + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + fn cvd_row(timestamp: i64, buy: f64, sell: f64, cumulative: f64) -> Value { json!({ - "timestamp": timestamp, + "timestamp": rfc3339(timestamp), + "timestamp_ms": timestamp, "buy_volume": buy, "sell_volume": sell, "delta": buy - sell, @@ -145,7 +154,8 @@ async fn cvd_sends_its_window_and_returns_the_cursor_and_notice() { .await .unwrap(); assert_eq!(page.data.len(), 2); - assert_eq!(page.data[0].timestamp, 1790000600000); + assert_eq!(page.data[0].timestamp, "2026-09-21T14:23:20.000Z"); + assert_eq!(page.data[0].timestamp_ms, 1790000600000); assert_eq!(page.data[0].delta, 6.5); assert_eq!(page.data[1].cumulative_delta, 4.5); assert_eq!(page.next_cursor.as_deref(), Some("1790000900000")); @@ -253,7 +263,8 @@ async fn hip3_oracle_reads_decode() { .respond_with(ok( json!({ "symbol": "xyz:XYZ100", "external_price": 30198.0, "mark_price": 30203.0, - "block_number": 1164898902_i64, "timestamp": 1790649697779_i64 + "block_number": 1164898902_i64, "timestamp": "2026-09-29T02:41:37.779Z", + "timestamp_ms": 1790649697779_i64 }), meta(1), )) @@ -265,7 +276,7 @@ async fn hip3_oracle_reads_decode() { .respond_with(ok( json!({ "symbol": "km:US500", "external_price": null, "mark_price": 749.64, - "block_number": 1, "timestamp": 2 + "block_number": 1, "timestamp": "1970-01-01T00:00:00.002Z", "timestamp_ms": 2 }), meta(1), )) @@ -282,7 +293,7 @@ async fn hip3_oracle_reads_decode() { "reference_source": "external", "max_leverage": 30, "bound_fraction": 0.03333333333333333, "lower_bound": 29191.4, "upper_bound": 31204.600000000002, "block_number": 1164898902_i64, - "timestamp": 1790649697779_i64 + "timestamp": "2026-09-29T02:41:37.779Z", "timestamp_ms": 1790649697779_i64 }), meta(1), )) @@ -301,6 +312,8 @@ async fn hip3_oracle_reads_decode() { assert_eq!(price.external_price, Some(30198.0)); assert_eq!(price.mark_price, Some(30203.0)); assert_eq!(price.block_number, 1164898902); + assert_eq!(price.timestamp, "2026-09-29T02:41:37.779Z"); + assert_eq!(price.timestamp_ms, 1790649697779); let no_external = client .hyperliquid @@ -320,6 +333,7 @@ async fn hip3_oracle_reads_decode() { .unwrap(); assert_eq!(bounds.reference_source, "external"); assert_eq!(bounds.max_leverage, 30); + assert_eq!(bounds.timestamp_ms, 1790649697779); assert!(bounds.lower_bound < bounds.reference_price); assert!(bounds.upper_bound > bounds.reference_price); } @@ -763,6 +777,7 @@ async fn webhook_refusals_surface_as_api_errors() { code, message, request_id, + .. } => { assert_eq!(code, 409); assert!(message.contains("budget")); @@ -1515,7 +1530,7 @@ async fn hip3_liquidations_keep_history_volume_and_levels() { } #[tokio::test] -async fn trades_and_history_reads_send_no_ignored_parameters() { +async fn trades_and_history_reads_send_no_unset_filters() { let server = MockServer::start().await; for route in [ "/v1/hyperliquid/trades/BTC", @@ -1546,6 +1561,7 @@ async fn trades_and_history_reads_send_no_ignored_parameters() { end: 2_i64.into(), cursor: cursor(), limit: None, + side: None, }, ) .await @@ -1560,6 +1576,7 @@ async fn trades_and_history_reads_send_no_ignored_parameters() { end: 2_i64.into(), cursor: cursor(), limit: None, + side: None, }, ) .await @@ -1574,6 +1591,7 @@ async fn trades_and_history_reads_send_no_ignored_parameters() { end: 2_i64.into(), cursor: cursor(), limit: None, + depth: None, }, ) .await diff --git a/tests/websocket_capability.rs b/tests/websocket_capability.rs index 547f695..4db77e4 100644 --- a/tests/websocket_capability.rs +++ b/tests/websocket_capability.rs @@ -2,11 +2,11 @@ use oxarchive::error::Error; use oxarchive::ws::{ - is_core_l4_replay_channel, is_lighter_live_channel, is_lighter_replay_channel, - is_lighter_replay_only_channel, is_live_only_full_depth_channel, is_live_only_l4_channel, - is_rh_lighter_channel, is_rh_lighter_live_channel, is_rh_lighter_replay_only_channel, - OxArchiveWs, ServerMsg, WsOptions, FULL_DEPTH_REPLAY_ERROR, LIGHTER_LIVE_CHANNELS, - LIGHTER_REPLAY_ONLY_CHANNELS, LIGHTER_SUBSCRIPTION_ERROR, LIVE_ONLY_FULL_DEPTH_CHANNELS, + is_core_l4_replay_channel, is_full_depth_channel, is_l4_channel, is_lighter_live_channel, + is_lighter_replay_channel, is_lighter_replay_only_channel, is_rh_lighter_channel, + is_rh_lighter_live_channel, is_rh_lighter_replay_only_channel, is_single_channel_replay, + OxArchiveWs, ServerMsg, WsOptions, FULL_DEPTH_CHANNELS, L4_REPLAY_CHANNELS, + LIGHTER_LIVE_CHANNELS, LIGHTER_REPLAY_ONLY_CHANNELS, LIGHTER_SUBSCRIPTION_ERROR, RH_LIGHTER_LIVE_CHANNELS, RH_LIGHTER_REPLAY_CHANNELS, RH_LIGHTER_REPLAY_ONLY_CHANNELS, RH_LIGHTER_SUBSCRIPTION_ERROR, }; @@ -164,60 +164,84 @@ async fn hyperliquid_live_subscription_remains_allowed() { .expect("Hyperliquid live subscription must remain allowed"); } +const EVERY_L4_CHANNEL: [&str; 8] = [ + "l4_diffs", + "l4_orders", + "hip3_l4_diffs", + "hip3_l4_orders", + "spot_l4_diffs", + "spot_l4_orders", + "hip4_l4_diffs", + "hip4_l4_orders", +]; + #[test] -fn only_hyperliquid_core_l4_channels_support_replay() { +fn every_l4_channel_replays() { + assert_eq!(L4_REPLAY_CHANNELS, EVERY_L4_CHANNEL); + for channel in EVERY_L4_CHANNEL { + assert!(is_l4_channel(channel)); + assert!(is_single_channel_replay(channel)); + } for channel in ["l4_diffs", "l4_orders"] { assert!(is_core_l4_replay_channel(channel)); - assert!(!is_live_only_l4_channel(channel)); } - for channel in [ - "hip3_l4_diffs", - "hip3_l4_orders", - "hip4_l4_diffs", - "hip4_l4_orders", - "spot_l4_diffs", - "spot_l4_orders", - ] { - assert!(!is_core_l4_replay_channel(channel)); - assert!(is_live_only_l4_channel(channel)); + assert!(!is_core_l4_replay_channel("hip3_l4_diffs")); + assert!(!is_l4_channel("orderbook")); + assert!(!is_single_channel_replay("trades")); +} + +#[test] +#[allow(deprecated)] +fn the_deprecated_live_only_l4_helpers_report_no_live_only_channel() { + use oxarchive::ws::{is_live_only_l4_channel, LIVE_ONLY_L4_CHANNELS}; + assert_eq!(LIVE_ONLY_L4_CHANNELS.len(), 6); + for channel in EVERY_L4_CHANNEL { + assert!(!is_live_only_l4_channel(channel)); } } #[tokio::test] -async fn core_l4_replay_remains_allowed() { +async fn every_l4_channel_is_sent_for_replay() { let ws = OxArchiveWs::new(WsOptions::new("test-key")); - for channel in ["l4_diffs", "l4_orders"] { - ws.replay(channel, "BTC", 1, Some(2), None) + for (channel, symbol) in [ + ("l4_diffs", "BTC"), + ("l4_orders", "BTC"), + ("hip3_l4_diffs", "xyz:XYZ100"), + ("hip3_l4_orders", "xyz:XYZ100"), + ("spot_l4_diffs", "HYPE-USDC"), + ("spot_l4_orders", "HYPE-USDC"), + ("hip4_l4_diffs", "#0"), + ("hip4_l4_orders", "#0"), + ] { + ws.replay(channel, symbol, 1, Some(2), None) .await - .expect("core Hyperliquid L4 replay must remain allowed"); + .unwrap_or_else(|e| panic!("{channel} replay must be sent, got {e:?}")); } } #[tokio::test] -async fn non_core_l4_replay_is_rejected_before_send() { +async fn l4_replay_is_single_channel() { let ws = OxArchiveWs::new(WsOptions::new("test-key")); - for channel in [ - "hip3_l4_diffs", - "hip3_l4_orders", - "hip4_l4_diffs", - "hip4_l4_orders", - "spot_l4_diffs", - "spot_l4_orders", - ] { - let error = ws - .replay(channel, "BTC", 1, Some(2), None) + for channel in EVERY_L4_CHANNEL { + match ws + .replay_multi(&["trades", channel], "BTC", 1, Some(2), None) .await - .expect_err("non-core L4 replay must be rejected"); - match error { - Error::InvalidParam(message) => { + { + Err(Error::InvalidParam(message)) => { + assert!(message.contains(channel), "unexpected message: {message}"); assert!( - message.contains("live-only"), + message.contains("single-channel"), "unexpected message: {message}" ); } - other => panic!("expected invalid parameter error, got {other:?}"), + other => { + panic!("a multi-channel replay with {channel} must be rejected, got {other:?}") + } } } + ws.replay_multi(&["orderbook", "trades"], "BTC", 1, Some(2), None) + .await + .expect("a standard multi-channel replay must be sent"); } const RH_LIGHTER_CHANNELS: [&str; 5] = [ @@ -376,38 +400,38 @@ fn rh_lighter_live_frames_decode_with_the_mainnet_shapes() { } #[tokio::test] -async fn full_depth_orderbook_channels_are_live_only() { +async fn full_depth_orderbook_channels_stream_live_and_replay_on_their_own() { let ws = OxArchiveWs::new(WsOptions::new("test-key")); + assert_eq!( + FULL_DEPTH_CHANNELS, + ["orderbook_full", "hip3_orderbook_full"] + ); for (channel, symbol) in [ ("orderbook_full", "BTC"), ("hip3_orderbook_full", "km:US500"), ] { - assert!(is_live_only_full_depth_channel(channel)); + assert!(is_full_depth_channel(channel)); + assert!(is_single_channel_replay(channel)); ws.subscribe(channel, Some(symbol)) .await .expect("full-depth live subscriptions must be allowed"); - match ws.replay(channel, symbol, 1, Some(2), None).await { + ws.replay(channel, symbol, 1, Some(2), None) + .await + .expect("full-depth replay must be sent"); + match ws + .replay_multi(&["orderbook", channel], symbol, 1, Some(2), None) + .await + { Err(Error::InvalidParam(message)) => { - assert_eq!(message, FULL_DEPTH_REPLAY_ERROR); + assert!(message.contains(channel), "unexpected message: {message}"); } - other => panic!("full-depth replay must be rejected before send, got {other:?}"), + other => panic!( + "a multi-channel replay with a full-depth channel must be rejected, got {other:?}" + ), } } - match ws - .replay_multi(&["orderbook", "orderbook_full"], "BTC", 1, Some(2), None) - .await - { - Err(Error::InvalidParam(message)) => assert_eq!(message, FULL_DEPTH_REPLAY_ERROR), - other => panic!( - "a multi-channel replay with a full-depth channel must be rejected, got {other:?}" - ), - } - assert!(!is_live_only_full_depth_channel("orderbook")); - assert!(!is_live_only_full_depth_channel("hip3_orderbook")); - assert_eq!( - LIVE_ONLY_FULL_DEPTH_CHANNELS, - ["orderbook_full", "hip3_orderbook_full"] - ); + assert!(!is_full_depth_channel("orderbook")); + assert!(!is_full_depth_channel("hip3_orderbook")); } #[test] @@ -443,3 +467,157 @@ fn full_depth_orderbook_frames_decode() { other => panic!("expected an l4_batch frame, got {other:?}"), } } + +// --------------------------------------------------------------------------- +// API contract 2026-10: error codes, Lighter replay shapes, replay matrix +// --------------------------------------------------------------------------- + +#[test] +fn error_messages_carry_the_error_code() { + use oxarchive::ErrorCode; + for (code, expected) in [ + ("slow_consumer", ErrorCode::SlowConsumer), + ("endpoint_unsupported", ErrorCode::EndpointUnsupported), + ("unsupported_for_venue", ErrorCode::UnsupportedForVenue), + ("invalid_parameter", ErrorCode::InvalidParameter), + ("conflict", ErrorCode::Conflict), + ( + "a_future_code", + ErrorCode::Other("a_future_code".to_string()), + ), + ] { + let frame = format!(r#"{{"type":"error","message":"m","error_code":"{code}"}}"#); + let msg: ServerMsg = serde_json::from_str(&frame).unwrap(); + assert_eq!(msg.error_code(), Some(&expected)); + match msg { + ServerMsg::Error { + message, + error_code, + } => { + assert_eq!(message, "m"); + assert_eq!(error_code, Some(expected)); + } + other => panic!("expected an error message, got {other:?}"), + } + } + // A server that sends no code still parses. + let bare: ServerMsg = serde_json::from_str(r#"{"type":"error","message":"m"}"#).unwrap(); + assert_eq!(bare.error_code(), None); + let pong: ServerMsg = serde_json::from_str(r#"{"type":"pong"}"#).unwrap(); + assert_eq!(pong.error_code(), None); +} + +#[test] +fn lighter_replay_rows_decode_with_the_live_shapes() { + let trade = r#"{"type":"historical_data","channel":"lighter_trades","coin":"BTC","symbol":"BTC","timestamp":1790521266168,"data":[{"closed_pnl":"-0.061239","coin":"BTC","crossed":true,"dir":null,"fee":"0","fee_token":null,"hash":"5f66","oid":562953432224725,"px":"84518","side":"A","start_position":"0.01183","sz":"0.00411","tid":32228458048,"time":1790521266168,"users":["736647"]}]}"#; + let msg: ServerMsg = serde_json::from_str(trade).unwrap(); + let Some(Ok(LighterLiveData::Trades(fills))) = msg.lighter_live_data() else { + panic!("expected a replayed trade leg, got {msg:?}"); + }; + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].tid, 32228458048); + assert_eq!(fills[0].closed_pnl.as_deref(), Some("-0.061239")); + assert_eq!(fills[0].fee.as_deref(), Some("0")); + + let snapshot = r#"{"type":"replay_snapshot","channel":"rh_lighter_open_interest","coin":"BTC","symbol":"BTC","timestamp":1790521260650,"data":{"coin":"BTC","ctx":{"dayBaseVlm":null,"dayNtlVlm":null,"funding":null,"impactPxs":null,"markPx":"84511.4","midPx":null,"openInterest":"172850198.76447","oraclePx":"84552.7","premium":null,"prevDayPx":null}}}"#; + let msg: ServerMsg = serde_json::from_str(snapshot).unwrap(); + let Some(Ok(LighterLiveData::OpenInterest(stats))) = msg.lighter_live_data() else { + panic!("expected replayed open interest, got {msg:?}"); + }; + assert_eq!(stats.ctx.open_interest.as_deref(), Some("172850198.76447")); + assert_eq!(stats.ctx.funding, None); + + let book = r#"{"type":"historical_data","channel":"rh_lighter_orderbook","coin":"BTC","symbol":"BTC","timestamp":1790522085209,"data":{"coin":"BTC","levels":[[{"n":1,"px":"84465.1","sz":"1.3517"}],[]],"time":1790522085209}}"#; + let msg: ServerMsg = serde_json::from_str(book).unwrap(); + assert!(matches!( + msg.lighter_live_data(), + Some(Ok(LighterLiveData::OrderBook(_))) + )); + + // Candles have no live shape and are not decoded. + let candles = r#"{"type":"historical_data","channel":"lighter_candles","coin":"BTC","symbol":"BTC","timestamp":1,"data":{"open":"1"}}"#; + let msg: ServerMsg = serde_json::from_str(candles).unwrap(); + assert!(msg.lighter_live_data().is_none()); +} + +/// Channel rows of `GET /v1/capabilities` as served on 2026-10-04. +const CAPABILITY_CHANNELS: &str = r#"[ +{"venue":"hyperliquid","datatype":"l2_orderbook","ws_channels":["orderbook"],"live":true,"replay":true,"available_from":"2023-04-15T00:00:00.000Z"}, +{"venue":"hyperliquid","datatype":"l2_full_depth","ws_channels":["orderbook_full"],"live":true,"replay":true,"available_from":"2026-03-11T01:03:00.000Z"}, +{"venue":"hyperliquid","datatype":"l4_diffs","ws_channels":["l4_diffs"],"live":true,"replay":true,"available_from":"2026-03-11T01:03:00.000Z"}, +{"venue":"hyperliquid","datatype":"l4_orders","ws_channels":["l4_orders"],"live":true,"replay":true,"available_from":"2026-03-11T01:03:00.000Z"}, +{"venue":"hyperliquid","datatype":"trades","ws_channels":["trades"],"live":true,"replay":true,"available_from":"2023-04-15T03:31:00.000Z"}, +{"venue":"hyperliquid","datatype":"candles","ws_channels":["candles"],"live":false,"replay":true,"available_from":"2025-03-22T12:00:00.000Z"}, +{"venue":"hyperliquid","datatype":"funding","ws_channels":["funding"],"live":true,"replay":true,"available_from":"2023-05-20T02:50:00.000Z"}, +{"venue":"hyperliquid","datatype":"oi","ws_channels":["open_interest"],"live":true,"replay":true,"available_from":"2023-05-20T02:50:00.000Z"}, +{"venue":"hyperliquid","datatype":"liquidations","ws_channels":["liquidations"],"live":true,"replay":true,"available_from":"2025-12-22T00:00:00.000Z"}, +{"venue":"hyperliquid","datatype":"ticker","ws_channels":["ticker","all_tickers"],"live":true,"replay":false,"available_from":null}, +{"venue":"hip3","datatype":"l2_orderbook","ws_channels":["hip3_orderbook"],"live":true,"replay":true,"available_from":"2026-02-16T16:57:00.000Z"}, +{"venue":"hip3","datatype":"l2_full_depth","ws_channels":["hip3_orderbook_full"],"live":true,"replay":true,"available_from":"2026-03-11T01:03:00.000Z"}, +{"venue":"hip3","datatype":"l4_diffs","ws_channels":["hip3_l4_diffs"],"live":true,"replay":true,"available_from":"2026-03-11T01:03:00.000Z"}, +{"venue":"hip3","datatype":"l4_orders","ws_channels":["hip3_l4_orders"],"live":true,"replay":true,"available_from":"2026-03-11T01:03:00.000Z"}, +{"venue":"hip3","datatype":"trades","ws_channels":["hip3_trades"],"live":true,"replay":true,"available_from":"2025-10-13T12:24:00.000Z"}, +{"venue":"hip3","datatype":"candles","ws_channels":["hip3_candles"],"live":false,"replay":true,"available_from":"2025-12-22T00:00:00.000Z"}, +{"venue":"hip3","datatype":"funding","ws_channels":["hip3_funding"],"live":true,"replay":true,"available_from":"2026-02-16T17:03:00.000Z"}, +{"venue":"hip3","datatype":"oi","ws_channels":["hip3_open_interest"],"live":true,"replay":true,"available_from":"2026-02-16T17:03:00.000Z"}, +{"venue":"hip3","datatype":"liquidations","ws_channels":["hip3_liquidations"],"live":true,"replay":true,"available_from":"2025-12-22T00:00:00.000Z"}, +{"venue":"hip4","datatype":"l2_orderbook","ws_channels":["hip4_orderbook"],"live":false,"replay":true,"available_from":"2026-05-02T16:51:00.000Z"}, +{"venue":"hip4","datatype":"l4_diffs","ws_channels":["hip4_l4_diffs"],"live":true,"replay":true,"available_from":"2026-05-02T07:47:00.000Z"}, +{"venue":"hip4","datatype":"l4_orders","ws_channels":["hip4_l4_orders"],"live":true,"replay":true,"available_from":"2026-05-02T07:47:00.000Z"}, +{"venue":"hip4","datatype":"trades","ws_channels":["hip4_trades"],"live":true,"replay":true,"available_from":"2026-05-02T08:00:00.000Z"}, +{"venue":"hip4","datatype":"oi","ws_channels":["hip4_open_interest"],"live":false,"replay":true,"available_from":"2026-05-02T16:51:00.000Z"}, +{"venue":"spot","datatype":"l2_orderbook","ws_channels":["spot_orderbook"],"live":true,"replay":false,"available_from":"2026-05-05T19:56:00.000Z"}, +{"venue":"spot","datatype":"l4_diffs","ws_channels":["spot_l4_diffs"],"live":true,"replay":true,"available_from":"2026-05-05T22:57:00.000Z"}, +{"venue":"spot","datatype":"l4_orders","ws_channels":["spot_l4_orders"],"live":true,"replay":true,"available_from":"2026-05-05T22:57:00.000Z"}, +{"venue":"spot","datatype":"trades","ws_channels":["spot_trades"],"live":true,"replay":false,"available_from":"2025-03-22T10:50:22.000Z"}, +{"venue":"lighter","datatype":"l2_orderbook","ws_channels":["lighter_orderbook"],"live":true,"replay":true,"available_from":"2026-01-29T02:13:00.000Z"}, +{"venue":"lighter","datatype":"l3_orderbook","ws_channels":["lighter_l3_orderbook"],"live":false,"replay":true,"available_from":"2026-03-05T03:33:00.000Z"}, +{"venue":"lighter","datatype":"trades","ws_channels":["lighter_trades"],"live":true,"replay":true,"available_from":"2025-01-17T08:43:00.000Z"}, +{"venue":"lighter","datatype":"candles","ws_channels":["lighter_candles"],"live":false,"replay":true,"available_from":"2025-08-01T00:00:00.000Z"}, +{"venue":"lighter","datatype":"funding","ws_channels":["lighter_funding"],"live":true,"replay":true,"available_from":"2025-08-25T15:28:00.000Z"}, +{"venue":"lighter","datatype":"oi","ws_channels":["lighter_open_interest"],"live":true,"replay":true,"available_from":"2025-08-25T15:28:00.000Z"}, +{"venue":"rh-lighter","datatype":"l2_orderbook","ws_channels":["rh_lighter_orderbook"],"live":true,"replay":true,"available_from":"2026-08-22T18:43:00.000Z"}, +{"venue":"rh-lighter","datatype":"trades","ws_channels":["rh_lighter_trades"],"live":true,"replay":true,"available_from":"2026-06-26T20:10:26.000Z"}, +{"venue":"rh-lighter","datatype":"candles","ws_channels":["rh_lighter_candles"],"live":false,"replay":true,"available_from":"2026-06-26T20:10:00.000Z"}, +{"venue":"rh-lighter","datatype":"funding","ws_channels":["rh_lighter_funding"],"live":true,"replay":true,"available_from":"2026-08-22T18:43:00.000Z"}, +{"venue":"rh-lighter","datatype":"oi","ws_channels":["rh_lighter_open_interest"],"live":true,"replay":true,"available_from":"2026-08-22T18:43:00.000Z"} +]"#; + +#[derive(serde::Deserialize)] +struct ChannelRow { + ws_channels: Vec, + live: bool, + replay: bool, +} + +#[tokio::test] +async fn client_side_refusals_follow_the_capabilities() { + let rows: Vec = serde_json::from_str(CAPABILITY_CHANNELS).unwrap(); + let ws = OxArchiveWs::new(WsOptions::new("test-key")); + let mut replayable = 0; + for row in &rows { + for channel in &row.ws_channels { + let replay = ws.replay(channel, "BTC", 1, Some(2), None).await; + let multi = ws + .replay_multi(&[channel.as_str()], "BTC", 1, Some(2), None) + .await; + let live = ws.subscribe(channel, Some("BTC")).await; + if row.replay { + replayable += 1; + // Nothing the API replays is refused before sending; only + // the single-channel rule applies to multi-channel replay. + assert!(replay.is_ok(), "{channel} replays but was refused"); + assert_eq!( + multi.is_err(), + is_single_channel_replay(channel), + "{channel}" + ); + } + // A live subscription is refused only where the API has none. + if live.is_err() { + assert!(!row.live, "{channel} streams live but was refused"); + } + } + } + assert!(replayable >= 30); +}