diff --git a/.gitignore b/.gitignore index fab4502..3d9c9d2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ tests/* !tests/test_rh_lighter.py !tests/_mock_api.py !tests/test_timestamps.py +!tests/test_spec_fixes.py +!tests/test_websocket_full_depth.py +!tests/test_webhooks.py +!tests/test_webhook_signature.py +!tests/test_surface_parity.py # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4fedc..35f30f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,14 +97,90 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain - `RH_LIGHTER_LIVE_CHANNELS`, `RH_LIGHTER_REPLAY_ONLY_CHANNELS`, `RH_LIGHTER_REPLAY_CHANNELS`, `RH_LIGHTER_SUBSCRIPTION_ERROR` and `LIGHTER_BOOK_CHANNELS` in `oxarchive.websocket`. -- `orders.flow()` and `aflow()` take `cursor` (Hyperliquid, HIP-3 and HIP-4) - and send it; the API starts the response at the first bucket that opens - after it. Before this, a `cursor` keyword was accepted and silently dropped. - The API does not return `next_cursor` on order flow yet, so `next_cursor` - on an order-flow response is `None`. +- `orders.flow()` and `aflow()` take `cursor` (Hyperliquid, HIP-3 and HIP-4). + The API pages order flow: a page holds the oldest `limit` buckets of the + window, and `next_cursor` is set while more may follow. Pass it back as + `cursor` with the same `start`, `end` and `interval` until it is `None`. + Before this, a `cursor` keyword was accepted and silently dropped, so every + call returned the first page. +- Webhooks: `client.webhooks` (`WebhooksResource`) covers all 21 operations + under `/v1/webhooks`, each with an `a`-prefixed async version: + `event_types()`, `limits()`; `list_endpoints()`, `create_endpoint()`, + `delete_endpoint()`, `enable_endpoint()`, `rotate_secret()`, + `test_endpoint()`; `list_deliveries()`, `redeliver()`; + `list_subscriptions()`, `create_subscription()`, `update_subscription()`, + `delete_subscription()`, `resume_subscription()`, + `resume_all_subscriptions()`; `dry_run()`, `estimate()`; + `list_addresses()`, `add_address()`, `delete_address()`. A subscription + configuration is passed as `config`, a dict or a + `WebhookSubscriptionConfig`. Subscriptions report their pause state + (`status`, `pause_reason`, `pause_message`, suppressed counts), and a + resume returns the missed window as `WebhookResumeGap`. `limits()` + reports the plan's caps, today's delivery budget and the paused count. + Webhook delivery needs a paid plan; `estimate()` and `dry_run()` are + available on every plan. +- Webhook signature verification in `oxarchive.webhook_signature`: + `WebhookVerifier`, `verify_webhook()`, `verify_webhook_signature()`, + `parse_signature_header()`, `WebhookEvent`, `WebhookSignature`, + `WebhookSignatureError` and the header name constants. It verifies the raw + request bytes, accepts every `v1=` signature in the header (two during a + secret rotation), enforces a replay window (5 minutes by default) and + compares in constant time. +- Webhook models: `WebhookEventType` (with `WebhookEventTypeParam`, + `WebhookEventTypeMetric`, `WebhookCostFloor`), `WebhookEndpoint`, + `WebhookEndpointCreated`, `WebhookEndpointSecret`, + `WebhookSubscriptionConfig`, `WebhookSubscriptionCondition`, + `WebhookSubscription`, `WebhookSubscriptionResume`, + `WebhookSubscriptionResumeAll`, `WebhookResumeGap`, + `WebhookResumeReplayWindow`, `WebhookDelivery`, `WebhookDeliveryQueued`, + `WebhookRedelivery`, `WebhookWatchedAddress`, `WebhookLimits`, + `WebhookLimitUsage`, `WebhookDeliveryBudget`, `WebhookPausedSubscriptions`, + `WebhookDryRun`, `WebhookEstimate`, `WebhookPreviewWindow`, + `WebhookPreviewOccurrence`, `WebhookEstimateDayCount`, + `WebhookEstimateRung`, `WebhookEstimateDistribution` and + `WebhookEstimateBasis`. Every webhook model keeps unknown fields. +- Cumulative volume delta: `client.hyperliquid.cvd` and + `client.hyperliquid.hip3.cvd` with `history()` (one page; pass + `next_cursor` back unchanged with the same `start`, `end` and `interval`) + and `iterate()`, which follows the cursor, plus `ahistory()` and + `aiterate()`. Buckets are `CvdBucket`; intervals `1m` to `1w` + (`CvdInterval`). `cumulative_delta` restarts on every page. +- HIP-3 oracle: `client.hyperliquid.hip3.oracle.external_price(symbol)` and + `discovery_bounds(symbol)` (and `aexternal_price()`, + `adiscovery_bounds()`), typed as `Hip3OracleExternalPrice` and + `Hip3OracleDiscoveryBounds`. +- HIP-4 questions: `client.hyperliquid.hip4.questions.list()` (cursor paged) + and `get(question_id)`, the flat helpers + `client.hyperliquid.hip4.list_questions()` and `get_question()`, and their + async versions, typed as `Hip4Question`. +- Wallet classification: `client.hyperliquid.wallets.classify()` and + `client.hyperliquid.hip3.wallets.classify()` (and `aclassify()`), with + every filter, sort and `limit`/`offset` paging, typed as + `WalletClassification`, `ClassifiedWallet` and `WalletClassifyMetrics`. + `date` takes a date, a `YYYY-MM-DD` string or a datetime (a datetime + without a time zone is UTC). +- Hyperliquid core breadth: `client.hyperliquid.breadth` with `current()` and + `history()` (and `acurrent()`, `ahistory()`), the same resource as HIP-3 + breadth, served from 2026-08-24. Core responses carry empty `namespaces`. +- The public symbol universe: `client.symbols.list()` and `alist()`, one + `SymbolEntry` per market across every venue family, with data types and + coverage dates. +- Full-depth L2 WebSocket channels `orderbook_full` (Hyperliquid core) and + `hip3_orderbook_full` (HIP-3) for live subscription, with helpers + `subscribe_orderbook_full()`, `subscribe_hip3_orderbook_full()` and their + `unsubscribe_*` counterparts, and `FULL_DEPTH_L2_CHANNELS` in + `oxarchive.websocket`. A subscription opens with an `l4_snapshot` of every + price level, then `l4_batch` messages of changed levels, delivered to + `on_l4_snapshot()` and `on_l4_batch()`. They are live-only: `replay()` + and `multi_replay()` reject them with `ValueError` + (`FULL_DEPTH_LIVE_ONLY_ERROR`) before anything is sent. Stored full-depth + history is on REST `l2_orderbook.history()` and `diffs()`. +- `client.lighter.l3_orderbook.get()` and `history()` take `account`. ### Changed -- `WsChannel` includes the five `rh_lighter_*` channels. +- `WsChannel` includes the five `rh_lighter_*` channels, `orderbook_full` and + `hip3_orderbook_full`. Before, the acknowledgement, snapshot and batch + messages of the two full-depth channels failed to parse. - `rh_lighter_candles` raises `ValueError` with `RH_LIGHTER_SUBSCRIPTION_ERROR` on a live subscribe, like the other replay-only Lighter channels. - `interval_ms` is accepted on `rh_lighter_orderbook` as well as @@ -114,6 +190,12 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain `hip4`, `spot`, `lighter` and `rh-lighter`. - `LighterClient` shares its resources with `RhLighterClient` through a common base class; its paths and behavior are unchanged. +- Parameters the API never applied are removed, and passing one now raises + `TypeError` before anything is sent instead of returning unfiltered data: + `side` on `trades.list()`; `user`, `status` and `order_type` on + `client.spot.orders.history()` (core, HIP-3 and HIP-4 order history keep + them); `depth` on `l2_orderbook.history()`, `l4_orderbook.history()` and + `lighter.l3_orderbook.history()` (it still applies to `get()`). - `client.hyperliquid.hip3.breadth.history()` and `ahistory()` accept `interval="1m"`. The API serves 1-minute buckets on breadth, open interest, funding, price and liquidation-volume history for every venue. @@ -122,7 +204,43 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain (the default), `5m`, `15m` and `1h`. The docstring used to suggest `4h` and `1d`, which the API refuses. +### Removed +- Methods that called routes the API does not serve, so every call failed + with a 404: + - `client.hyperliquid.hip4.l2_orderbook` (`get()`, `history()`, + `diffs()` and their async versions). HIP-4 has no full-depth L2 route; + use `hip4.orderbook` and `hip4.l4_orderbook`. + - `client.hyperliquid.hip4.orders.trigger_levels()` and + `trigger_levels_history()`. + - `client.spot.orders.flow()`, `tpsl()`, `trigger_levels()` and + `trigger_levels_history()`. Spot serves order history only. + - `client.hyperliquid.hip3.liquidations.by_user()`. The per-user route + exists for Hyperliquid core only. +- The async versions of those methods go with them. The affected resources + are now `Hip4OrdersResource`, `SpotOrdersResource` and + `Hip3LiquidationsResource`; Hyperliquid core and HIP-3 keep + `OrdersResource`, and Hyperliquid core keeps `LiquidationsResource`. + ### Fixed +- `trades.list()` and `alist()` send `cursor` back exactly as the API + returned it. A trades cursor is an opaque string such as + `"1790640000578_218303497631402"` (Lighter adds a third part), and it was + converted as if it were a timestamp, so asking for the second page raised + `ValueError` or sent a different cursor. Integer and datetime cursors are + still accepted. +- `CoinFreshness.funding` is optional. HIP-4 has no funding, and + `client.hyperliquid.hip4.get_freshness()` raised a validation error on + every response. +- `data_quality.symbol_coverage()` and `asymbol_coverage()` send the symbol + as given, URL-encoded as one path segment. They upper-cased it, which + turned case-sensitive HIP-3 symbols such as `km:US500` into a symbol + with no data, and they did not encode the `#` of HIP-4 symbols, which cut + the path short. +- `SpotTableFreshness` has the shape the API returns: `symbol`, `coin`, + `exchange`, `measured_at`, and `orderbook`, `trades`, `l4_diffs`, + `l4_checkpoints`, `orders` and `twap` as `DataTypeFreshness`. Its + `tables` field was never filled; `tables` is now a property that returns + the datasets present. - Times without a time zone are UTC on every method. Before, a naive `datetime` and an ISO string without an offset (`"2026-09-01"`, `"2026-09-01T00:00:00"`) were read as the machine's local time, so the @@ -143,6 +261,17 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain - The HIP-3 coin table is removed, since builders list and delist markets; call `client.hyperliquid.hip3.instruments.list()` for the current set. - Documentation links point at docs.0xarchive.io. +- The breadth `history()` docstring says `start` defaults to 24 hours before + now, as the API applies it, rather than 24 hours before `end`. +- The README's order-flow example pages a full day at `1m`, following + `next_cursor` until it is `None`, and the `flow()` docstring describes + paging. +- A README section on webhooks: plan limits, pauses and resumes, the event + catalog, configuration, previews, verifying deliveries, secret rotation and + retries. +- README sections for cumulative volume delta, the HIP-3 oracle, HIP-4 + questions, wallet classification, the symbol universe and the full-depth L2 + WebSocket channels. ## [1.11.0] - 2026-09-25 diff --git a/README.md b/README.md index 3f3e115..83a76dd 100644 --- a/README.md +++ b/README.md @@ -324,8 +324,8 @@ while result.next_cursor: ) trades.extend(result.data) -# Filter by side -buys = client.hyperliquid.trades.list("BTC", start=..., end=..., side="buy") +# The API does not filter trades by side; filter the returned rows +buys = [t for t in result.data if t.side == "B"] # Get recent trades (Lighter and HIP-3 - have real-time data) recent = client.lighter.trades.recent("BTC", limit=100) @@ -435,6 +435,31 @@ while history.next_cursor: The session resets at 00:00 UTC and compares the close of the most recently completed one-minute candle with session VWAP. Instruments without session volume or with a completed candle older than five minutes are excluded, so `coverage_ratio` varies with market hours. `value_pct` is `None`, not zero, when no instrument is eligible. History begins on 2026-08-28; the SDK does not imply synthetic pre-launch history. Interval downsampling uses the last snapshot in each bucket, never an average of percentages. +The same breadth is served for Hyperliquid core perpetuals on `client.hyperliquid.breadth`, with the same methods, intervals and cursor paging. Core history begins on 2026-08-24, and core responses carry empty `namespaces` maps. + +```python +current = client.hyperliquid.breadth.current() +history = client.hyperliquid.breadth.history(start="2026-08-24T00:00:00Z", interval="1h") +``` + +#### HIP-3 Oracle + +The deployer-pushed external price of a HIP-3 market, and its instantaneous discovery bounds: + +```python +price = client.hyperliquid.hip3.oracle.external_price("km:US500") +print(price.external_price, price.mark_price, price.block_number) + +bounds = client.hyperliquid.hip3.oracle.discovery_bounds("km:US500") +print(bounds.reference_source, bounds.lower_bound, bounds.upper_bound) + +# Async versions +price = await client.hyperliquid.hip3.oracle.aexternal_price("km:US500") +bounds = await client.hyperliquid.hip3.oracle.adiscovery_bounds("km:US500") +``` + +`external_price` and `mark_price` are `None` when the market has none. The discovery bounds are `reference_price` times one minus and one plus `bound_fraction`, where the reference is the external price when available and the mark price otherwise (`reference_source`) and the fraction follows from the market's max leverage. The full ratcheted range can be wider when a deployer's reset configuration applies. `timestamp` is Unix milliseconds. + #### HIP-4 Outcome Markets HIP-4 binary-outcome markets resolve to ``Yes`` (side 0) or ``No`` (side 1) at expiry. Each outcome has two per-side coins (``#N``, where ``N = 10*outcome_id + side``). The SDK accepts both the bare numeric (``"0"``) and ``#``-prefixed (``"#0"``) forms. On REST paths it sends the bare form (the backend routes both to the same record). HIP-4 serves candles and outcome-side OI from 2026-05-02, with raw OI updates at ~10s. HIP-3 and Lighter candle pages accept up to 10,000 rows; HIP-4 candle pages are capped at 1,000 rows. HIP-4 has **no funding and no liquidations**. The ``mark_price`` field on HIP-4 OI/summary responses is an **implied probability in [0, 1]**, not a USD price. @@ -457,6 +482,14 @@ outcome = client.hyperliquid.hip4.get_outcome_by_slug("btc-above-78213-may-04-06 # Filter the list endpoint by slug. Short-circuits to a one-item response. result = client.hyperliquid.hip4.list_outcomes(slug="btc-above-78213-yes-may-04-0600") +# Questions group several binary outcomes under one multi-choice resolver: +# one named outcome per choice, plus a fallback outcome that resolves Yes when +# no named choice does. Page with next_cursor, passed back unchanged. +page = client.hyperliquid.hip4.list_questions(limit=100) +question = client.hyperliquid.hip4.get_question(0) +print(question.named_outcome_ids, question.fallback_outcome_id, question.settled_named_outcomes) +# Also: client.hyperliquid.hip4.questions.list() / .get(), and alist() / aget() + # Per-side instruments. Either bare or "#"-prefixed works. yes = client.hyperliquid.hip4.instruments.get("0") # bare, recommended no_ = client.hyperliquid.hip4.instruments.get("#1") # also works @@ -520,11 +553,12 @@ orders = client.spot.orders.history("HYPE-USDC", start=..., end=...) twap_pair = client.spot.twap.by_symbol("HYPE-USDC", start=..., end=...) twap_user = client.spot.twap.by_user("0xabc...", start=..., end=...) -# Per-table freshness lag +# Per-dataset freshness lag: orderbook, trades, l4_diffs, l4_checkpoints, +# orders and twap, each with optional lag_ms and last_updated fresh = client.spot.get_freshness("HYPE-USDC") -for table, lag in fresh.tables.items(): - # Values are dicts with optional `lag_ms` and `last_updated` keys. - print(f"{table}: lag={lag.get('lag_ms')}ms last_updated={lag.get('last_updated')}") +print(fresh.orderbook.lag_ms, fresh.trades.last_updated) +for dataset, lag in fresh.tables.items(): + print(f"{dataset}: lag={lag.lag_ms}ms last_updated={lag.last_updated}") # Async versions are available on every method: ob = await client.spot.orderbook.aget("HYPE-USDC") @@ -688,7 +722,7 @@ while liquidations.next_cursor: ) all_liquidations.extend(liquidations.data) -# Get liquidations for a specific user +# Get liquidations for a specific user (Hyperliquid core; HIP-3 has no per-user route) user_liquidations = client.hyperliquid.liquidations.by_user( "0x1234...", start="2025-06-01", @@ -814,13 +848,13 @@ print(f"24h liquidation volume: ${summary.liquidation_volume_24h}") print(f" Long: ${summary.long_liquidation_volume_24h}") print(f" Short: ${summary.short_liquidation_volume_24h}") -# Lighter.xyz (price, funding, OI — no volume/liquidation data) +# Lighter.xyz (price, funding, OI; no volume/liquidation data) lighter_summary = client.lighter.get_summary("BTC") # Lighter on Robinhood Chain (same shape as mainnet Lighter) rh_summary = client.rh_lighter.get_summary("BTC") -# HIP-3 (includes mid_price — case-sensitive coins) +# HIP-3 (includes mid_price; case-sensitive coins) hip3_summary = client.hyperliquid.hip3.get_summary("km:US500") print(f"Mid price: {hip3_summary.mid_price}") @@ -945,6 +979,52 @@ spot_candles = await client.spot.candles.ahistory( | `1d` | 1 day | | `1w` | 1 week | +### Cumulative Volume Delta + +Taker buy and sell notional per bucket, their difference, and a running total, for Hyperliquid core and HIP-3. Intervals of `1h` and longer roll up hourly totals; `1m`, `5m`, `15m` and `30m` are summed from taker fills. + +```python +# One page. Without start (and without a cursor) the response is the newest +# `limit` buckets of the 24 hours before `end`. +page = client.hyperliquid.cvd.history( + "BTC", + start="2026-09-01T00:00:00Z", + end="2026-09-02T00:00:00Z", + interval="1m", # 1m, 5m, 15m, 30m, 1h (default), 4h, 1d, 1w + limit=500, # buckets per page (default 500, max 10000) +) +for bucket in page.data: + print(bucket.timestamp, bucket.buy_volume, bucket.sell_volume, bucket.delta) + +# Follow next_cursor with the same start, end and interval. Stop on the +# cursor, not on a short page: below 1h a page can be short and still carry one. +while page.next_cursor: + page = client.hyperliquid.cvd.history( + "BTC", + start="2026-09-01T00:00:00Z", + end="2026-09-02T00:00:00Z", + interval="1m", + cursor=page.next_cursor, + ) + +# Or let the iterator follow the cursor +running = 0.0 +for bucket in client.hyperliquid.cvd.iterate( + "BTC", start="2026-09-01T00:00:00Z", end="2026-09-02T00:00:00Z", interval="1m" +): + running += bucket.delta + +# HIP-3 (case-sensitive coins) +hip3 = client.hyperliquid.hip3.cvd.history("km:US500", start="2026-09-01T00:00:00Z", interval="1h") + +# Async versions +page = await client.hyperliquid.cvd.ahistory("BTC", start="2026-09-01T00:00:00Z") +async for bucket in client.hyperliquid.hip3.cvd.aiterate("km:US500", start="2026-09-01T00:00:00Z"): + ... +``` + +Buckets are labelled by their open time in UTC (`timestamp`, Unix milliseconds) and omitted when they hold no trades. `4h`, `1d` and `1w` buckets open on UTC epoch boundaries, so `1w` buckets open on Thursdays, and at those widths the first and last bucket of a window can be partial. `cumulative_delta` restarts on every page: to join pages, rebuild the running total from `delta`. A response that is one page of several says so in `meta.notice`. + ### L4 Orderbook (Order-Level) Get L4 order-level orderbook data with user attribution. Available for Hyperliquid and HIP-3. @@ -965,12 +1045,11 @@ diffs = client.hyperliquid.l4_orderbook.diffs( limit=1000 ) -# Get L4 orderbook history (full snapshots over time) +# Get L4 orderbook history (full checkpoints over time) history = client.hyperliquid.l4_orderbook.history( "BTC", start="2024-01-01", end="2024-01-02", - depth=20, limit=100 ) @@ -992,7 +1071,7 @@ hip3_snapshot = await client.hyperliquid.hip3.l4_orderbook.aget("km:US500") |--------|-------------| | `get(symbol, *, timestamp, depth)` | Get L4 orderbook snapshot | | `diffs(symbol, *, start, end, cursor, limit)` | Get L4 orderbook diffs (order-level changes) | -| `history(symbol, *, start, end, cursor, limit, depth)` | Get L4 orderbook history (full snapshots) | +| `history(symbol, *, start, end, cursor, limit)` | Get L4 orderbook history (full checkpoints; `depth` applies to `get()` only) | ### L3 Orderbook (Lighter.xyz Only) @@ -1006,12 +1085,16 @@ snapshot = client.lighter.l3_orderbook.get("BTC", depth=20) # Get L3 orderbook at a specific timestamp historical = client.lighter.l3_orderbook.get("BTC", timestamp=1704067200000) +# Only one account's resting orders +mine = client.lighter.l3_orderbook.get("BTC", account=281474976710654) + # Get L3 orderbook history history = client.lighter.l3_orderbook.history( "BTC", start="2026-03-05", end="2026-03-06", - depth=250, + granularity="checkpoint", # checkpoint (default), 30s, 10s, 1s, tick + account=281474976710654, # optional: one account's orders limit=100 ) @@ -1034,8 +1117,8 @@ history = await client.lighter.l3_orderbook.ahistory("BTC", start=..., end=...) | Method | Description | |--------|-------------| -| `get(symbol, *, timestamp, depth)` | Get an L3 snapshot, up to 250 orders per side | -| `history(symbol, *, start, end, cursor, limit, depth)` | Get tick-level L3 history from March 5, 2026, up to 250 orders per side | +| `get(symbol, *, timestamp, depth, account)` | Get an L3 snapshot, up to 250 orders per side | +| `history(symbol, *, start, end, cursor, limit, account)` | Get L3 history from March 5, 2026, up to 250 orders per side | ### L2 Orderbook (Full-Depth) @@ -1066,7 +1149,7 @@ l2_diffs = await client.hyperliquid.l2_orderbook.adiffs("BTC", start=..., end=.. | Method | Description | |--------|-------------| | `get(symbol, *, timestamp, depth)` | Get L2 full-depth orderbook snapshot | -| `history(symbol, *, start, end, cursor, limit, depth)` | Get L2 orderbook history | +| `history(symbol, *, start, end, cursor, limit)` | Get L2 orderbook history (every level; `depth` applies to `get()` only) | | `diffs(symbol, *, start, end, cursor, limit)` | Get L2 tick-level diffs | ### Orders (L4 Order History) @@ -1092,14 +1175,25 @@ result = client.hyperliquid.orders.history( order_type="limit" ) -# Get order flow aggregation: time buckets, oldest first +# Get order flow aggregation: one page of time buckets flow = client.hyperliquid.orders.flow( "BTC", start="2026-07-13T00:00:00Z", end="2026-07-14T00:00:00Z", - interval="15m", # 1m (default), 5m, 15m, 1h - limit=1000, # buckets (default 1000, max 10000) + interval="1m", # 1m (default), 5m, 15m, 1h ) +buckets = list(flow.data) +# A page holds up to `limit` buckets (default 1000, max 10000); follow the +# cursor with the same start, end and interval until it is None +while flow.next_cursor: + flow = client.hyperliquid.orders.flow( + "BTC", + start="2026-07-13T00:00:00Z", + end="2026-07-14T00:00:00Z", + interval="1m", + cursor=flow.next_cursor, + ) + buckets.extend(flow.data) # Get TP/SL history tpsl = client.hyperliquid.orders.tpsl( @@ -1127,9 +1221,11 @@ hip3_orders = await client.hyperliquid.hip3.orders.ahistory("km:US500", start=.. | Method | Description | |--------|-------------| | `history(symbol, *, start, end, user, status, order_type, cursor, limit)` | Get order history | -| `flow(symbol, *, start, end, interval, limit)` | Get order flow aggregation | +| `flow(symbol, *, start, end, interval, cursor, limit)` | Get order flow aggregation, one page of buckets | | `tpsl(symbol, *, start, end, user, triggered, cursor, limit)` | Get TP/SL history | +Hyperliquid core and HIP-3 also have `trigger_levels()` and `trigger_levels_history()`. HIP-4 serves `history()`, `flow()` and `tpsl()`. Spot serves `history(symbol, *, start, end, cursor, limit)` only, without user, status or order-type filters. + ### Account Positions What an account holds, now or at any instant, plus hourly history, the change log of every fill that moved a position, account summaries, and market-wide listings. Available on four clients with the same method names: @@ -1231,6 +1327,53 @@ Things to know: - **Cursors are bound to their request.** Pass `next_cursor` back with every other argument unchanged. If the snapshot a market cursor was paging is replaced, the API answers 409 (`OxArchiveError.code == 409`); restart without a cursor. - **Billing and limits.** Position rows are billed like trades, 1,000 rows per credit; `account()`, `account_history()`, and `by_l1()` are billed at the per-request minimum. Wallet routes return up to 5,000 rows per page (default 500), market routes up to 2,000 (default 100), bulk `all()` up to 2,000 (default 1,000), and summary series up to 168 hours per page. +### Wallet Classification + +Precomputed daily behavior metrics for active wallets on Hyperliquid core and HIP-3: orders, fills, cancel and fill rates, maker ratio, order sizes, cancel speed, fees, realized PnL, TWAP, priority gas and builder usage. Filter and sort server side, and page with `limit` and `offset`. + +```python +page = client.hyperliquid.wallets.classify( + min_orders=1000, + min_volume_usd=1_000_000, + sort="total_volume_usd", # default total_orders + order="desc", + limit=100, # 1 to 1000 (default 100) + offset=0, # at most 100000 + date="2026-09-28", # daily snapshot; default yesterday (UTC) +) +print(f"{page.total} wallets match on {page.date}") +for wallet in page.wallets: + m = wallet.metrics + print(wallet.address, m.total_volume_usd, m.maker_ratio, m.cancel_rate) + +# HIP-3 wallets, filtered by behavior +hip3 = client.hyperliquid.hip3.wallets.classify(uses_twap=True, min_cancel_rate=0.5) + +# Async versions +page = await client.hyperliquid.wallets.aclassify(sort="realized_pnl_usd") +``` + +`date` accepts a `date`, a `YYYY-MM-DD` string, or a datetime (a datetime without a time zone is UTC). Each request costs 10 credits, with row-based metering on the wallets returned where it applies. Every metric field is optional. + +### Symbol Universe + +Every public market across the venue families (`hyperliquid`, `hip3`, `hip4`, `spot`, `lighter`, `rh-lighter`), with the data types served for each and coverage dates overall and per data type. Use it to discover symbols before choosing a venue-specific route. The route needs no API key. + +```python +symbols = client.symbols.list() +hip3 = [s.symbol for s in symbols if s.exchange == "hip3"] +btc = next(s for s in symbols if s.exchange == "hyperliquid" and s.symbol == "BTC") +print(btc.data_types, btc.coverage_from, btc.coverage_by_type.get("trades")) + +# HIP-4 entries also carry the slug, the outcome pair and a display title +outcomes = [s for s in symbols if s.exchange == "hip4" and not s.is_settled] + +# Async version +symbols = await client.symbols.alist() +``` + +The whole universe is returned in one response, so fetch it once and filter locally. + ### Data Quality Monitoring Monitor data coverage, incidents, latency, and SLA compliance across venue APIs. Venue scopes are `hyperliquid`, `hip3`, `hip4`, `spot` (Hyperliquid spot), `lighter` (Lighter mainnet), and `rh-lighter` (Lighter on Robinhood Chain). @@ -1300,7 +1443,7 @@ coverage = await client.data_quality.acoverage() | `status()` | Overall system health and per-exchange status | | `coverage()` | Data coverage summary for venue APIs | | `exchange_coverage(exchange)` | Coverage details for a venue scope (`hyperliquid`, `hip3`, `hip4`, `spot`, `lighter`, `rh-lighter`) | -| `symbol_coverage(exchange, symbol, *, from_time, to_time)` | Coverage with gap detection, cadence, and historical coverage | +| `symbol_coverage(exchange, symbol, *, from_time, to_time)` | Coverage with gap detection, cadence, and historical coverage. The symbol is sent as given, URL-encoded (`km:US500`, `HYPE-USDC`, `#0`) | | `list_incidents(...)` | List incidents with filtering and pagination | | `get_incident(incident_id)` | Get specific incident details | | `latency()` | Current latency metrics (WebSocket, REST, data freshness) | @@ -1316,6 +1459,303 @@ client = Client( ) ``` +### Webhooks + +Push delivery of market, account and platform events. Instead of polling a route on a timer, register an HTTPS URL and 0xArchive posts to it when something happens. + +Three objects, in the order you create them: + +1. An **endpoint** is an HTTPS URL 0xArchive posts to. Creating one returns its signing secret, once. +2. A **subscription** is a rule: one event type, on one endpoint, with an optional configuration of filters, parameters and conditions. +3. A **watched address** is a wallet. Event types whose `scope` is `addresses` report only on wallets on this list. + +```python +# 1. Somewhere to deliver +endpoint = client.webhooks.create_endpoint( + "https://example.com/hooks/0xarchive", + description="Desk alerts", +) +store_secret(endpoint.secret) # shown once, never returned again + +# 2. Size the rule before turning it on +estimate = client.webhooks.estimate( + "market.liquidation", + {"venue": "hyperliquid", "min_notional_usd": 250_000}, + lookback_days=7, +) +print(f"median {estimate.per_day_p50:.0f} deliveries a day, busiest day {estimate.per_day_max}") + +# 3. Turn it on +sub = client.webhooks.create_subscription( + endpoint.id, + "market.liquidation", + {"venue": "hyperliquid", "min_notional_usd": 250_000}, +) + +# 4. Send a real signed test delivery to the receiver +client.webhooks.test_endpoint(endpoint.id) +``` + +#### Plan limits + +Webhook delivery needs a paid plan. `client.webhooks.limits()` reports what the plan allows and what is in use: endpoints, subscriptions, watched wallets, today's delivery budget and when it resets, and how many subscriptions are paused. + +| Plan | Endpoints | Subscriptions | Watched wallets | Deliveries per day | +| --- | --- | --- | --- | --- | +| Free | 0 | 0 | 0 | 0 | +| Build | 1 | 8 | 2 | 5,000 | +| Pro | 4 | 40 | 15 | 50,000 | +| Scale | 12 | 200 | 50 | 500,000 | +| Enterprise | 100 | 2,000 | 250 | No daily ceiling | + +`estimate()` and `dry_run()` are available on every plan, Free included, so a rule can be designed and sized before there is anywhere to deliver it. They are metered like the market data they return, and an account can run six of them a minute, shared between the two. An event type whose `scope` is `addresses` previews against your watched wallets, so it needs at least one on the list. + +```python +limits = client.webhooks.limits() +print(limits.plan, limits.included) +print(limits.subscriptions.used, "of", limits.subscriptions.limit) +print(limits.deliveries_per_day.remaining, "deliveries left until", limits.deliveries_per_day.resets_at) +print(limits.paused_subscriptions.count, "paused") +``` + +#### Paused subscriptions + +When an account reaches its daily delivery budget, its subscriptions pause and say so: `status` becomes `"auto_paused"`, `pause_reason` is `"deliveries_per_day_cap"` (or `"plan_no_webhooks"` when the plan has no delivery), and `pause_message` explains what clears it. Nothing is queued while a rule is paused, and a paused rule does not restart when the budget resets. Resume it, then recover the window it missed from the REST routes: + +```python +for sub in client.webhooks.list_subscriptions(): + if sub.status == "auto_paused": + print(sub.id, sub.pause_message, sub.suppressed_count) + +# One rule, or every paused rule on the account +result = client.webhooks.resume_subscription(sub.id) +result = client.webhooks.resume_all_subscriptions() +if result.gap: + window = result.gap.replay_window + print(f"re-read {window.start} to {window.end}: {result.gap.note}") +``` + +`gap.suppressed_count` is a lower bound on what matched while paused. It is `None`, with `counted` False, when the rules were address scoped, because their occurrences were never looked at. Resuming while the budget is still spent is refused (`OxArchiveError` with code 409) rather than granted and paused again. Your own `enabled` switch is never changed by a resume. + +#### Event types + +The catalog is the only place event types are declared. Read the filters, parameters, metrics and operators from it rather than hardcoding them. + +```python +for t in client.webhooks.event_types(): + if t.live: + print(t.type, t.scope, t.latency_class, t.description) + +liq = next(t for t in client.webhooks.event_types() if t.type == "market.liquidation") +print(liq.filters) # accepted config keys +print(liq.params) # tunable parameters, with type, bounds and default +print(liq.metrics) # what conditions can be written against +print(liq.operators["number"]) # operators for number metrics +print(liq.cost_floor) # the smallest occurrence the type reports +``` + +| Scope | Reports on | Needs | +| --- | --- | --- | +| `public` | Market-wide activity | Nothing extra | +| `addresses` | Your watched wallets | At least one watched wallet | +| `user` | Your own account and platform activity | Nothing extra | + +A type whose `live` is `False` is published but does not yet accept subscriptions. + +#### Configuration: filters, parameters and conditions + +A subscription's configuration is checked against the event type's declaration before anything is stored, so an unknown key, an undeclared parameter, an out of range value or a condition on a metric the event does not carry is refused with the reason, never dropped. Declared parameters left out are stored at their defaults, so the stored configuration comes back more complete than what was sent. Pass the configuration as a dict or as a `WebhookSubscriptionConfig`; the API calls it `filters` on subscriptions and `config` on the previews, and the SDK takes it as `config` everywhere. + +```python +from oxarchive import WebhookSubscriptionConfig, WebhookSubscriptionCondition + +# Address scoped rules can only name watched wallets: add the wallet first +client.webhooks.add_address("0x6b9e773128f453f5c2c60935ee2de2cbc5390a24", label="Desk 1") + +sub = client.webhooks.create_subscription( + endpoint.id, + "account.fill", + WebhookSubscriptionConfig( + addresses=["0x6b9e773128f453f5c2c60935ee2de2cbc5390a24"], + conditions=[ + WebhookSubscriptionCondition(metric="notional_usd", op=">=", value=25_000), + WebhookSubscriptionCondition(metric="taker", op="equal", value=True), + ], + ), +) + +# Edit in place. A config replaces the stored one, so send the whole object. +client.webhooks.update_subscription(sub.id, config={"addresses": [...], "conditions": [...]}) + +# Or switch the rule off without losing it +client.webhooks.update_subscription(sub.id, enabled=False) +``` + +Conditions accept symbol spellings (`>=`, `<`, `!=`) as well as canonical names (`greater_than_or_equal`, `less_than`, `not_equal`), and are stored canonically. `between` and `not_between` take a `[low, high]` pair, `in` and `not_in` a list, and `is_empty` and `is_not_empty` no value. A subscription holds at most 16 conditions, all of which must hold for a delivery. + +#### Previewing a rule + +`dry_run()` answers "which recent occurrences would this have delivered?". `estimate()` answers "how often would it have fired?". Both validate the configuration exactly as `create_subscription()` does, so a configuration that previews cleanly subscribes cleanly. Neither stores anything or sends a delivery. Not every event type can be previewed; a type that cannot is refused with the list of those that can. + +```python +# Which ones, over a recent window (60 s to 24 h, default 1 h) +preview = client.webhooks.dry_run( + "market.liquidation", + {"venue": "hyperliquid", "min_notional_usd": 500_000}, + lookback_s=86_400, + limit=100, # 1 to 200 +) +print(f"{preview.matched} matched between {preview.window.from_} and {preview.window.to}") +for occurrence in preview.occurrences[:5]: + print(occurrence.observed_at_estimate, occurrence.data) + +# How often, over 1 to 30 days (default 7) +est = client.webhooks.estimate("market.liquidation_burst", lookback_days=14) +print(f"{est.total} over {est.days} days, median {est.per_day_p50:.0f} a day ({est.basis.mode})") + +# And what a different threshold would have produced +for rung in est.ladder: + print(f" at {rung.value:,.0f}: {rung.per_day:.1f} a day") +``` + +`window.from_` is named with a trailing underscore because `from` is a Python keyword. `window` can start later than requested when a scan reached its row cap; `truncated` says so. + +#### Verifying deliveries + +Every delivery is signed with HMAC-SHA256. Verify it, or anyone who learns the URL can post to it. + +The one rule that matters: **verify the raw request body**, before any JSON parser touches it. The signature covers the exact bytes that were sent, and their key order and spacing need not match what any JSON library produces, so a re-serialised dict will not verify. + +```python +import os +from oxarchive import WebhookVerifier, WebhookSignatureError + +verifier = WebhookVerifier(os.environ["OXARCHIVE_WEBHOOK_SECRET"]) + +# Flask +@app.post("/webhooks/0xarchive") +def receive(): + try: + event = verifier.verify(request.get_data(), request.headers) + except WebhookSignatureError as e: + # Answer 4xx. A 5xx asks for a retry of the same unverifiable delivery. + app.logger.warning("rejected webhook: %s", e.reason) + return "", 400 + + if already_seen(event.id): # delivery is at-least-once + return "", 200 + enqueue(event.payload) # do the work out of band + return "", 200 +``` + +| Framework | Raw body | +| --- | --- | +| Flask | `request.get_data()` | +| FastAPI / Starlette | `await request.body()` | +| Django | `request.body` | + +Not `request.get_json()`, not a parsed Pydantic model, not `request.POST`. A proxy or gateway in front of the receiver that re-encodes JSON also breaks verification: verify before that layer, or turn it off. + +Each delivery carries these headers: + +| Header | Meaning | +| --- | --- | +| `0xa-signature` | `t=,v1=`, and during a secret rotation a second `v1=` | +| `0xa-event-id` | Event identifier. **Deduplicate on this.** Stable across retries and repeat deliveries | +| `0xa-event-type` | The event type, for example `account.fill` | +| `content-type` | `application/json` | + +The signed string is the header's timestamp, a full stop, and the raw body. The event id, the event type and the other headers are not signed, so the replay window plus deduplication on `0xa-event-id` is the defence against replays. Serve the receiver over HTTPS. + +`WebhookVerifier` accepts every `v1=` in the header, compares signatures in constant time, and enforces a replay window of 5 minutes by default. Every attempt, including retries and repeat deliveries, is signed with a fresh timestamp, so a legitimate delivery is never older than network and clock skew: + +```python +verifier = WebhookVerifier(secret, tolerance_seconds=120) # tighten it if you like +``` + +A failed check raises `WebhookSignatureError` with a `reason`: `missing_header`, `malformed_header`, `timestamp_out_of_tolerance`, `no_secrets` or `signature_mismatch`. `is_valid()` returns a bool instead. For a one-off check without holding state: + +```python +from oxarchive import verify_webhook + +event = verify_webhook(raw_body_bytes, headers, secret) +``` + +#### Rotating a secret + +`rotate_secret()` issues a new secret and keeps the previous one verifying for 24 hours. During the overlap every delivery carries **two** `v1=` signatures, one under each secret, so a receiver holding either keeps verifying. + +```python +rotated = client.webhooks.rotate_secret(endpoint.id) + +verifier.add_secret(rotated.secret) # now accepts both +deploy() # ship the new secret to every replica +verifier.remove_secret(old_secret) # before the 24 hours are up +``` + +Only one previous secret is kept: rotating twice inside the window retires the original at once. A verifier written by hand must read **every** `v1=` in the header, not just the first, or it fails for up to 24 hours after each rotation. + +#### Retries and failures + +| Behaviour | Detail | +| --- | --- | +| Success | HTTP 200 to 299. Redirects are not followed, so a 301 or 302 is a failure | +| Timeout | 10 seconds per attempt. Acknowledge at once and process asynchronously | +| Retry schedule | 5 s, 30 s, 2 min, 10 min, 1 h, then hourly, for up to 24 hours | +| `Retry-After` | Honoured on a failed attempt, between 1 second and 5 minutes. A 429 does not count against the endpoint's health | +| Auto-disable | After 10 consecutive failures spanning at least 6 hours (`status == "auto_disabled"`) | +| Recovery | Fix the receiver, then `client.webhooks.enable_endpoint(endpoint_id)` | + +```python +# What happened +for d in client.webhooks.list_deliveries(endpoint.id, limit=100): + print(d.event_type, d.state, d.attempts, d.last_status_code, d.last_error) + +# Send one again after fixing the receiver. The event id is unchanged, so a +# receiver that already processed it can deduplicate it. +client.webhooks.redeliver(delivery_id) +``` + +A test delivery and a repeat delivery are real signed deliveries: they count against today's budget. A repeat to a switched-off endpoint is refused (code 409), since nothing would be sent; re-enable the endpoint first. + +#### Watched addresses + +```python +client.webhooks.add_address("0x6b9e773128f453f5c2c60935ee2de2cbc5390a24", label="Desk 1") +watched = client.webhooks.list_addresses() +client.webhooks.delete_address(watched[0].id) +``` + +Adding a wallet that is already watched changes nothing and does not count against the cap again. Addresses are stored lowercase. Hyperliquid bridge system addresses are refused: such an address is a counterparty to every bridge movement of its token rather than an account. Removing a wallet leaves subscriptions that named it unchanged, so edit those rules too. + +#### Every webhook method + +| Method | Route | +| --- | --- | +| `event_types()` | `GET /v1/webhooks/event-types` | +| `limits()` | `GET /v1/webhooks/limits` | +| `list_endpoints()` | `GET /v1/webhooks/endpoints` | +| `create_endpoint(url, description="")` | `POST /v1/webhooks/endpoints` | +| `delete_endpoint(endpoint_id)` | `DELETE /v1/webhooks/endpoints/{id}` | +| `enable_endpoint(endpoint_id)` | `POST /v1/webhooks/endpoints/{id}/enable` | +| `rotate_secret(endpoint_id)` | `POST /v1/webhooks/endpoints/{id}/rotate` | +| `test_endpoint(endpoint_id)` | `POST /v1/webhooks/endpoints/{id}/test` | +| `list_deliveries(endpoint_id, limit=None)` | `GET /v1/webhooks/endpoints/{id}/deliveries` | +| `redeliver(delivery_id)` | `POST /v1/webhooks/deliveries/{id}/redeliver` | +| `list_subscriptions()` | `GET /v1/webhooks/subscriptions` | +| `create_subscription(endpoint_id, event_type, config=None)` | `POST /v1/webhooks/subscriptions` | +| `update_subscription(subscription_id, config=None, enabled=None)` | `PATCH /v1/webhooks/subscriptions/{id}` | +| `delete_subscription(subscription_id)` | `DELETE /v1/webhooks/subscriptions/{id}` | +| `resume_subscription(subscription_id)` | `POST /v1/webhooks/subscriptions/{id}/resume` | +| `resume_all_subscriptions()` | `POST /v1/webhooks/subscriptions/resume` | +| `dry_run(event_type, config=None, lookback_s=None, limit=None)` | `POST /v1/webhooks/subscriptions/dry-run` | +| `estimate(event_type, config=None, lookback_days=None)` | `POST /v1/webhooks/subscriptions/estimate` | +| `list_addresses()` | `GET /v1/webhooks/addresses` | +| `add_address(address, label="")` | `POST /v1/webhooks/addresses` | +| `delete_address(address_id)` | `DELETE /v1/webhooks/addresses/{id}` | + +Every method has an async version prefixed with `a`: `await client.webhooks.aestimate(...)`, `await client.webhooks.acreate_endpoint(...)`, and so on. Management calls cost no credits; the two previews are metered like market data. + ### Web3 Authentication Get API keys programmatically using an Ethereum wallet. No browser or email required. @@ -1500,6 +1940,35 @@ asyncio.run(main()) Hyperliquid `open_interest` and `funding` also stream live, in addition to historical replay. Subscribe with `ws.subscribe("open_interest", "BTC")` or `ws.subscribe("funding", "BTC")`. Each update's `data` holds `coin` and a `ctx` object with fields such as `openInterest`, `funding`, `markPx`, and `oraclePx`. These channels have no typed callback, so their updates arrive on `on_message` as `WsData` messages. +### Full-Depth L2 Book + +`orderbook_full` (Hyperliquid core) and `hip3_orderbook_full` (HIP-3) stream every price level of the book, not just the top 20, aggregated from the order-level book. A subscription opens with an `l4_snapshot` of the whole book, followed by `l4_batch` messages of changed levels. Receive them with `on_l4_snapshot` and `on_l4_batch`; the `channel` argument tells them apart from the L4 channels. These channels are live-only; stored full-depth history is on REST `l2_orderbook.history()` and `l2_orderbook.diffs()`. + +```python +book = {"B": {}, "A": {}} + +def on_snapshot(channel, coin, message): + data = message["data"] + book["B"] = {level["px"]: level for level in data["bids"]} + book["A"] = {level["px"]: level for level in data["asks"]} + print(coin, data["bid_count"], data["ask_count"], data["mid_price"]) + +def on_batch(channel, coin, levels): + for level in levels: # apply in the order received + side = book[level["side"]] + if level["sz"] == 0: + side.pop(level["px"], None) # the level was removed + else: + side[level["px"]] = level + +ws.on_l4_snapshot(on_snapshot) +ws.on_l4_batch(on_batch) +ws.subscribe_orderbook_full("BTC") +ws.subscribe_hip3_orderbook_full("km:US500") # case-sensitive +``` + +Each level is `{"px", "sz", "n"}` with numeric values; a changed level also carries `side` (`"B"` or `"A"`) and `bn`, the block it was applied in. The snapshot's `data` also holds `bid_count`, `ask_count`, `total_bid_size`, `total_ask_size`, `mid_price`, `spread`, `spread_bps` and `is_crossed`, and `last_block_number` on the message is the block the snapshot reflects. + ### Live Lighter.xyz Data Live Lighter messages use the same `data` envelope as Hyperliquid live data. Symbols are the ones returned by `client.lighter.instruments.list()`; they are case-insensitive on subscribe and echoed uppercase. Live Lighter data is served at `wss://api.0xarchive.io/ws`, the client default; `wss://stream.0xarchive.io/ws` does not serve Lighter channels and answers a Lighter subscribe with an error pointing to `wss://api.0xarchive.io/ws`. It is available on every plan and metered per message like Hyperliquid live data, with the same per-plan subscription and connection limits and the limit of 10 subscribe operations per second. @@ -1740,6 +2209,7 @@ ws = OxArchiveWs(WsOptions( | `all_tickers` | All market tickers | No | Yes | No | | `l4_diffs` | L4 orderbook diffs with user attribution | Yes | Yes | Yes | | `l4_orders` | Order lifecycle events with user attribution | Yes | Yes | Yes | +| `orderbook_full` | Full-depth L2 order book: every price level, then changed levels | Yes | Yes | No | Only Hyperliquid core `l4_diffs` and `l4_orders` support historical L4 replay. Their sequence is `l4_snapshot` followed by ordered `l4_batch` events. HIP-3, HIP-4, and Hyperliquid Spot L4 remain live-only. @@ -1757,6 +2227,9 @@ Only Hyperliquid core `l4_diffs` and `l4_orders` support historical L4 replay. T | `hip3_liquidations` | HIP-3 liquidation events (2025-12-22+) | Yes | Yes | Yes | | `hip3_l4_diffs` | HIP-3 L4 orderbook diffs | Yes | Yes | No | | `hip3_l4_orders` | HIP-3 order lifecycle events | Yes | Yes | No | +| `hip3_orderbook_full` | HIP-3 full-depth L2 order book: every price level, then changed levels | Yes | Yes | No | + +> **Note:** `orderbook_full` and `hip3_orderbook_full` are live-only; `replay()` and `multi_replay()` reject them with `ValueError` before anything is sent. Stored full-depth history is served over REST by `l2_orderbook.history()` and `l2_orderbook.diffs()`. > **Note:** HIP-3 coins are case-sensitive (e.g., `km:US500`, `xyz:XYZ100`). Do not uppercase them. @@ -2051,6 +2524,8 @@ from oxarchive.types import ( LighterLiquidation, LighterLiquidationVolume, ResponseMeta, Position, PositionChange, MarketPosition, MarketPositionsSummary, AccountSummary, WalletPositions, LighterL1Accounts, + CvdBucket, Hip3OracleExternalPrice, Hip4Question, WalletClassification, SymbolEntry, + WebhookSubscription, WebhookEstimate, WebhookLimits, ) from oxarchive.resources.trades import CursorResponse @@ -2076,6 +2551,11 @@ now: CursorResponse[WalletPositions] = client.hyperliquid.positions.get("0xabc.. meta: ResponseMeta = now.meta legs: CursorResponse[list[PositionChange]] = client.rh_lighter.positions.changes(4521, start=..., end=...) +# Cumulative volume delta, wallet classification and webhooks +cvd: CursorResponse[list[CvdBucket]] = client.hyperliquid.cvd.history("BTC", start=...) +wallets: WalletClassification = client.hyperliquid.wallets.classify(limit=10) +rules: list[WebhookSubscription] = client.webhooks.list_subscriptions() + # Lighter granularity type hint granularity: LighterGranularity = "10s" diff --git a/oxarchive/__init__.py b/oxarchive/__init__.py index 0030bbf..bb8610e 100644 --- a/oxarchive/__init__.py +++ b/oxarchive/__init__.py @@ -14,6 +14,7 @@ funding from 2026-08-22) - Account positions on client.hyperliquid.positions, client.hyperliquid.hip3.positions, client.lighter.positions and client.rh_lighter.positions +- Webhooks on client.webhooks, with WebhookVerifier to verify deliveries Example: >>> from oxarchive import Client @@ -60,6 +61,19 @@ reconstruct_final, ) from .l4_reconstructor import L4OrderBookReconstructor, L4Order, L2Level +from .webhook_signature import ( + DEFAULT_TOLERANCE_SECONDS, + EVENT_ID_HEADER, + EVENT_TYPE_HEADER, + SIGNATURE_HEADER, + WebhookEvent, + WebhookSignature, + WebhookSignatureError, + WebhookVerifier, + parse_signature_header, + verify_webhook, + verify_webhook_signature, +) from .types import ( OrderBook, Trade, @@ -98,6 +112,49 @@ OxArchiveError, CursorResponse, ResponseMeta, + # Cumulative volume delta, HIP-3 oracle, HIP-4 questions + CvdBucket, + CvdInterval, + Hip3OracleDiscoveryBounds, + Hip3OracleExternalPrice, + Hip4Question, + # Wallet classification and the symbol universe + WalletClassifySort, + WalletClassifyMetrics, + ClassifiedWallet, + WalletClassification, + SymbolEntry, + # Webhook types + WebhookEventTypeParam, + WebhookEventTypeMetric, + WebhookCostFloor, + WebhookEventType, + WebhookEndpoint, + WebhookEndpointCreated, + WebhookEndpointSecret, + WebhookSubscriptionCondition, + WebhookSubscriptionConfig, + WebhookSubscription, + WebhookResumeReplayWindow, + WebhookResumeGap, + WebhookSubscriptionResume, + WebhookSubscriptionResumeAll, + WebhookDelivery, + WebhookDeliveryQueued, + WebhookRedelivery, + WebhookWatchedAddress, + WebhookLimitUsage, + WebhookDeliveryBudget, + WebhookPausedSubscriptions, + WebhookLimits, + WebhookPreviewWindow, + WebhookPreviewOccurrence, + WebhookDryRun, + WebhookEstimateDayCount, + WebhookEstimateRung, + WebhookEstimateDistribution, + WebhookEstimateBasis, + WebhookEstimate, # Account positions types Position, PositionLeverage, @@ -226,6 +283,61 @@ "OxArchiveError", "CursorResponse", "ResponseMeta", + # Cumulative Volume Delta, HIP-3 Oracle, HIP-4 Questions + "CvdBucket", + "CvdInterval", + "Hip3OracleDiscoveryBounds", + "Hip3OracleExternalPrice", + "Hip4Question", + # Wallet Classification and the Symbol Universe + "WalletClassifySort", + "WalletClassifyMetrics", + "ClassifiedWallet", + "WalletClassification", + "SymbolEntry", + # Webhook Signature Verification + "WebhookVerifier", + "WebhookEvent", + "WebhookSignature", + "WebhookSignatureError", + "verify_webhook", + "verify_webhook_signature", + "parse_signature_header", + "SIGNATURE_HEADER", + "EVENT_ID_HEADER", + "EVENT_TYPE_HEADER", + "DEFAULT_TOLERANCE_SECONDS", + # Webhook Types + "WebhookEventTypeParam", + "WebhookEventTypeMetric", + "WebhookCostFloor", + "WebhookEventType", + "WebhookEndpoint", + "WebhookEndpointCreated", + "WebhookEndpointSecret", + "WebhookSubscriptionCondition", + "WebhookSubscriptionConfig", + "WebhookSubscription", + "WebhookResumeReplayWindow", + "WebhookResumeGap", + "WebhookSubscriptionResume", + "WebhookSubscriptionResumeAll", + "WebhookDelivery", + "WebhookDeliveryQueued", + "WebhookRedelivery", + "WebhookWatchedAddress", + "WebhookLimitUsage", + "WebhookDeliveryBudget", + "WebhookPausedSubscriptions", + "WebhookLimits", + "WebhookPreviewWindow", + "WebhookPreviewOccurrence", + "WebhookDryRun", + "WebhookEstimateDayCount", + "WebhookEstimateRung", + "WebhookEstimateDistribution", + "WebhookEstimateBasis", + "WebhookEstimate", # Account Positions Types "Position", "PositionLeverage", diff --git a/oxarchive/_params.py b/oxarchive/_params.py new file mode 100644 index 0000000..c81965e --- /dev/null +++ b/oxarchive/_params.py @@ -0,0 +1,29 @@ +"""Keyword arguments the SDK no longer sends. + +Several methods keep ``**kwargs`` for the deprecated ``coin`` keyword, so a +removed parameter would otherwise be accepted and silently dropped. These +parameters were removed because the API never applied them; raising says so +instead of returning unfiltered data as if the filter had worked. +""" + +from __future__ import annotations + +from typing import Any, Mapping + + +def reject_unsupported( + method: str, kwargs: Mapping[str, Any], unsupported: Mapping[str, str] +) -> None: + """Raise ``TypeError`` if ``kwargs`` holds a parameter the API does not apply. + + Args: + method: The method name, for the message. + kwargs: The keyword arguments the method received beyond its signature. + unsupported: Parameter name to the reason it is not supported. + """ + for name, reason in unsupported.items(): + if name in kwargs: + raise TypeError(f"{method}() does not take '{name}': {reason}") + + +__all__ = ["reject_unsupported"] diff --git a/oxarchive/client.py b/oxarchive/client.py index c7d199a..58d0e06 100644 --- a/oxarchive/client.py +++ b/oxarchive/client.py @@ -14,7 +14,9 @@ FundingResource, OpenInterestResource, DataQualityResource, + SymbolsResource, Web3Resource, + WebhooksResource, ) DEFAULT_BASE_URL = "https://api.0xarchive.io" @@ -43,6 +45,10 @@ class Client: `client.hyperliquid.hip3.positions`, `client.lighter.positions` and `client.rh_lighter.positions`. + Cross-venue: `client.symbols` (the public symbol universe), + `client.data_quality`, and `client.webhooks` (push delivery; verify + deliveries with :class:`~oxarchive.WebhookVerifier`). + Example: >>> from oxarchive import Client >>> @@ -143,6 +149,15 @@ def __init__( self.web3 = Web3Resource(self._http) """Wallet-based auth: get API keys via SIWE signature""" + # Public symbol universe (cross-venue discovery) + self.symbols = SymbolsResource(self._http) + """Every public symbol across venue families, with coverage dates""" + + # Webhooks (push delivery). Delivery needs a paid plan; the estimate + # and dry-run previews are available on every plan. + self.webhooks = WebhooksResource(self._http) + """Webhook endpoints, subscriptions, watched wallets, deliveries and limits""" + # Legacy resource namespaces (deprecated - use client.hyperliquid.* instead) # These will be removed in v2.0 # Note: Using /v1/hyperliquid base path for backward compatibility diff --git a/oxarchive/exchanges.py b/oxarchive/exchanges.py index fd53140..370ffc6 100644 --- a/oxarchive/exchanges.py +++ b/oxarchive/exchanges.py @@ -11,13 +11,18 @@ from .resources import ( BreadthResource, CandlesResource, + CvdResource, FundingResource, Hip3CandlesResource, Hip3InstrumentsResource, + Hip3LiquidationsResource, + Hip3OracleResource, Hip4CandlesResource, Hip4InstrumentsResource, Hip4OpenInterestResource, + Hip4OrdersResource, Hip4OutcomesResource, + Hip4QuestionsResource, HyperliquidPositionsResource, InstrumentsResource, L2OrderBookResource, @@ -32,9 +37,11 @@ OrderBookResource, OrdersResource, SpotCandlesResource, + SpotOrdersResource, SpotPairsResource, SpotTwapResource, TradesResource, + WalletsResource, ) from .types import ( CoinFreshness, @@ -42,6 +49,7 @@ CursorResponse, Hip4Outcome, Hip4OutcomeAggregate, + Hip4Question, LiquidationVolume, PriceSnapshot, SpotTableFreshness, @@ -119,6 +127,16 @@ def __init__(self, http: HttpClient): change log, account summaries, and market-wide listings. Change log from 2025-05-25, hourly history from 2026-06-07, live every 5 minutes.""" + self.breadth = BreadthResource(http, base_path, label="Hyperliquid core") + """Percent of eligible core perpetuals above their UTC-session VWAP + (history from 2026-08-24).""" + + self.cvd = CvdResource(http, base_path) + """Cumulative volume delta: taker buy and sell notional per bucket.""" + + self.wallets = WalletsResource(http, base_path) + """Wallet classification: daily behavior metrics for active wallets.""" + self.hip3 = Hip3Client(http) """HIP-3 builder-deployed perpetuals (trades from 2025-10-13)""" @@ -359,8 +377,10 @@ def __init__(self, http: HttpClient): self.candles = Hip3CandlesResource(http, base_path, coin_transform=coin_transform) """OHLCV candle data (max 10,000 rows per page)""" - self.liquidations = LiquidationsResource(http, base_path, coin_transform=coin_transform) - """Liquidation events""" + self.liquidations = Hip3LiquidationsResource( + http, base_path, coin_transform=coin_transform + ) + """Liquidation events, volume and levels (no per-user route on HIP-3)""" self.orders = OrdersResource(http, base_path, coin_transform=coin_transform) """L4 order history, flow, and TP/SL""" @@ -376,6 +396,15 @@ def __init__(self, http: HttpClient): Change log from 2025-10-13, hourly history from 2026-06-07, live every 5 minutes.""" + self.cvd = CvdResource(http, base_path, coin_transform=coin_transform) + """Cumulative volume delta: taker buy and sell notional per bucket.""" + + self.oracle = Hip3OracleResource(http, base_path) + """Deployer-pushed external price and discovery bounds.""" + + self.wallets = WalletsResource(http, base_path) + """Wallet classification: daily behavior metrics for active HIP-3 wallets.""" + _convert_timestamp = staticmethod(to_unix_ms) def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness: @@ -553,6 +582,9 @@ def __init__(self, http: HttpClient): self.outcomes = Hip4OutcomesResource(http, base_path) """HIP-4 outcome-level metadata (one row per outcome_id).""" + self.questions = Hip4QuestionsResource(http, base_path) + """HIP-4 questions: multi-choice groupings of outcome markets.""" + self.orderbook = OrderBookResource(http, base_path, coin_transform=_hip4_encode) """L2 order book snapshots.""" @@ -567,15 +599,12 @@ def __init__(self, http: HttpClient): ) """Per-side open interest. For paired/aggregated OI use ``outcomes.get()``.""" - self.orders = OrdersResource(http, base_path, coin_transform=_hip4_encode) + self.orders = Hip4OrdersResource(http, base_path, coin_transform=_hip4_encode) """L4 order history, flow, and TP/SL.""" self.l4_orderbook = L4OrderBookResource(http, base_path, coin_transform=_hip4_encode) """L4 order-level orderbook data.""" - self.l2_orderbook = L2OrderBookResource(http, base_path, coin_transform=_hip4_encode) - """L2 full-depth orderbook (derived from L4).""" - _convert_timestamp = staticmethod(to_unix_ms) # ----------------------------------------------------------------- @@ -653,6 +682,32 @@ async def aget_outcome_by_slug(self, slug: str) -> Hip4OutcomeAggregate: """Async version of get_outcome_by_slug().""" return await self.outcomes.aget_by_slug(slug) + def list_questions( + self, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[Hip4Question]]: + """List questions, ascending by question ID. Pass ``next_cursor`` back unchanged.""" + return self.questions.list(cursor=cursor, limit=limit) + + async def alist_questions( + self, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[Hip4Question]]: + """Async version of list_questions().""" + return await self.questions.alist(cursor=cursor, limit=limit) + + def get_question(self, question_id: int) -> Hip4Question: + """Get a single question by its numeric ID.""" + return self.questions.get(question_id) + + async def aget_question(self, question_id: int) -> Hip4Question: + """Async version of get_question().""" + return await self.questions.aget(question_id) + # ----------------------------------------------------------------- # Market-data flat helpers (mirror HIP-3 surface) # ----------------------------------------------------------------- @@ -1103,12 +1158,9 @@ def __init__(self, http: HttpClient): self.candles = SpotCandlesResource(http, base_path) """OHLCV candle history (from 2025-03-22T10:50:22Z; max 1,000 rows).""" - self.orders = OrdersResource(http, base_path) - """L4 order lifecycle history (live from 2026-05-05). - - Note: spot exposes only ``history()``. Flow and TP/SL endpoints exist - on the resource but the spot backend does not implement them. - """ + self.orders = SpotOrdersResource(http, base_path) + """L4 order lifecycle history (live from 2026-05-05). Spot serves + ``history()`` only: no flow, TP/SL or trigger levels.""" self.l4_orderbook = L4OrderBookResource(http, base_path) """L4 order-level orderbook: full reconstruction, raw diffs, diff --git a/oxarchive/http.py b/oxarchive/http.py index 059b22a..69248ed 100644 --- a/oxarchive/http.py +++ b/oxarchive/http.py @@ -158,3 +158,60 @@ async def apost( except httpx.HTTPError as e: raise OxArchiveError(f"Network error: {e}", 0) from e return self._handle_response(response) + + def patch( + self, + path: str, + json: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """Make a synchronous PATCH request.""" + try: + response = self.client.patch(path, json=json) + except httpx.HTTPError as e: + raise OxArchiveError(f"Network error: {e}", 0) from e + return self._handle_response(response) + + async def apatch( + self, + path: str, + json: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """Make an asynchronous PATCH request.""" + try: + response = await self.async_client.patch(path, json=json) + except httpx.HTTPError as e: + raise OxArchiveError(f"Network error: {e}", 0) from e + return self._handle_response(response) + + def delete( + self, + path: str, + params: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """Make a synchronous DELETE request. + + No 0xarchive DELETE route takes a request body, so none is sent. + """ + if params: + params = {k: v for k, v in params.items() if v is not None} + + try: + response = self.client.delete(path, params=params) + except httpx.HTTPError as e: + raise OxArchiveError(f"Network error: {e}", 0) from e + return self._handle_response(response) + + async def adelete( + self, + path: str, + params: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """Make an asynchronous DELETE request.""" + if params: + params = {k: v for k, v in params.items() if v is not None} + + try: + response = await self.async_client.delete(path, params=params) + except httpx.HTTPError as e: + raise OxArchiveError(f"Network error: {e}", 0) from e + return self._handle_response(response) diff --git a/oxarchive/resources/__init__.py b/oxarchive/resources/__init__.py index 3023554..be8d867 100644 --- a/oxarchive/resources/__init__.py +++ b/oxarchive/resources/__init__.py @@ -2,6 +2,7 @@ from .breadth import BreadthResource from .candles import CandlesResource, Hip3CandlesResource, Hip4CandlesResource, SpotCandlesResource +from .cvd import CvdResource from .data_quality import DataQualityResource from .funding import FundingResource from .instruments import ( @@ -14,19 +15,23 @@ from .l3_orderbook import L3OrderBookResource from .l4_orderbook import L4OrderBookResource from .lighter_liquidations import LighterLiquidationsResource -from .liquidations import LiquidationsResource +from .liquidations import Hip3LiquidationsResource, LiquidationsResource from .openinterest import Hip4OpenInterestResource, OpenInterestResource +from .oracle import Hip3OracleResource from .orderbook import OrderBookResource -from .orders import OrdersResource -from .outcomes import Hip4OutcomesResource +from .orders import Hip4OrdersResource, OrdersResource, SpotOrdersResource +from .outcomes import Hip4OutcomesResource, Hip4QuestionsResource from .positions import ( HyperliquidPositionsResource, LighterAccountsResource, LighterPositionsResource, ) from .spot import SpotPairsResource, SpotTwapResource +from .symbols import SymbolsResource from .trades import TradesResource +from .wallets import WalletsResource from .web3 import Web3Resource +from .webhooks import WebhooksResource __all__ = [ "OrderBookResource", @@ -44,10 +49,13 @@ "Hip4CandlesResource", "SpotCandlesResource", "LiquidationsResource", + "Hip3LiquidationsResource", "LighterLiquidationsResource", "DataQualityResource", "Web3Resource", "OrdersResource", + "Hip4OrdersResource", + "SpotOrdersResource", "Hip4OutcomesResource", "L4OrderBookResource", "L2OrderBookResource", @@ -57,4 +65,10 @@ "HyperliquidPositionsResource", "LighterPositionsResource", "LighterAccountsResource", + "CvdResource", + "Hip3OracleResource", + "Hip4QuestionsResource", + "WalletsResource", + "SymbolsResource", + "WebhooksResource", ] diff --git a/oxarchive/resources/breadth.py b/oxarchive/resources/breadth.py index defb477..8091c3a 100644 --- a/oxarchive/resources/breadth.py +++ b/oxarchive/resources/breadth.py @@ -1,4 +1,4 @@ -"""HIP-3 market breadth API resource.""" +"""Market breadth API resource (Hyperliquid core and HIP-3).""" from __future__ import annotations @@ -13,32 +13,38 @@ class BreadthResource: - """HIP-3 percent-above-session-VWAP market breadth. - - The current route returns one validated snapshot. History defaults to the - last 24 hours of raw one-minute snapshots; the server applies the - last-snapshot-per-bucket rule when ``interval`` is supplied. Collection - began on 2026-08-28, so callers must not infer synthetic pre-launch history. + """Percent-above-session-VWAP market breadth. + + Served for HIP-3 (``client.hyperliquid.hip3.breadth``, history from + 2026-08-28) and Hyperliquid core perpetuals (``client.hyperliquid.breadth``, + history from 2026-08-24). The current route returns one validated + snapshot. History defaults to the last 24 hours of raw one-minute + snapshots; the server applies the last-snapshot-per-bucket rule when + ``interval`` is supplied. Do not infer history before collection began. + Core responses carry empty ``namespaces`` maps. """ def __init__( self, http: HttpClient, base_path: str = "/v1/hyperliquid/hip3", + label: str = "HIP-3", ) -> None: self._http = http self._base_path = base_path + self._label = label self._max_limit = 1000 def _validate_limit(self, limit: Optional[int]) -> None: if limit is not None and not 1 <= limit <= self._max_limit: - raise ValueError(f"limit must be between 1 and {self._max_limit} for HIP-3 breadth") + raise ValueError( + f"limit must be between 1 and {self._max_limit} for {self._label} breadth" + ) - @staticmethod - def _validate_interval(interval: Optional[BreadthInterval]) -> None: + def _validate_interval(self, interval: Optional[BreadthInterval]) -> None: if interval is not None and interval not in BREADTH_INTERVALS: choices = ", ".join(sorted(BREADTH_INTERVALS)) - raise ValueError(f"interval must be one of {choices} for HIP-3 breadth") + raise ValueError(f"interval must be one of {choices} for {self._label} breadth") _convert_timestamp = staticmethod(to_unix_ms) @@ -50,7 +56,7 @@ def _history_response(payload: dict[str, Any]) -> CursorResponse[list[BreadthSna ) def current(self) -> BreadthSnapshot: - """Return the latest validated HIP-3 breadth snapshot.""" + """Return the latest validated breadth snapshot.""" payload = self._http.get(f"{self._base_path}/breadth/above-vwap/current") return BreadthSnapshot.model_validate(payload["data"]) @@ -68,12 +74,13 @@ def history( cursor: Optional[str] = None, limit: Optional[int] = None, ) -> CursorResponse[list[BreadthSnapshot]]: - """Return ascending HIP-3 breadth history with cursor pagination. + """Return ascending breadth history with cursor pagination. - ``start`` defaults to 24 hours before ``end`` and ``end`` defaults to + ``start`` defaults to 24 hours before now and ``end`` defaults to now. ``cursor`` is the epoch-millisecond string returned by - ``meta.next_cursor`` and is passed back unchanged. History begins on - 2026-08-28; a pre-launch window may be empty with coverage metadata. + ``meta.next_cursor`` and is passed back unchanged. HIP-3 history + begins on 2026-08-28 and core history on 2026-08-24; a window before + that may be empty with coverage metadata. """ self._validate_interval(interval) self._validate_limit(limit) diff --git a/oxarchive/resources/cvd.py b/oxarchive/resources/cvd.py new file mode 100644 index 0000000..c06323d --- /dev/null +++ b/oxarchive/resources/cvd.py @@ -0,0 +1,186 @@ +"""Cumulative volume delta API resource (Hyperliquid core and HIP-3).""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Callable, Iterator, Optional + +from .._time import to_unix_ms +from ..http import HttpClient +from ..types import CursorResponse, CvdBucket, CvdInterval, ResponseMeta, Timestamp + +CVD_INTERVALS = frozenset({"1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"}) +"""Bucket widths the API accepts on cumulative volume delta.""" + +CVD_MAX_LIMIT = 10_000 +"""Largest page size the API accepts on cumulative volume delta.""" + + +def _page(payload: dict[str, Any]) -> CursorResponse[list[CvdBucket]]: + meta = ResponseMeta.model_validate(payload.get("meta") or {}) + return CursorResponse( + data=[CvdBucket.model_validate(item) for item in payload["data"]], + next_cursor=meta.next_cursor, + meta=meta, + ) + + +class CvdResource: + """ + Cumulative volume delta: taker buy and sell notional per bucket. + + Buckets are labelled by their open time in UTC and omitted when they hold + no trades. Intervals of ``1h`` and longer roll up hourly totals; ``1m``, + ``5m``, ``15m`` and ``30m`` are summed from taker fills. ``4h``, ``1d`` and + ``1w`` buckets open on UTC epoch boundaries (so ``1w`` buckets open on + Thursdays), and at those widths the first and last bucket of a window can + be partial. + + ``cumulative_delta`` restarts on every page. To join pages, rebuild the + running total from ``delta``; a response that is one page of several says + so in ``meta.notice``. + + Example: + >>> page = client.hyperliquid.cvd.history( + ... "BTC", start="2026-09-01", end="2026-09-02", interval="1m" + ... ) + >>> for bucket in client.hyperliquid.cvd.iterate( + ... "BTC", start="2026-09-01", end="2026-09-02", interval="1m" + ... ): + ... print(bucket.timestamp, bucket.delta) + """ + + def __init__( + self, + http: HttpClient, + base_path: str = "/v1/hyperliquid", + coin_transform: Callable[[str], str] = str.upper, + ) -> None: + self._http = http + self._base_path = base_path + self._coin_transform = coin_transform + + @staticmethod + def _params( + start: Optional[Timestamp], + end: Optional[Timestamp], + interval: Optional[CvdInterval], + cursor: Optional[str], + limit: Optional[int], + ) -> dict[str, Any]: + if interval is not None and interval not in CVD_INTERVALS: + choices = ", ".join(sorted(CVD_INTERVALS)) + raise ValueError(f"interval must be one of {choices} for cumulative volume delta") + if limit is not None and not 1 <= limit <= CVD_MAX_LIMIT: + raise ValueError( + f"limit must be between 1 and {CVD_MAX_LIMIT} for cumulative volume delta" + ) + return { + "start": to_unix_ms(start, "start"), + "end": to_unix_ms(end, "end"), + "interval": interval, + "cursor": cursor, + "limit": limit, + } + + def _path(self, symbol: str) -> str: + return f"{self._base_path}/cvd/{self._coin_transform(symbol)}" + + def history( + self, + symbol: str, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + interval: Optional[CvdInterval] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[CvdBucket]]: + """ + Get one page of cumulative volume delta buckets. + + While ``next_cursor`` is set, more buckets may follow inside + ``[start, end]``: pass it back as ``cursor`` with ``start``, ``end`` + and ``interval`` unchanged, and stop when it is None. Below ``1h`` a + page can hold fewer than ``limit`` buckets and still carry a cursor, + so stop on the cursor, not on a short page. Without ``start`` or + ``cursor``, the response is the newest ``limit`` buckets of the 24 + hours before ``end`` (or now), with no cursor. + + Args: + symbol: Symbol (for example ``BTC``; HIP-3 symbols keep their + builder prefix and case, for example ``km:US500``). + start: Window start. A time without a time zone is UTC. + end: Window end (default: now). + interval: Bucket width: ``1m``, ``5m``, ``15m``, ``30m``, ``1h`` + (default), ``4h``, ``1d`` or ``1w``. + cursor: The previous page's ``next_cursor``, passed back unchanged. + limit: Buckets per page, 1 to 10000 (default 500). + + Returns: + CursorResponse with the buckets, ``next_cursor`` and ``meta``. + """ + payload = self._http.get( + self._path(symbol), params=self._params(start, end, interval, cursor, limit) + ) + return _page(payload) + + async def ahistory( + self, + symbol: str, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + interval: Optional[CvdInterval] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[CvdBucket]]: + """Async version of :meth:`history`.""" + payload = await self._http.aget( + self._path(symbol), params=self._params(start, end, interval, cursor, limit) + ) + return _page(payload) + + def iterate( + self, + symbol: str, + *, + start: Timestamp, + end: Optional[Timestamp] = None, + interval: Optional[CvdInterval] = None, + limit: Optional[int] = None, + ) -> Iterator[CvdBucket]: + """ + Yield every bucket in ``[start, end]``, following ``next_cursor``. + + Each page is requested with the same ``start``, ``end`` and + ``interval``. ``cumulative_delta`` on the yielded buckets restarts at + each page boundary; sum ``delta`` for a running total over the window. + """ + params = self._params(start, end, interval, None, limit) + path = self._path(symbol) + while True: + page = _page(self._http.get(path, params=params)) + yield from page.data + if not page.next_cursor: + return + params = {**params, "cursor": page.next_cursor} + + async def aiterate( + self, + symbol: str, + *, + start: Timestamp, + end: Optional[Timestamp] = None, + interval: Optional[CvdInterval] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[CvdBucket]: + """Async version of :meth:`iterate`.""" + params = self._params(start, end, interval, None, limit) + path = self._path(symbol) + while True: + page = _page(await self._http.aget(path, params=params)) + for bucket in page.data: + yield bucket + if not page.next_cursor: + return + params = {**params, "cursor": page.next_cursor} diff --git a/oxarchive/resources/data_quality.py b/oxarchive/resources/data_quality.py index 326da7e..3f85305 100644 --- a/oxarchive/resources/data_quality.py +++ b/oxarchive/resources/data_quality.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Literal, Optional +from urllib.parse import quote from .._time import to_unix_ms from ..http import HttpClient @@ -143,7 +144,10 @@ def symbol_coverage( Args: exchange: Venue scope: 'hyperliquid', 'hip3', 'hip4', 'spot', 'lighter' or 'rh-lighter' (Lighter on Robinhood Chain) - symbol: Symbol name (e.g., 'BTC', 'ETH', HIP3 coins like 'xyz:XYZ100', or HIP4 coins like '#0') + symbol: Symbol exactly as the venue's routes take it: 'BTC', a + case-sensitive HIP-3 coin such as 'km:US500', a spot pair such as + 'HYPE-USDC', or a HIP-4 coin such as '#0'. It is sent as one + URL-encoded path segment and is not re-cased. from_time: Start of gap detection window (default: now - 30 days). Accepts Unix ms, datetime, or ISO string. to_time: End of gap detection window (default: now). @@ -171,7 +175,7 @@ def symbol_coverage( ... print(f"Cadence: {btc.data_types['orderbook'].cadence.median_interval_seconds}s") """ data = self._http.get( - f"{self._base_path}/coverage/{exchange.lower()}/{symbol.upper()}", + f"{self._base_path}/coverage/{exchange.lower()}/{quote(symbol, safe='')}", params={ "from": self._convert_timestamp(from_time), "to": self._convert_timestamp(to_time), @@ -189,7 +193,7 @@ async def asymbol_coverage( ) -> SymbolCoverageResponse: """Async version of symbol_coverage().""" data = await self._http.aget( - f"{self._base_path}/coverage/{exchange.lower()}/{symbol.upper()}", + f"{self._base_path}/coverage/{exchange.lower()}/{quote(symbol, safe='')}", params={ "from": self._convert_timestamp(from_time), "to": self._convert_timestamp(to_time), diff --git a/oxarchive/resources/l2_orderbook.py b/oxarchive/resources/l2_orderbook.py index c959bfc..a5c877f 100644 --- a/oxarchive/resources/l2_orderbook.py +++ b/oxarchive/resources/l2_orderbook.py @@ -4,10 +4,17 @@ from typing import Optional +from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, Timestamp +_UNSUPPORTED_HISTORY = { + "depth": ( + "history returns every price level of each snapshot. depth applies to get() only." + ), +} + class L2OrderBookResource: """ @@ -87,7 +94,6 @@ def history( end: Timestamp, cursor: Optional[str] = None, limit: Optional[int] = None, - depth: Optional[int] = None, **kwargs, ) -> CursorResponse: """ @@ -99,12 +105,12 @@ def history( end: End timestamp (required) cursor: Cursor from previous response's next_cursor limit: Maximum number of results - depth: Number of price levels per side Returns: CursorResponse with L2 orderbook checkpoints and next_cursor for pagination """ symbol = self._resolve_symbol(symbol, kwargs) + reject_unsupported("history", kwargs, _UNSUPPORTED_HISTORY) data = self._http.get( f"{self._base_path}/orderbook/{self._coin_transform(symbol)}/l2/history", params={ @@ -112,7 +118,6 @@ def history( "end": self._convert_timestamp(end), "cursor": cursor, "limit": limit, - "depth": depth, }, ) return CursorResponse( @@ -128,11 +133,11 @@ async def ahistory( end: Timestamp, cursor: Optional[str] = None, limit: Optional[int] = None, - depth: Optional[int] = None, **kwargs, ) -> CursorResponse: """Async version of history().""" symbol = self._resolve_symbol(symbol, kwargs) + reject_unsupported("ahistory", kwargs, _UNSUPPORTED_HISTORY) data = await self._http.aget( f"{self._base_path}/orderbook/{self._coin_transform(symbol)}/l2/history", params={ @@ -140,7 +145,6 @@ async def ahistory( "end": self._convert_timestamp(end), "cursor": cursor, "limit": limit, - "depth": depth, }, ) return CursorResponse( diff --git a/oxarchive/resources/l3_orderbook.py b/oxarchive/resources/l3_orderbook.py index c51408c..2220af4 100644 --- a/oxarchive/resources/l3_orderbook.py +++ b/oxarchive/resources/l3_orderbook.py @@ -4,10 +4,18 @@ from typing import Optional +from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, Timestamp +_UNSUPPORTED_HISTORY = { + "depth": ( + "history returns every resting order of each snapshot, up to 250 per side. " + "depth applies to get() only." + ), +} + class L3OrderBookResource: """ @@ -44,6 +52,7 @@ def get( *, timestamp: Optional[Timestamp] = None, depth: Optional[int] = None, + account: Optional[int] = None, **kwargs, ) -> dict: """ @@ -53,6 +62,7 @@ def get( symbol: The symbol (e.g., 'BTC', 'ETH') timestamp: Optional timestamp to get historical snapshot depth: Maximum individual resting orders per side (1 to 250) + account: Only the orders of this Lighter account index Returns: L3 order book snapshot (dict) @@ -64,6 +74,7 @@ def get( params={ "timestamp": self._convert_timestamp(timestamp), "depth": depth, + "account": account, }, ) return data["data"] @@ -74,6 +85,7 @@ async def aget( *, timestamp: Optional[Timestamp] = None, depth: Optional[int] = None, + account: Optional[int] = None, **kwargs, ) -> dict: """Async version of get().""" @@ -84,6 +96,7 @@ async def aget( params={ "timestamp": self._convert_timestamp(timestamp), "depth": depth, + "account": account, }, ) return data["data"] @@ -96,7 +109,7 @@ def history( end: Timestamp, cursor: Optional[str] = None, limit: Optional[int] = None, - depth: Optional[int] = None, + account: Optional[int] = None, **kwargs, ) -> CursorResponse: """ @@ -108,13 +121,13 @@ def history( end: End timestamp (required) cursor: Cursor from previous response's next_cursor limit: Maximum number of results - depth: Maximum individual resting orders per side (1 to 250) + account: Only the orders of this Lighter account index Returns: CursorResponse with L3 orderbook snapshots and next_cursor for pagination """ symbol = self._resolve_symbol(symbol, kwargs) - self._validate_depth(depth) + reject_unsupported("history", kwargs, _UNSUPPORTED_HISTORY) data = self._http.get( f"{self._base_path}/l3orderbook/{self._coin_transform(symbol)}/history", params={ @@ -122,7 +135,7 @@ def history( "end": self._convert_timestamp(end), "cursor": cursor, "limit": limit, - "depth": depth, + "account": account, }, ) return CursorResponse( @@ -138,12 +151,12 @@ async def ahistory( end: Timestamp, cursor: Optional[str] = None, limit: Optional[int] = None, - depth: Optional[int] = None, + account: Optional[int] = None, **kwargs, ) -> CursorResponse: """Async version of history().""" symbol = self._resolve_symbol(symbol, kwargs) - self._validate_depth(depth) + reject_unsupported("ahistory", kwargs, _UNSUPPORTED_HISTORY) data = await self._http.aget( f"{self._base_path}/l3orderbook/{self._coin_transform(symbol)}/history", params={ @@ -151,7 +164,7 @@ async def ahistory( "end": self._convert_timestamp(end), "cursor": cursor, "limit": limit, - "depth": depth, + "account": account, }, ) return CursorResponse( diff --git a/oxarchive/resources/l4_orderbook.py b/oxarchive/resources/l4_orderbook.py index 8572bde..97715ea 100644 --- a/oxarchive/resources/l4_orderbook.py +++ b/oxarchive/resources/l4_orderbook.py @@ -4,10 +4,17 @@ from typing import Optional +from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, Timestamp +_UNSUPPORTED_HISTORY = { + "depth": ( + "history returns every order of each checkpoint. depth applies to get() only." + ), +} + class L4OrderBookResource: """ @@ -151,7 +158,6 @@ def history( end: Timestamp, cursor: Optional[str] = None, limit: Optional[int] = None, - depth: Optional[int] = None, **kwargs, ) -> CursorResponse: """ @@ -163,12 +169,12 @@ def history( end: End timestamp (required) cursor: Cursor from previous response's next_cursor limit: Maximum number of results - depth: Number of price levels per side Returns: CursorResponse with L4 orderbook snapshots and next_cursor for pagination """ symbol = self._resolve_symbol(symbol, kwargs) + reject_unsupported("history", kwargs, _UNSUPPORTED_HISTORY) data = self._http.get( f"{self._base_path}/orderbook/{self._coin_transform(symbol)}/l4/history", params={ @@ -176,7 +182,6 @@ def history( "end": self._convert_timestamp(end), "cursor": cursor, "limit": limit, - "depth": depth, }, ) return CursorResponse( @@ -192,11 +197,11 @@ async def ahistory( end: Timestamp, cursor: Optional[str] = None, limit: Optional[int] = None, - depth: Optional[int] = None, **kwargs, ) -> CursorResponse: """Async version of history().""" symbol = self._resolve_symbol(symbol, kwargs) + reject_unsupported("ahistory", kwargs, _UNSUPPORTED_HISTORY) data = await self._http.aget( f"{self._base_path}/orderbook/{self._coin_transform(symbol)}/l4/history", params={ @@ -204,7 +209,6 @@ async def ahistory( "end": self._convert_timestamp(end), "cursor": cursor, "limit": limit, - "depth": depth, }, ) return CursorResponse( diff --git a/oxarchive/resources/liquidations.py b/oxarchive/resources/liquidations.py index d1aec49..72869bd 100644 --- a/oxarchive/resources/liquidations.py +++ b/oxarchive/resources/liquidations.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional +from typing import Any, Optional from .._time import to_unix_ms from ..http import HttpClient @@ -16,29 +16,8 @@ ) -class LiquidationsResource: - """ - Liquidations API resource. - - Retrieve historical liquidation events from Hyperliquid. - - Note: Liquidation data is available from May 25, 2025 onwards. - - Example: - >>> # Get recent liquidations - >>> liquidations = client.hyperliquid.liquidations.history( - ... "BTC", - ... start="2025-06-01", - ... end="2025-06-02" - ... ) - >>> - >>> # Get liquidations for a specific user - >>> user_liquidations = client.hyperliquid.liquidations.by_user( - ... "0x1234...", - ... start="2025-06-01", - ... end="2025-06-02" - ... ) - """ +class _LiquidationsBase: + """Liquidation history, volume and projected liquidation levels.""" def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str.upper): self._http = http @@ -120,111 +99,6 @@ async def ahistory( next_cursor=data.get("meta", {}).get("next_cursor"), ) - def by_user( - self, - user_address: str, - *, - start: Timestamp, - end: Timestamp, - symbol: Optional[str] = None, - cursor: Optional[str] = None, - limit: Optional[int] = None, - **kwargs, - ) -> CursorResponse[list[Liquidation]]: - """ - Get liquidation history for a specific user. - - This returns liquidations where the user was either: - - The liquidated party (their position was liquidated) - - The liquidator (they executed the liquidation) - - Args: - user_address: User's wallet address (e.g., '0x1234...') - start: Start timestamp (required) - end: End timestamp (required) - symbol: Optional symbol filter (e.g., 'BTC', 'ETH') - cursor: Cursor from previous response's next_cursor - limit: Maximum number of results (default: 100, max: 1000) - - Returns: - CursorResponse with liquidation records and next_cursor for pagination - """ - # Handle deprecated 'coin' kwarg for the symbol filter - if "coin" in kwargs: - import warnings - - warnings.warn( - "'coin' is deprecated, use 'symbol' instead", - DeprecationWarning, - stacklevel=2, - ) - if symbol is None: - symbol = kwargs.pop("coin") - else: - kwargs.pop("coin") - - params = { - "start": self._convert_timestamp(start), - "end": self._convert_timestamp(end), - "cursor": cursor, - "limit": limit, - } - if symbol: - params["coin"] = symbol.upper() - - data = self._http.get( - f"{self._base_path}/liquidations/user/{user_address}", - params=params, - ) - return CursorResponse( - data=[Liquidation.model_validate(item) for item in data["data"]], - next_cursor=data.get("meta", {}).get("next_cursor"), - ) - - async def aby_user( - self, - user_address: str, - *, - start: Timestamp, - end: Timestamp, - symbol: Optional[str] = None, - cursor: Optional[str] = None, - limit: Optional[int] = None, - **kwargs, - ) -> CursorResponse[list[Liquidation]]: - """Async version of by_user().""" - # Handle deprecated 'coin' kwarg for the symbol filter - if "coin" in kwargs: - import warnings - - warnings.warn( - "'coin' is deprecated, use 'symbol' instead", - DeprecationWarning, - stacklevel=2, - ) - if symbol is None: - symbol = kwargs.pop("coin") - else: - kwargs.pop("coin") - - params = { - "start": self._convert_timestamp(start), - "end": self._convert_timestamp(end), - "cursor": cursor, - "limit": limit, - } - if symbol: - params["coin"] = symbol.upper() - - data = await self._http.aget( - f"{self._base_path}/liquidations/user/{user_address}", - params=params, - ) - return CursorResponse( - data=[Liquidation.model_validate(item) for item in data["data"]], - next_cursor=data.get("meta", {}).get("next_cursor"), - ) - def volume( self, symbol: str, @@ -439,7 +313,7 @@ async def alevels_history( ) @staticmethod - def _resolve_symbol(symbol, kwargs): + def _resolve_symbol(symbol: str, kwargs: dict[str, Any]) -> str: import warnings if "coin" in kwargs: @@ -453,3 +327,147 @@ def _resolve_symbol(symbol, kwargs): else: kwargs.pop("coin") return symbol + + +class LiquidationsResource(_LiquidationsBase): + """ + Liquidations API resource. + + Retrieve historical liquidation events from Hyperliquid. + + Note: Liquidation data is available from May 25, 2025 onwards. + + Example: + >>> # Get recent liquidations + >>> liquidations = client.hyperliquid.liquidations.history( + ... "BTC", + ... start="2025-06-01", + ... end="2025-06-02" + ... ) + >>> + >>> # Get liquidations for a specific user + >>> user_liquidations = client.hyperliquid.liquidations.by_user( + ... "0x1234...", + ... start="2025-06-01", + ... end="2025-06-02" + ... ) + """ + + def by_user( + self, + user_address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + **kwargs, + ) -> CursorResponse[list[Liquidation]]: + """ + Get liquidation history for a specific user. + + This returns liquidations where the user was either: + - The liquidated party (their position was liquidated) + - The liquidator (they executed the liquidation) + + Args: + user_address: User's wallet address (e.g., '0x1234...') + start: Start timestamp (required) + end: End timestamp (required) + symbol: Optional symbol filter (e.g., 'BTC', 'ETH') + cursor: Cursor from previous response's next_cursor + limit: Maximum number of results (default: 100, max: 1000) + + Returns: + CursorResponse with liquidation records and next_cursor for pagination + """ + # Handle deprecated 'coin' kwarg for the symbol filter + if "coin" in kwargs: + import warnings + + warnings.warn( + "'coin' is deprecated, use 'symbol' instead", + DeprecationWarning, + stacklevel=2, + ) + if symbol is None: + symbol = kwargs.pop("coin") + else: + kwargs.pop("coin") + + params = { + "start": self._convert_timestamp(start), + "end": self._convert_timestamp(end), + "cursor": cursor, + "limit": limit, + } + if symbol: + params["coin"] = symbol.upper() + + data = self._http.get( + f"{self._base_path}/liquidations/user/{user_address}", + params=params, + ) + return CursorResponse( + data=[Liquidation.model_validate(item) for item in data["data"]], + next_cursor=data.get("meta", {}).get("next_cursor"), + ) + + async def aby_user( + self, + user_address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + **kwargs, + ) -> CursorResponse[list[Liquidation]]: + """Async version of by_user().""" + # Handle deprecated 'coin' kwarg for the symbol filter + if "coin" in kwargs: + import warnings + + warnings.warn( + "'coin' is deprecated, use 'symbol' instead", + DeprecationWarning, + stacklevel=2, + ) + if symbol is None: + symbol = kwargs.pop("coin") + else: + kwargs.pop("coin") + + params = { + "start": self._convert_timestamp(start), + "end": self._convert_timestamp(end), + "cursor": cursor, + "limit": limit, + } + if symbol: + params["coin"] = symbol.upper() + + data = await self._http.aget( + f"{self._base_path}/liquidations/user/{user_address}", + params=params, + ) + return CursorResponse( + data=[Liquidation.model_validate(item) for item in data["data"]], + next_cursor=data.get("meta", {}).get("next_cursor"), + ) + + +class Hip3LiquidationsResource(_LiquidationsBase): + """ + HIP-3 liquidations: history, volume and projected liquidation levels. + + HIP-3 has no per-user liquidation route, so this resource has no + ``by_user``. Symbols keep their builder prefix and case. + + Example: + >>> liqs = client.hyperliquid.hip3.liquidations.history( + ... "xyz:XYZ100", start="2026-09-01", end="2026-09-02" + ... ) + """ diff --git a/oxarchive/resources/oracle.py b/oxarchive/resources/oracle.py new file mode 100644 index 0000000..9a6e152 --- /dev/null +++ b/oxarchive/resources/oracle.py @@ -0,0 +1,60 @@ +"""HIP-3 oracle API resource.""" + +from __future__ import annotations + +from ..http import HttpClient +from ..types import Hip3OracleDiscoveryBounds, Hip3OracleExternalPrice + + +class Hip3OracleResource: + """ + HIP-3 oracle reads: the deployer-pushed external price and the discovery bounds. + + Symbols keep their builder prefix and case (for example ``km:US500``). + + Example: + >>> price = client.hyperliquid.hip3.oracle.external_price("km:US500") + >>> print(price.external_price, price.mark_price) + >>> bounds = client.hyperliquid.hip3.oracle.discovery_bounds("km:US500") + >>> print(bounds.lower_bound, bounds.upper_bound) + """ + + def __init__(self, http: HttpClient, base_path: str = "/v1/hyperliquid/hip3") -> None: + self._http = http + self._base_path = base_path + + def discovery_bounds(self, symbol: str) -> Hip3OracleDiscoveryBounds: + """ + Get the instantaneous discovery bounds for a HIP-3 market. + + The bounds are ``reference_price`` times one minus and one plus + ``bound_fraction``, where the reference is the external price when + available and the mark price otherwise, and the fraction follows from + the market's max leverage. The full ratcheted range can be wider when + a deployer's reset configuration applies. + + Args: + symbol: HIP-3 symbol with its builder prefix (case-sensitive). + """ + data = self._http.get(f"{self._base_path}/oracle/discovery-bounds/{symbol}") + return Hip3OracleDiscoveryBounds.model_validate(data["data"]) + + async def adiscovery_bounds(self, symbol: str) -> Hip3OracleDiscoveryBounds: + """Async version of :meth:`discovery_bounds`.""" + data = await self._http.aget(f"{self._base_path}/oracle/discovery-bounds/{symbol}") + return Hip3OracleDiscoveryBounds.model_validate(data["data"]) + + def external_price(self, symbol: str) -> Hip3OracleExternalPrice: + """ + Get the latest deployer-pushed external price and mark price for a HIP-3 market. + + Args: + symbol: HIP-3 symbol with its builder prefix (case-sensitive). + """ + data = self._http.get(f"{self._base_path}/oracle/external-price/{symbol}") + return Hip3OracleExternalPrice.model_validate(data["data"]) + + async def aexternal_price(self, symbol: str) -> Hip3OracleExternalPrice: + """Async version of :meth:`external_price`.""" + data = await self._http.aget(f"{self._base_path}/oracle/external-price/{symbol}") + return Hip3OracleExternalPrice.model_validate(data["data"]) diff --git a/oxarchive/resources/orders.py b/oxarchive/resources/orders.py index 18db9ab..c06b046 100644 --- a/oxarchive/resources/orders.py +++ b/oxarchive/resources/orders.py @@ -2,8 +2,9 @@ from __future__ import annotations -from typing import Optional +from typing import Any, Optional +from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient from ..types import ( @@ -14,21 +15,8 @@ ) -class OrdersResource: - """ - L4 order history, flow, and TP/SL endpoints. - - Example: - >>> # Get order history - >>> result = client.hyperliquid.orders.history("BTC", start="2024-01-01", end="2024-01-02") - >>> orders = result.data - >>> - >>> # Get order flow aggregation - >>> flow = client.hyperliquid.orders.flow("BTC", start="2024-01-01", end="2024-01-02") - >>> - >>> # Get TP/SL history - >>> tpsl = client.hyperliquid.orders.tpsl("BTC", start="2024-01-01", end="2024-01-02") - """ +class _OrdersBase: + """Shared construction and symbol handling for the order resources.""" def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str.upper): self._http = http @@ -37,6 +25,26 @@ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str. _convert_timestamp = staticmethod(to_unix_ms) + @staticmethod + def _resolve_symbol(symbol: str, kwargs: dict[str, Any]) -> str: + import warnings + + if "coin" in kwargs: + warnings.warn( + "'coin' is deprecated, use 'symbol' instead", + DeprecationWarning, + stacklevel=3, + ) + if symbol is None: + symbol = kwargs.pop("coin") + else: + kwargs.pop("coin") + return symbol + + +class _OrderFlowResource(_OrdersBase): + """Order history, flow and TP/SL: the routes Hyperliquid core, HIP-3 and HIP-4 share.""" + def history( self, symbol: str, @@ -128,23 +136,24 @@ def flow( **kwargs, ) -> CursorResponse: """ - Get order flow aggregation in time buckets. + Get order flow aggregation, one page of time buckets. Buckets are labelled by their open time in UTC, and buckets with no - events are omitted. A response holds the oldest ``limit`` buckets of - the window. + events are omitted. A page holds the oldest ``limit`` buckets of the + window. While ``next_cursor`` is set, pass it back as ``cursor`` with + the same ``start``, ``end`` and ``interval``; stop when it is None. Args: symbol: The symbol (e.g., 'BTC', 'ETH') start: Start timestamp, inclusive (required) end: End timestamp, exclusive (required) interval: Bucket width: '1m' (default), '5m', '15m' or '1h' - cursor: Optional resume point, a Unix millisecond timestamp; the - response starts at the first bucket that opens after it - limit: Maximum number of buckets (default: 1000, max: 10000) + cursor: The previous response's next_cursor (a numeric + millisecond timestamp string); pass it back unchanged + limit: Buckets per page (default: 1000, max: 10000) Returns: - CursorResponse with order flow buckets + CursorResponse with order flow buckets and next_cursor for pagination """ symbol = self._resolve_symbol(symbol, kwargs) data = self._http.get( @@ -265,6 +274,22 @@ async def atpsl( ) +class OrdersResource(_OrderFlowResource): + """ + L4 order history, flow, TP/SL and trigger levels (Hyperliquid core and HIP-3). + + Example: + >>> # Get order history + >>> result = client.hyperliquid.orders.history("BTC", start="2024-01-01", end="2024-01-02") + >>> orders = result.data + >>> + >>> # Get order flow aggregation + >>> flow = client.hyperliquid.orders.flow("BTC", start="2024-01-01", end="2024-01-02") + >>> + >>> # Get TP/SL history + >>> tpsl = client.hyperliquid.orders.tpsl("BTC", start="2024-01-01", end="2024-01-02") + """ + def trigger_levels( self, symbol: str, @@ -387,18 +412,102 @@ async def atrigger_levels_history( next_cursor=data.get("meta", {}).get("next_cursor"), ) - @staticmethod - def _resolve_symbol(symbol, kwargs): - import warnings - if "coin" in kwargs: - warnings.warn( - "'coin' is deprecated, use 'symbol' instead", - DeprecationWarning, - stacklevel=3, - ) - if symbol is None: - symbol = kwargs.pop("coin") - else: - kwargs.pop("coin") - return symbol +class Hip4OrdersResource(_OrderFlowResource): + """ + HIP-4 L4 order history, flow and TP/SL. + + HIP-4 has no trigger-level map, so this resource has no ``trigger_levels``. + + Example: + >>> result = client.hyperliquid.hip4.orders.history("0", start=..., end=...) + >>> flow = client.hyperliquid.hip4.orders.flow("0", start=..., end=...) + """ + + +_UNSUPPORTED_SPOT_HISTORY = { + name: "the spot order history route does not filter by it. Filter the returned rows instead." + for name in ("user", "status", "order_type") +} + + +class SpotOrdersResource(_OrdersBase): + """ + Hyperliquid spot L4 order lifecycle history (live from 2026-05-05). + + Spot serves order history only: there is no flow, TP/SL or trigger-level + route, and the history route takes no user, status or order-type filter. + + Example: + >>> result = client.spot.orders.history("HYPE-USDC", start=..., end=...) + >>> while result.next_cursor: + ... result = client.spot.orders.history( + ... "HYPE-USDC", start=..., end=..., cursor=result.next_cursor + ... ) + """ + + def history( + self, + symbol: str, + *, + start: Timestamp, + end: Timestamp, + cursor: Optional[str] = None, + limit: Optional[int] = None, + **kwargs: Any, + ) -> CursorResponse[list[dict[str, Any]]]: + """ + Get spot order lifecycle history. + + Args: + symbol: Pair symbol in dashed canonical form (e.g., 'HYPE-USDC') + start: Start timestamp (required) + end: End timestamp (required) + cursor: Cursor from previous response's next_cursor + limit: Maximum number of results + + Returns: + CursorResponse with order data and next_cursor for pagination + """ + symbol = self._resolve_symbol(symbol, kwargs) + reject_unsupported("history", kwargs, _UNSUPPORTED_SPOT_HISTORY) + data = self._http.get( + f"{self._base_path}/orders/{self._coin_transform(symbol)}/history", + params={ + "start": self._convert_timestamp(start), + "end": self._convert_timestamp(end), + "cursor": cursor, + "limit": limit, + }, + ) + return CursorResponse( + data=data["data"], + next_cursor=data.get("meta", {}).get("next_cursor"), + ) + + async def ahistory( + self, + symbol: str, + *, + start: Timestamp, + end: Timestamp, + cursor: Optional[str] = None, + limit: Optional[int] = None, + **kwargs: Any, + ) -> CursorResponse[list[dict[str, Any]]]: + """Async version of history().""" + symbol = self._resolve_symbol(symbol, kwargs) + reject_unsupported("ahistory", kwargs, _UNSUPPORTED_SPOT_HISTORY) + data = await self._http.aget( + f"{self._base_path}/orders/{self._coin_transform(symbol)}/history", + params={ + "start": self._convert_timestamp(start), + "end": self._convert_timestamp(end), + "cursor": cursor, + "limit": limit, + }, + ) + return CursorResponse( + data=data["data"], + next_cursor=data.get("meta", {}).get("next_cursor"), + ) diff --git a/oxarchive/resources/outcomes.py b/oxarchive/resources/outcomes.py index 9f60969..da4ecf5 100644 --- a/oxarchive/resources/outcomes.py +++ b/oxarchive/resources/outcomes.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import Optional +from typing import Any, List, Optional from ..http import HttpClient -from ..types import CursorResponse, Hip4OutcomeAggregate +from ..types import CursorResponse, Hip4OutcomeAggregate, Hip4Question, ResponseMeta class Hip4OutcomesResource: @@ -62,7 +62,7 @@ async def alist( slug: Optional[str] = None, cursor: Optional[str] = None, limit: Optional[int] = None, - ) -> CursorResponse[list[Hip4OutcomeAggregate]]: + ) -> CursorResponse[List[Hip4OutcomeAggregate]]: """Async version of list().""" data = await self._http.aget( f"{self._base_path}/outcomes", @@ -103,3 +103,77 @@ async def aget_by_slug(self, slug: str) -> Hip4OutcomeAggregate: """Async version of get_by_slug().""" data = await self._http.aget(f"{self._base_path}/outcomes/by-slug/{slug}") return Hip4OutcomeAggregate.model_validate(data["data"]) + + +def _questions_page(payload: dict[str, Any]) -> CursorResponse[list[Hip4Question]]: + meta = ResponseMeta.model_validate(payload.get("meta") or {}) + return CursorResponse( + data=[Hip4Question.model_validate(item) for item in payload["data"]], + next_cursor=meta.next_cursor, + meta=meta, + ) + + +class Hip4QuestionsResource: + """ + HIP-4 questions: multi-choice resolvers that group binary outcome markets. + + A question has one named outcome per choice plus a fallback outcome that + resolves Yes when no named choice does. Look the outcomes up with + ``client.hyperliquid.hip4.outcomes.get(outcome_id)``. + + Example: + >>> page = client.hyperliquid.hip4.questions.list(limit=100) + >>> question = client.hyperliquid.hip4.questions.get(0) + >>> print(question.named_outcome_ids, question.fallback_outcome_id) + """ + + def __init__(self, http: HttpClient, base_path: str = "/v1/hyperliquid/hip4"): + self._http = http + self._base_path = base_path + + def list( + self, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[Hip4Question]]: + """List questions in ascending question ID order. + + Args: + cursor: The previous page's ``next_cursor`` (a question ID), + passed back unchanged. + limit: Page size (default 100, max 1000). + + Returns: + CursorResponse with the questions and ``next_cursor``; ``None`` on + the last page. + """ + data = self._http.get( + f"{self._base_path}/questions", + params={"cursor": cursor, "limit": limit}, + ) + return _questions_page(data) + + async def alist( + self, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[List[Hip4Question]]: + """Async version of list().""" + data = await self._http.aget( + f"{self._base_path}/questions", + params={"cursor": cursor, "limit": limit}, + ) + return _questions_page(data) + + def get(self, question_id: int) -> Hip4Question: + """Get a single question by its numeric ID.""" + data = self._http.get(f"{self._base_path}/questions/{int(question_id)}") + return Hip4Question.model_validate(data["data"]) + + async def aget(self, question_id: int) -> Hip4Question: + """Async version of get().""" + data = await self._http.aget(f"{self._base_path}/questions/{int(question_id)}") + return Hip4Question.model_validate(data["data"]) diff --git a/oxarchive/resources/symbols.py b/oxarchive/resources/symbols.py new file mode 100644 index 0000000..ba20df6 --- /dev/null +++ b/oxarchive/resources/symbols.py @@ -0,0 +1,40 @@ +"""Public symbol universe API resource.""" + +from __future__ import annotations + +from typing import Any, List + +from ..http import HttpClient +from ..types import SymbolEntry + + +def _entries(payload: dict[str, Any]) -> list[SymbolEntry]: + return [SymbolEntry.model_validate(item) for item in payload.get("symbols") or []] + + +class SymbolsResource: + """ + The public symbol universe across every venue family. + + One entry per market with its venue family (``exchange``), the data types + served for it, coverage dates overall and per data type, and, for HIP-4, + the slug, outcome pair and display title. Use it to discover symbols + before choosing a venue-specific route. The route needs no API key and + returns the whole universe in one response. + + Example: + >>> symbols = client.symbols.list() + >>> hip3 = [s.symbol for s in symbols if s.exchange == "hip3"] + """ + + def __init__(self, http: HttpClient, base_path: str = "/v1") -> None: + self._http = http + self._base_path = base_path + + def list(self) -> list[SymbolEntry]: + """List every public symbol, across all venue families.""" + return _entries(self._http.get(f"{self._base_path}/symbols")) + + async def alist(self) -> List[SymbolEntry]: + """Async version of :meth:`list`.""" + return _entries(await self._http.aget(f"{self._base_path}/symbols")) diff --git a/oxarchive/resources/trades.py b/oxarchive/resources/trades.py index e803ac7..4a4def4 100644 --- a/oxarchive/resources/trades.py +++ b/oxarchive/resources/trades.py @@ -2,13 +2,19 @@ from __future__ import annotations -from typing import Literal, Optional +from typing import Optional, Union +from .._params import reject_unsupported from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, OxArchiveError, ResponseMeta, Trade, Timestamp +_UNSUPPORTED_LIST = { + "side": "the API does not filter trades by side. Filter the returned trades on Trade.side.", +} + + class TradesResource: """ Trades API resource. @@ -46,6 +52,19 @@ def __init__( _convert_timestamp = staticmethod(to_unix_ms) + @staticmethod + def _cursor(cursor: Optional[Timestamp]) -> Optional[Union[str, int]]: + """The ``cursor`` query value. + + A trades cursor is opaque: a string such as ``"1790640000578_218303497631402"`` + (Hyperliquid, HIP-3, HIP-4 and spot) or ``"1790540000288_32247366750_1"`` + (Lighter), sent back exactly as the API returned it. A string or an + integer is passed through unchanged; only a datetime is converted. + """ + if cursor is None or isinstance(cursor, (str, int)): + return cursor + return to_unix_ms(cursor, "cursor") + def list( self, symbol: str, @@ -54,7 +73,6 @@ def list( end: Timestamp, cursor: Optional[Timestamp] = None, limit: Optional[int] = None, - side: Optional[Literal["buy", "sell"]] = None, **kwargs, ) -> CursorResponse[list[Trade]]: """ @@ -67,9 +85,8 @@ def list( symbol: The symbol (e.g., 'BTC', 'ETH') start: Start timestamp (required) end: End timestamp (required) - cursor: Cursor from previous response's next_cursor (timestamp) + cursor: The previous response's next_cursor, passed back unchanged limit: Maximum number of results (default: 100, max: 1000) - side: Filter by trade side Returns: CursorResponse with trades and next_cursor for pagination. On @@ -90,14 +107,14 @@ def list( ... trades.extend(result.data) """ symbol = self._resolve_symbol(symbol, kwargs) + reject_unsupported("list", kwargs, _UNSUPPORTED_LIST) data = self._http.get( f"{self._base_path}/trades/{self._coin_transform(symbol)}", params={ "start": self._convert_timestamp(start), "end": self._convert_timestamp(end), - "cursor": self._convert_timestamp(cursor), + "cursor": self._cursor(cursor), "limit": limit, - "side": side, }, ) return CursorResponse( @@ -114,7 +131,6 @@ async def alist( end: Timestamp, cursor: Optional[Timestamp] = None, limit: Optional[int] = None, - side: Optional[Literal["buy", "sell"]] = None, **kwargs, ) -> CursorResponse[list[Trade]]: """ @@ -123,14 +139,14 @@ async def alist( Uses cursor-based pagination by default. """ symbol = self._resolve_symbol(symbol, kwargs) + reject_unsupported("alist", kwargs, _UNSUPPORTED_LIST) data = await self._http.aget( f"{self._base_path}/trades/{self._coin_transform(symbol)}", params={ "start": self._convert_timestamp(start), "end": self._convert_timestamp(end), - "cursor": self._convert_timestamp(cursor), + "cursor": self._cursor(cursor), "limit": limit, - "side": side, }, ) return CursorResponse( diff --git a/oxarchive/resources/wallets.py b/oxarchive/resources/wallets.py new file mode 100644 index 0000000..6e6fe30 --- /dev/null +++ b/oxarchive/resources/wallets.py @@ -0,0 +1,166 @@ +"""Wallet classification API resource (Hyperliquid core and HIP-3).""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any, Literal, Optional, Union + +from ..http import HttpClient +from ..types import WalletClassification, WalletClassifySort + + +def _snapshot_date(value: Union[str, date, datetime, None]) -> Optional[str]: + """``YYYY-MM-DD`` for the ``date`` parameter. + + A datetime without a time zone is UTC; an aware one is converted to UTC + first. A string is sent as given. + """ + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None: + value = value.astimezone(timezone.utc) + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + return value + + +class WalletsResource: + """ + Wallet classification: precomputed daily behavior metrics for active wallets. + + One row per wallet with order, fill, cancel, maker, fee, PnL, TWAP, + priority gas and builder metrics over the day, filtered and sorted server + side. Page with ``limit`` and ``offset``; ``total`` is the number of + wallets matching the filters. Each call costs 10 credits, with row-based + metering on the wallets returned where it applies. + + Example: + >>> page = client.hyperliquid.wallets.classify( + ... sort="total_volume_usd", min_volume_usd=1_000_000, limit=50 + ... ) + >>> for wallet in page.wallets: + ... print(wallet.address, wallet.metrics.maker_ratio) + """ + + def __init__(self, http: HttpClient, base_path: str = "/v1/hyperliquid") -> None: + self._http = http + self._base_path = base_path + + @staticmethod + def _params( + min_orders: Optional[int], + min_volume_usd: Optional[float], + sort: Optional[WalletClassifySort], + order: Optional[Literal["asc", "desc"]], + limit: Optional[int], + offset: Optional[int], + uses_twap: Optional[bool], + uses_priority_gas: Optional[bool], + min_cancel_rate: Optional[float], + max_cancel_rate: Optional[float], + date: Union[str, date, datetime, None], + ) -> dict[str, Any]: + return { + "min_orders": min_orders, + "min_volume_usd": min_volume_usd, + "sort": sort, + "order": order, + "limit": limit, + "offset": offset, + "uses_twap": uses_twap, + "uses_priority_gas": uses_priority_gas, + "min_cancel_rate": min_cancel_rate, + "max_cancel_rate": max_cancel_rate, + "date": _snapshot_date(date), + } + + def classify( + self, + *, + min_orders: Optional[int] = None, + min_volume_usd: Optional[float] = None, + sort: Optional[WalletClassifySort] = None, + order: Optional[Literal["asc", "desc"]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + uses_twap: Optional[bool] = None, + uses_priority_gas: Optional[bool] = None, + min_cancel_rate: Optional[float] = None, + max_cancel_rate: Optional[float] = None, + date: Union[str, date, datetime, None] = None, + ) -> WalletClassification: + """ + Classify active wallets by their daily behavior. + + Args: + min_orders: Minimum order count (server default 100). + min_volume_usd: Minimum fill volume in USD (server default 0). + sort: Metric to sort by (server default ``total_orders``). + order: ``"asc"`` or ``"desc"`` (server default ``"desc"``). + limit: Wallets per page, 1 to 1000 (server default 100). + offset: Wallets to skip, at most 100000 (server default 0). + uses_twap: Keep only wallets that did (True) or did not (False) use TWAP. + uses_priority_gas: Keep only wallets that did or did not pay priority gas. + min_cancel_rate: Minimum cancel rate, 0.0 to 1.0. + max_cancel_rate: Maximum cancel rate, 0.0 to 1.0. + date: Daily snapshot date (default: yesterday, UTC). A ``date``, + a ``YYYY-MM-DD`` string, or a datetime (a datetime without a + time zone is UTC). + + Returns: + WalletClassification with ``wallets``, ``total`` and ``date``. + """ + data = self._http.get( + f"{self._base_path}/wallets/classify", + params=self._params( + min_orders, + min_volume_usd, + sort, + order, + limit, + offset, + uses_twap, + uses_priority_gas, + min_cancel_rate, + max_cancel_rate, + date, + ), + ) + return WalletClassification.model_validate(data["data"]) + + async def aclassify( + self, + *, + min_orders: Optional[int] = None, + min_volume_usd: Optional[float] = None, + sort: Optional[WalletClassifySort] = None, + order: Optional[Literal["asc", "desc"]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + uses_twap: Optional[bool] = None, + uses_priority_gas: Optional[bool] = None, + min_cancel_rate: Optional[float] = None, + max_cancel_rate: Optional[float] = None, + date: Union[str, date, datetime, None] = None, + ) -> WalletClassification: + """Async version of :meth:`classify`.""" + data = await self._http.aget( + f"{self._base_path}/wallets/classify", + params=self._params( + min_orders, + min_volume_usd, + sort, + order, + limit, + offset, + uses_twap, + uses_priority_gas, + min_cancel_rate, + max_cancel_rate, + date, + ), + ) + return WalletClassification.model_validate(data["data"]) + diff --git a/oxarchive/resources/webhooks.py b/oxarchive/resources/webhooks.py new file mode 100644 index 0000000..39250aa --- /dev/null +++ b/oxarchive/resources/webhooks.py @@ -0,0 +1,797 @@ +"""Webhooks API resource: endpoints, subscriptions, watched wallets, deliveries.""" + +from __future__ import annotations + +from typing import Any, Optional, Union +from urllib.parse import quote + +from pydantic import BaseModel + +from ..http import HttpClient +from ..types import ( + WebhookDelivery, + WebhookDeliveryQueued, + WebhookDryRun, + WebhookEndpoint, + WebhookEndpointCreated, + WebhookEndpointSecret, + WebhookEstimate, + WebhookEventType, + WebhookLimits, + WebhookRedelivery, + WebhookSubscription, + WebhookSubscriptionConfig, + WebhookSubscriptionResume, + WebhookSubscriptionResumeAll, + WebhookWatchedAddress, +) + +WebhookConfig = Union[WebhookSubscriptionConfig, dict[str, Any]] +"""A subscription configuration: a :class:`WebhookSubscriptionConfig` or a plain dict.""" + + +def _segment(value: str) -> str: + """One path segment. Identifiers are UUIDs; anything else is encoded, never a path.""" + return quote(str(value), safe="") + + +def _config_body(config: WebhookConfig) -> dict[str, Any]: + """The JSON object sent for a configuration. + + A model is dumped without the fields left unset or ``None``, and keeps any + extra keys (declared parameters written at the top level). A dict is sent + as given. + """ + if isinstance(config, BaseModel): + return config.model_dump(mode="json", exclude_none=True) + return dict(config) + + +def _with_note(envelope: dict[str, Any]) -> dict[str, Any]: + """Fold the envelope's top-level ``note`` into the payload. + + Create, rotate and redeliver put an advisory next to ``data`` rather than + inside it. Merging the two lets one model carry both, without clobbering a + ``note`` the payload may carry of its own. + """ + payload = dict(envelope.get("data") or {}) + note = envelope.get("note") + if note is not None and "note" not in payload: + payload["note"] = note + return payload + + +def _resume(envelope: dict[str, Any]) -> WebhookSubscriptionResume: + return WebhookSubscriptionResume.model_validate( + { + "subscription": envelope.get("data") or {}, + "gap": envelope.get("gap"), + "note": envelope.get("note"), + } + ) + + +def _resume_all(envelope: dict[str, Any]) -> WebhookSubscriptionResumeAll: + return WebhookSubscriptionResumeAll.model_validate( + { + "subscriptions": envelope.get("data") or [], + "resumed_count": envelope.get("resumed_count", 0), + "gap": envelope.get("gap"), + "note": envelope.get("note"), + } + ) + + +class WebhooksResource: + """ + Webhooks: push delivery of market, account and platform events. + + Three objects, in the order you create them: + + 1. An **endpoint** is an HTTPS URL 0xArchive posts to. Creating one returns + its signing secret, once. + 2. A **subscription** is a rule: one event type, on one endpoint, with an + optional configuration of filters, parameters and conditions. + 3. A **watched address** is a wallet. Event types whose ``scope`` is + ``"addresses"`` report only on wallets on this list. + + Webhook delivery needs a paid plan; :meth:`limits` reports what the plan + allows and what is in use. :meth:`estimate` and :meth:`dry_run` are + available on every plan, Free included, so a rule can be sized before it + is created. Verify what arrives with :class:`~oxarchive.WebhookVerifier`. + + Every method has an async version prefixed with ``a``. + + Example: + >>> endpoint = client.webhooks.create_endpoint( + ... "https://example.com/hooks/0xarchive", description="Desk alerts" + ... ) + >>> store_secret(endpoint.secret) # shown once + >>> est = client.webhooks.estimate( + ... "market.liquidation", + ... {"venue": "hyperliquid", "min_notional_usd": 250000}, + ... lookback_days=7, + ... ) + >>> print(est.per_day_p50, est.per_day_max) + >>> sub = client.webhooks.create_subscription( + ... endpoint.id, + ... "market.liquidation", + ... {"venue": "hyperliquid", "min_notional_usd": 250000}, + ... ) + >>> client.webhooks.test_endpoint(endpoint.id) + """ + + def __init__(self, http: HttpClient, base_path: str = "/v1/webhooks"): + self._http = http + self._base_path = base_path + + # ========================================================================= + # Catalog and limits + # ========================================================================= + + def event_types(self) -> list[WebhookEventType]: + """ + List every event type with the filters, parameters, metrics and operators it accepts. + + Subscriptions are validated against these declarations, so read them + from here rather than hardcoding them. A type whose ``live`` is False + is published but does not yet accept subscriptions. + + Example: + >>> for t in client.webhooks.event_types(): + ... if t.live: + ... print(t.type, t.scope, t.latency_class) + """ + data = self._http.get(f"{self._base_path}/event-types") + return [WebhookEventType.model_validate(x) for x in data["data"]] + + async def aevent_types(self) -> list[WebhookEventType]: + """Async version of :meth:`event_types`.""" + data = await self._http.aget(f"{self._base_path}/event-types") + return [WebhookEventType.model_validate(x) for x in data["data"]] + + def limits(self) -> WebhookLimits: + """ + Get what the plan allows for webhooks and what is in use. + + Reports the endpoint, subscription and watched wallet caps, today's + delivery budget and when it resets, and how many subscriptions are + paused. The budget resets on its own and a paused subscription does + not, which is why the paused count is reported next to it. On a plan + without webhook delivery, ``included`` is False, every cap is zero and + ``notice`` says what to do. + + Example: + >>> limits = client.webhooks.limits() + >>> print(limits.deliveries_per_day.remaining, limits.paused_subscriptions.count) + """ + data = self._http.get(f"{self._base_path}/limits") + return WebhookLimits.model_validate(data["data"]) + + async def alimits(self) -> WebhookLimits: + """Async version of :meth:`limits`.""" + data = await self._http.aget(f"{self._base_path}/limits") + return WebhookLimits.model_validate(data["data"]) + + # ========================================================================= + # Endpoints + # ========================================================================= + + def list_endpoints(self) -> list[WebhookEndpoint]: + """List your delivery endpoints, oldest first. Secrets are never included.""" + data = self._http.get(f"{self._base_path}/endpoints") + return [WebhookEndpoint.model_validate(x) for x in data["data"]] + + async def alist_endpoints(self) -> list[WebhookEndpoint]: + """Async version of :meth:`list_endpoints`.""" + data = await self._http.aget(f"{self._base_path}/endpoints") + return [WebhookEndpoint.model_validate(x) for x in data["data"]] + + def create_endpoint(self, url: str, description: str = "") -> WebhookEndpointCreated: + """ + Register an HTTPS destination and get its signing secret. + + The returned ``secret`` is shown once. Store it before anything else: + no later call returns it, and without it a delivery cannot be + verified. If it is lost, :meth:`rotate_secret` issues a new one. + Destinations that resolve to a private or internal address are + refused, at creation and again on every delivery. + + Args: + url: HTTPS destination for deliveries. + description: Your own label for the endpoint. + + Returns: + The endpoint, with ``secret`` populated. + + Raises: + OxArchiveError: The plan has no webhook delivery or is at its + endpoint cap, or the URL was refused. + """ + data = self._http.post( + f"{self._base_path}/endpoints", + json={"url": url, "description": description}, + ) + return WebhookEndpointCreated.model_validate(_with_note(data)) + + async def acreate_endpoint(self, url: str, description: str = "") -> WebhookEndpointCreated: + """Async version of :meth:`create_endpoint`.""" + data = await self._http.apost( + f"{self._base_path}/endpoints", + json={"url": url, "description": description}, + ) + return WebhookEndpointCreated.model_validate(_with_note(data)) + + def delete_endpoint(self, endpoint_id: str) -> None: + """ + Delete an endpoint and every subscription pointing at it. + + Raises: + OxArchiveError: No such endpoint on this account. + """ + self._http.delete(f"{self._base_path}/endpoints/{_segment(endpoint_id)}") + + async def adelete_endpoint(self, endpoint_id: str) -> None: + """Async version of :meth:`delete_endpoint`.""" + await self._http.adelete(f"{self._base_path}/endpoints/{_segment(endpoint_id)}") + + def enable_endpoint(self, endpoint_id: str) -> None: + """ + Put an endpoint back into service. + + Use it after you switched the endpoint off, or after a long run of + failed deliveries switched it off for you (``status == + "auto_disabled"``). Fix the receiver first. Deliveries resume on the + next matching event; nothing that happened while it was off is + replayed. + + Raises: + OxArchiveError: No such endpoint on this account. + """ + self._http.post(f"{self._base_path}/endpoints/{_segment(endpoint_id)}/enable") + + async def aenable_endpoint(self, endpoint_id: str) -> None: + """Async version of :meth:`enable_endpoint`.""" + await self._http.apost(f"{self._base_path}/endpoints/{_segment(endpoint_id)}/enable") + + def rotate_secret(self, endpoint_id: str) -> WebhookEndpointSecret: + """ + Issue a new signing secret; the previous one keeps verifying for 24 hours. + + During the overlap every delivery carries two ``v1=`` signatures, one + per secret, so a receiver holding either keeps verifying. Roll over + by rotating, accepting both secrets, deploying, then dropping the old + one before the 24 hours are up. Only one previous secret is kept: + rotating twice inside the window retires the original at once. + + Returns: + The new secret, shown once. + + Example: + >>> rotated = client.webhooks.rotate_secret(endpoint_id) + >>> verifier.add_secret(rotated.secret) # accept both, then deploy + """ + data = self._http.post(f"{self._base_path}/endpoints/{_segment(endpoint_id)}/rotate") + return WebhookEndpointSecret.model_validate(_with_note(data)) + + async def arotate_secret(self, endpoint_id: str) -> WebhookEndpointSecret: + """Async version of :meth:`rotate_secret`.""" + data = await self._http.apost( + f"{self._base_path}/endpoints/{_segment(endpoint_id)}/rotate" + ) + return WebhookEndpointSecret.model_validate(_with_note(data)) + + def test_endpoint(self, endpoint_id: str) -> WebhookDeliveryQueued: + """ + Queue a ``webhook.test`` delivery to an endpoint. + + It is a real signed delivery through the same path as every other, so + it checks a receiver and its signature verification end to end. It + counts against today's delivery budget, and it is refused on a plan + without webhook delivery or once the budget is spent. A refusal here + never pauses a subscription. + + Returns: + The queued delivery and event identifiers. + + Example: + >>> queued = client.webhooks.test_endpoint(endpoint_id) + >>> log = client.webhooks.list_deliveries(endpoint_id, limit=1) + >>> print(log[0].state, log[0].last_status_code) + """ + data = self._http.post(f"{self._base_path}/endpoints/{_segment(endpoint_id)}/test") + return WebhookDeliveryQueued.model_validate(data["data"]) + + async def atest_endpoint(self, endpoint_id: str) -> WebhookDeliveryQueued: + """Async version of :meth:`test_endpoint`.""" + data = await self._http.apost( + f"{self._base_path}/endpoints/{_segment(endpoint_id)}/test" + ) + return WebhookDeliveryQueued.model_validate(data["data"]) + + # ========================================================================= + # Deliveries + # ========================================================================= + + def list_deliveries( + self, + endpoint_id: str, + *, + limit: Optional[int] = None, + ) -> list[WebhookDelivery]: + """ + Read an endpoint's delivery log, newest first. + + Each record carries the event payload as sent, the attempt count, the + status the receiver returned and the timing of the last attempt. + + Args: + endpoint_id: Endpoint identifier. + limit: Deliveries to return, 1 to 200 (server default 50). Values + outside the range are clamped by the server. + """ + data = self._http.get( + f"{self._base_path}/endpoints/{_segment(endpoint_id)}/deliveries", + params={"limit": limit}, + ) + return [WebhookDelivery.model_validate(x) for x in data["data"]] + + async def alist_deliveries( + self, + endpoint_id: str, + *, + limit: Optional[int] = None, + ) -> list[WebhookDelivery]: + """Async version of :meth:`list_deliveries`.""" + data = await self._http.aget( + f"{self._base_path}/endpoints/{_segment(endpoint_id)}/deliveries", + params={"limit": limit}, + ) + return [WebhookDelivery.model_validate(x) for x in data["data"]] + + def redeliver(self, delivery_id: str) -> WebhookRedelivery: + """ + Send a past delivery again. + + The delivery and event identifiers are unchanged, so a receiver that + already processed the event can deduplicate it. The record is reset in + place: the retry window and attempt counter start again. A repeat is + a real signed delivery and counts against today's budget. + + Raises: + OxArchiveError: No such delivery (404); the endpoint is switched + off, so nothing would be sent, or today's budget is spent + (409). Re-enable the endpoint with :meth:`enable_endpoint` + first. + """ + data = self._http.post(f"{self._base_path}/deliveries/{_segment(delivery_id)}/redeliver") + return WebhookRedelivery.model_validate(_with_note(data)) + + async def aredeliver(self, delivery_id: str) -> WebhookRedelivery: + """Async version of :meth:`redeliver`.""" + data = await self._http.apost( + f"{self._base_path}/deliveries/{_segment(delivery_id)}/redeliver" + ) + return WebhookRedelivery.model_validate(_with_note(data)) + + # ========================================================================= + # Subscriptions + # ========================================================================= + + def list_subscriptions(self) -> list[WebhookSubscription]: + """ + List your subscriptions across every endpoint. + + Each carries its stored configuration and its pause state. A paused + rule has ``status == "auto_paused"`` and a ``pause_message`` saying + why and what clears it. + """ + data = self._http.get(f"{self._base_path}/subscriptions") + return [WebhookSubscription.model_validate(x) for x in data["data"]] + + async def alist_subscriptions(self) -> list[WebhookSubscription]: + """Async version of :meth:`list_subscriptions`.""" + data = await self._http.aget(f"{self._base_path}/subscriptions") + return [WebhookSubscription.model_validate(x) for x in data["data"]] + + @staticmethod + def _create_body( + endpoint_id: str, event_type: str, config: Optional[WebhookConfig] + ) -> dict[str, Any]: + body: dict[str, Any] = {"endpoint_id": endpoint_id, "event_type": event_type} + if config is not None: + body["filters"] = _config_body(config) + return body + + def create_subscription( + self, + endpoint_id: str, + event_type: str, + config: Optional[WebhookConfig] = None, + ) -> WebhookSubscription: + """ + Subscribe an endpoint to an event type. + + The configuration is checked against the event type's declaration + (see :meth:`event_types`) before anything is stored, so an unknown + key, an undeclared parameter, an out of range value or a condition on + a metric the event does not carry is refused rather than dropped. + Addresses in the configuration must already be on your watched list. + Without a configuration the rule matches every occurrence of the type. + Run :meth:`estimate` or :meth:`dry_run` with the same configuration + first to see how much it would deliver. + + Args: + endpoint_id: Endpoint that receives the deliveries. + event_type: Event type from the catalog, for example ``"account.fill"``. + config: Filters, parameters and conditions, as a dict or a + :class:`~oxarchive.WebhookSubscriptionConfig`. Sent as + ``filters``. + + Returns: + The created rule with its normalised configuration. + + Raises: + OxArchiveError: The plan has no webhook delivery or is at its + subscription cap, the endpoint is not yours, the event type is + unknown or not live, or the configuration was refused. + + Example: + >>> sub = client.webhooks.create_subscription( + ... endpoint_id, + ... "account.fill", + ... { + ... "addresses": ["0x6b9e773128f453f5c2c60935ee2de2cbc5390a24"], + ... "conditions": [ + ... {"metric": "notional_usd", "op": ">=", "value": 25000} + ... ], + ... }, + ... ) + """ + data = self._http.post( + f"{self._base_path}/subscriptions", + json=self._create_body(endpoint_id, event_type, config), + ) + return WebhookSubscription.model_validate(data["data"]) + + async def acreate_subscription( + self, + endpoint_id: str, + event_type: str, + config: Optional[WebhookConfig] = None, + ) -> WebhookSubscription: + """Async version of :meth:`create_subscription`.""" + data = await self._http.apost( + f"{self._base_path}/subscriptions", + json=self._create_body(endpoint_id, event_type, config), + ) + return WebhookSubscription.model_validate(data["data"]) + + @staticmethod + def _update_body(config: Optional[WebhookConfig], enabled: Optional[bool]) -> dict[str, Any]: + if config is None and enabled is None: + raise ValueError("update_subscription needs config, enabled, or both") + body: dict[str, Any] = {} + if config is not None: + body["filters"] = _config_body(config) + if enabled is not None: + body["enabled"] = enabled + return body + + def update_subscription( + self, + subscription_id: str, + *, + config: Optional[WebhookConfig] = None, + enabled: Optional[bool] = None, + ) -> WebhookSubscription: + """ + Edit a rule in place. + + A ``config`` replaces the stored configuration rather than merging into + it, so send the whole object; it is validated as at create. ``enabled`` + switches the rule on or off without touching its configuration. A + field left as ``None`` is not sent and is left alone. + + Args: + subscription_id: Subscription identifier. + config: The complete replacement configuration. Sent as ``filters``. + enabled: Your own on and off switch for the rule. + + Raises: + ValueError: Neither ``config`` nor ``enabled`` was given. + OxArchiveError: No such subscription, or the configuration was refused. + + Example: + >>> client.webhooks.update_subscription(sub.id, enabled=False) + """ + data = self._http.patch( + f"{self._base_path}/subscriptions/{_segment(subscription_id)}", + json=self._update_body(config, enabled), + ) + return WebhookSubscription.model_validate(data["data"]) + + async def aupdate_subscription( + self, + subscription_id: str, + *, + config: Optional[WebhookConfig] = None, + enabled: Optional[bool] = None, + ) -> WebhookSubscription: + """Async version of :meth:`update_subscription`.""" + data = await self._http.apatch( + f"{self._base_path}/subscriptions/{_segment(subscription_id)}", + json=self._update_body(config, enabled), + ) + return WebhookSubscription.model_validate(data["data"]) + + def delete_subscription(self, subscription_id: str) -> None: + """ + Delete a rule. Its endpoint and any other rule pointing at it are untouched. + + Raises: + OxArchiveError: No such subscription on this account. + """ + self._http.delete(f"{self._base_path}/subscriptions/{_segment(subscription_id)}") + + async def adelete_subscription(self, subscription_id: str) -> None: + """Async version of :meth:`delete_subscription`.""" + await self._http.adelete(f"{self._base_path}/subscriptions/{_segment(subscription_id)}") + + def resume_subscription(self, subscription_id: str) -> WebhookSubscriptionResume: + """ + Put one paused rule back into service. + + Nothing is buffered while a rule is paused, so the result carries the + window that was missed (``gap``) instead of replaying it: when it + began and ended, how much of it was counted, and what can be re-read + from the REST routes. A rule that is already serving is left as it + is, with ``gap`` None and a ``note``. Your own ``enabled`` switch is + never changed. + + Raises: + OxArchiveError: No such subscription (404), or resuming would be + undone at once because the plan has no webhook delivery or + today's budget is spent (400 or 409). + + Example: + >>> result = client.webhooks.resume_subscription(sub.id) + >>> if result.gap: + ... print(result.gap.replay_window.start, result.gap.replay_window.end) + """ + data = self._http.post( + f"{self._base_path}/subscriptions/{_segment(subscription_id)}/resume" + ) + return _resume(data) + + async def aresume_subscription(self, subscription_id: str) -> WebhookSubscriptionResume: + """Async version of :meth:`resume_subscription`.""" + data = await self._http.apost( + f"{self._base_path}/subscriptions/{_segment(subscription_id)}/resume" + ) + return _resume(data) + + def resume_all_subscriptions(self) -> WebhookSubscriptionResumeAll: + """ + Put every paused rule on the account back into service in one call. + + The daily delivery limit is counted per account while a pause is + written per rule, so one busy rule can pause them all; this brings the + account back without a call per rule. The result lists the resumed + rules and the window they missed. If nothing is paused, nothing + changes and ``note`` says so. + + Raises: + OxArchiveError: Resuming would be undone at once because the plan + has no webhook delivery or today's budget is spent (400 or 409). + """ + data = self._http.post(f"{self._base_path}/subscriptions/resume") + return _resume_all(data) + + async def aresume_all_subscriptions(self) -> WebhookSubscriptionResumeAll: + """Async version of :meth:`resume_all_subscriptions`.""" + data = await self._http.apost(f"{self._base_path}/subscriptions/resume") + return _resume_all(data) + + # ========================================================================= + # Previews + # ========================================================================= + + @staticmethod + def _dry_run_body( + event_type: str, + config: Optional[WebhookConfig], + lookback_s: Optional[int], + limit: Optional[int], + ) -> dict[str, Any]: + body: dict[str, Any] = {"event_type": event_type} + if config is not None: + body["config"] = _config_body(config) + if lookback_s is not None: + body["lookback_s"] = lookback_s + if limit is not None: + body["limit"] = limit + return body + + def dry_run( + self, + event_type: str, + config: Optional[WebhookConfig] = None, + *, + lookback_s: Optional[int] = None, + limit: Optional[int] = None, + ) -> WebhookDryRun: + """ + Show which recent occurrences a configuration would have delivered. + + The configuration is validated and normalised exactly as at create, + then evaluated against recent history. Nothing is stored and nothing + is delivered. Available on every plan, Free included, and metered like + the market data it returns. Not every event type can be dry-run; a + type that cannot is refused with the list of those that can. An + address scoped type needs at least one watched wallet. Dry-runs and + estimates share a limit of six a minute per account. + + Args: + event_type: Event type from the catalog. + config: The configuration you would subscribe with. Sent as ``config``. + lookback_s: Seconds of history to scan, ending now: 60 to 86400 + (server default 3600). + limit: Occurrences to return, newest first: 1 to 200 (server + default 100). + + Example: + >>> preview = client.webhooks.dry_run( + ... "market.liquidation", + ... {"venue": "hyperliquid", "min_notional_usd": 500000}, + ... lookback_s=86400, + ... ) + >>> print(preview.matched, preview.window.from_, preview.window.to) + """ + data = self._http.post( + f"{self._base_path}/subscriptions/dry-run", + json=self._dry_run_body(event_type, config, lookback_s, limit), + ) + return WebhookDryRun.model_validate(data["data"]) + + async def adry_run( + self, + event_type: str, + config: Optional[WebhookConfig] = None, + *, + lookback_s: Optional[int] = None, + limit: Optional[int] = None, + ) -> WebhookDryRun: + """Async version of :meth:`dry_run`.""" + data = await self._http.apost( + f"{self._base_path}/subscriptions/dry-run", + json=self._dry_run_body(event_type, config, lookback_s, limit), + ) + return WebhookDryRun.model_validate(data["data"]) + + @staticmethod + def _estimate_body( + event_type: str, + config: Optional[WebhookConfig], + lookback_days: Optional[int], + ) -> dict[str, Any]: + body: dict[str, Any] = {"event_type": event_type} + if config is not None: + body["config"] = _config_body(config) + if lookback_days is not None: + body["lookback_days"] = lookback_days + return body + + def estimate( + self, + event_type: str, + config: Optional[WebhookConfig] = None, + *, + lookback_days: Optional[int] = None, + ) -> WebhookEstimate: + """ + Show how often a configuration would have fired, per day, over a past window. + + Returns the total, a count per day, the median and busiest day, the + distribution of the event's primary metric, a ladder of the daily + rate at other thresholds, and a sample of real matches, so a + threshold can be chosen against history. Compare ``per_day_p50`` and + ``per_day_max`` with the plan's daily delivery budget. Available on + every plan, Free included, and metered like the market data it + returns. A type that cannot be estimated is refused with the list of + those that can; an address scoped type needs at least one watched + wallet. Dry-runs and estimates share a limit of six a minute. + + Args: + event_type: Event type from the catalog. + config: The configuration you would subscribe with. Sent as ``config``. + lookback_days: Days of history to evaluate, ending now: 1 to 30 + (server default 7). Some types cap their own window lower; + ``days`` on the result says what was covered. + + Example: + >>> est = client.webhooks.estimate("market.liquidation_burst") + >>> for rung in est.ladder: + ... print(rung.value, rung.per_day) + """ + data = self._http.post( + f"{self._base_path}/subscriptions/estimate", + json=self._estimate_body(event_type, config, lookback_days), + ) + return WebhookEstimate.model_validate(data["data"]) + + async def aestimate( + self, + event_type: str, + config: Optional[WebhookConfig] = None, + *, + lookback_days: Optional[int] = None, + ) -> WebhookEstimate: + """Async version of :meth:`estimate`.""" + data = await self._http.apost( + f"{self._base_path}/subscriptions/estimate", + json=self._estimate_body(event_type, config, lookback_days), + ) + return WebhookEstimate.model_validate(data["data"]) + + # ========================================================================= + # Watched addresses + # ========================================================================= + + def list_addresses(self) -> list[WebhookWatchedAddress]: + """List the wallets address scoped event types report on.""" + data = self._http.get(f"{self._base_path}/addresses") + return [WebhookWatchedAddress.model_validate(x) for x in data["data"]] + + async def alist_addresses(self) -> list[WebhookWatchedAddress]: + """Async version of :meth:`list_addresses`.""" + data = await self._http.aget(f"{self._base_path}/addresses") + return [WebhookWatchedAddress.model_validate(x) for x in data["data"]] + + def add_address(self, address: str, label: str = "") -> WebhookWatchedAddress: + """ + Watch a wallet so address scoped event types can report on it. + + Add the wallet before a subscription that names it. Adding a wallet + that is already watched changes nothing and does not count against + the cap again. The address is stored lowercase. Hyperliquid bridge + system addresses are refused: such an address is a counterparty to + every bridge movement of its token rather than an account. + + Args: + address: A 0x prefixed, 40 hex character wallet address. + label: Your own label, at most 64 characters. + + Raises: + OxArchiveError: The plan has no webhook delivery or is at its + watched wallet cap, or the address was refused. + """ + data = self._http.post( + f"{self._base_path}/addresses", + json={"address": address, "label": label}, + ) + return WebhookWatchedAddress.model_validate(data["data"]) + + async def aadd_address(self, address: str, label: str = "") -> WebhookWatchedAddress: + """Async version of :meth:`add_address`.""" + data = await self._http.apost( + f"{self._base_path}/addresses", + json={"address": address, "label": label}, + ) + return WebhookWatchedAddress.model_validate(data["data"]) + + def delete_address(self, address_id: str) -> None: + """ + Stop watching a wallet. + + Subscriptions that named it keep their stored configuration, so remove + the address from those rules too if they should no longer reference it. + + Args: + address_id: Watched address identifier, from :meth:`list_addresses`. + """ + self._http.delete(f"{self._base_path}/addresses/{_segment(address_id)}") + + async def adelete_address(self, address_id: str) -> None: + """Async version of :meth:`delete_address`.""" + await self._http.adelete(f"{self._base_path}/addresses/{_segment(address_id)}") diff --git a/oxarchive/types.py b/oxarchive/types.py index 3a7ba39..8919285 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -511,33 +511,56 @@ class SpotTwapStatus(BaseModel): class SpotTableFreshness(BaseModel): - """Per-table freshness lag for a spot pair. + """Freshness of each dataset for a spot pair, from ``client.spot.get_freshness()``. - Returned by ``/v1/hyperliquid/spot/freshness/{symbol}`` for each backing - table (``spot_orderbook_snapshots``, ``spot_fills``, ``spot_orderbook_l4_diffs``, - ``spot_orders``, ``spot_twap``). Mirrors the per-data-type shape used by - :class:`CoinFreshness` but keyed by table name rather than fixed channels. - - The ``tables`` field accepts either fully-typed :class:`DataTypeFreshness` - objects or raw dicts so the model survives backend shape evolution. Each - entry typically has ``last_updated`` and ``lag_ms`` keys, both optional. + One entry per dataset spot serves: ``orderbook``, ``trades``, + ``l4_diffs``, ``l4_checkpoints``, ``orders`` and ``twap``. Each carries + ``last_updated`` and ``lag_ms``, both optional. Spot has no funding, open + interest or liquidations. Unknown fields are kept. """ + symbol: Optional[str] = None + """Pair symbol in dashed canonical form (for example ``HYPE-USDC``).""" + coin: Optional[str] = None """Pair symbol in dashed canonical form.""" exchange: Optional[str] = None - """Exchange name (``hyperliquid_spot``).""" + """Venue family (``spot``).""" measured_at: Optional[datetime] = None """When this freshness was measured.""" - tables: dict[str, Any] = Field(default_factory=dict) - """Per-dataset freshness lag, keyed by dataset name. Values are - typically ``{last_updated, lag_ms}`` dicts but the schema is permissive.""" + orderbook: Optional[DataTypeFreshness] = None + """L2 order book snapshots.""" + + trades: Optional[DataTypeFreshness] = None + """Trades.""" + + l4_diffs: Optional[DataTypeFreshness] = None + """L4 order book diffs.""" + + l4_checkpoints: Optional[DataTypeFreshness] = None + """L4 order book checkpoints.""" + + orders: Optional[DataTypeFreshness] = None + """L4 order lifecycle events.""" + + twap: Optional[DataTypeFreshness] = None + """TWAP statuses.""" model_config = {"extra": "allow"} + @property + def tables(self) -> dict[str, DataTypeFreshness]: + """The datasets present in the response, keyed by name. + + Before 1.12.0 this was a field that the API never filled, so it was + always empty; it now lists the datasets above. + """ + names = ("orderbook", "trades", "l4_diffs", "l4_checkpoints", "orders", "twap") + return {name: value for name in names if (value := getattr(self, name)) is not None} + class LighterInstrument(BaseModel): """Trading instrument specification (Lighter.xyz). @@ -971,8 +994,8 @@ class CoinFreshness(BaseModel): trades: DataTypeFreshness """Trades data freshness.""" - funding: DataTypeFreshness - """Funding rate data freshness.""" + funding: Optional[DataTypeFreshness] = None + """Funding rate data freshness. ``None`` on HIP-4, which has no funding.""" open_interest: DataTypeFreshness """Open interest data freshness.""" @@ -1096,6 +1119,7 @@ class Candle(BaseModel): "hip4_l4_diffs", "hip4_l4_orders", "l4_diffs", "l4_orders", "hip3_l4_diffs", "hip3_l4_orders", + "orderbook_full", "hip3_orderbook_full", "spot_orderbook", "spot_trades", "spot_l4_diffs", "spot_l4_orders", "spot_twap", ] """Available WebSocket channels. @@ -1121,6 +1145,12 @@ class Candle(BaseModel): - l4_diffs, l4_orders: Hyperliquid core L4 order-level data. Historical replay emits one ``l4_snapshot`` followed by ordered ``l4_batch`` messages. - hip3_l4_diffs, hip3_l4_orders: HIP-3 L4 order-level data (live-only). +- orderbook_full, hip3_orderbook_full: full-depth L2 books (every price level) + for Hyperliquid core and HIP-3, aggregated from the order-level book. Live + subscriptions open with an ``l4_snapshot`` of the whole book, followed by + ``l4_batch`` messages of changed levels. Live-only: replay is rejected with + ``ValueError``; stored full-depth history is served by REST + ``l2_orderbook.history()`` and ``l2_orderbook.diffs()``. - hip4_trades: HIP-4 outcome-market fills (realtime + replay). - hip4_orderbook, hip4_open_interest: stored replay only; live bridges paused. - hip4_l4_diffs, hip4_l4_orders: HIP-4 L4 order-level data (live-only). @@ -2770,3 +2800,1065 @@ class SlaResponse(BaseModel): total_downtime_minutes: int """Total downtime in minutes.""" + + +# ============================================================================= +# Cumulative Volume Delta Types +# ============================================================================= + +CvdInterval = Literal["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"] +"""Cumulative volume delta bucket widths. ``1h`` and longer roll up hourly +totals; ``1m`` to ``30m`` are summed from taker fills.""" + + +class CvdBucket(BaseModel): + """One cumulative volume delta bucket: taker buy and sell notional.""" + + timestamp: int + """Bucket open time in Unix milliseconds (UTC).""" + + buy_volume: float + """Taker buy notional in the bucket.""" + + sell_volume: float + """Taker sell notional in the bucket.""" + + delta: float + """``buy_volume`` minus ``sell_volume``.""" + + cumulative_delta: float + """Running total of ``delta`` from the first bucket of this response. It + restarts on every page, so rebuild it from ``delta`` when joining pages.""" + + +# ============================================================================= +# HIP-3 Oracle Types +# ============================================================================= + + +class Hip3OracleDiscoveryBounds(BaseModel): + """Instantaneous HIP-3 discovery bounds from the reference price and max leverage. + + The full ratcheted range can be wider when a deployer's reset + configuration applies. + """ + + symbol: str + """HIP-3 symbol (for example ``km:US500``).""" + + reference_price: float + """External price when available, otherwise the mark price.""" + + reference_source: str + """Source of ``reference_price``: ``"external"`` or ``"mark"``.""" + + max_leverage: int + """Market max leverage used for the bound fraction.""" + + bound_fraction: float + """Fraction applied on each side of ``reference_price``.""" + + lower_bound: float + """Instantaneous lower discovery bound.""" + + upper_bound: float + """Instantaneous upper discovery bound.""" + + block_number: int + """Source block number.""" + + timestamp: int + """Source timestamp in Unix milliseconds.""" + + +class Hip3OracleExternalPrice(BaseModel): + """Latest deployer-pushed external price and mark price for a HIP-3 market.""" + + symbol: str + """HIP-3 symbol (for example ``km:US500``).""" + + external_price: Optional[float] = None + """Externally derived reference price, when available.""" + + mark_price: Optional[float] = None + """On-chain mark input.""" + + block_number: int + """Source block number.""" + + timestamp: int + """Source timestamp in Unix milliseconds.""" + + +# ============================================================================= +# HIP-4 Question Types +# ============================================================================= + + +class Hip4Question(BaseModel): + """A HIP-4 question: a multi-choice resolver grouping binary outcome markets. + + One named outcome per choice, plus a fallback outcome that resolves Yes + when no named choice does. + """ + + model_config = ConfigDict(extra="allow") + + question_id: int + """Question identifier.""" + + name: str + """Question name as published on-chain (recurring markets use a generic + name such as ``"Recurring"``).""" + + description: str + """Pipe-delimited question metadata, for example + ``"class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"``.""" + + fallback_outcome_id: int + """Outcome that resolves Yes when no named outcome does.""" + + named_outcome_ids: list[int] = Field(default_factory=list) + """Outcomes of the named choices grouped under this question.""" + + settled_named_outcomes: list[int] = Field(default_factory=list) + """The named outcomes that have already settled.""" + + first_seen_at: datetime + """When 0xArchive first observed the question (UTC).""" + + last_updated_at: datetime + """When the question record was last updated (UTC).""" + + +# ============================================================================= +# Wallet Classification Types +# ============================================================================= + +WalletClassifySort = Literal[ + "total_orders", + "total_fills", + "total_volume", + "total_volume_usd", + "cancel_rate", + "fill_rate", + "maker_ratio", + "avg_order_size_usd", + "avg_order_notional", + "max_order_size_usd", + "max_order_notional", + "active_hours", + "unique_coins", + "total_fees", + "total_fees_usd", + "realized_pnl", + "realized_pnl_usd", + "median_cancel_speed_ms", + "twap_fills", + "total_priority_gas", + "total_priority_gas_paid", + "total_builder_fees", + "total_builder_fees_paid", +] +"""Metrics wallet classification can sort by.""" + + +class WalletClassifyMetrics(BaseModel): + """Precomputed daily behavior metrics for one wallet. Every field is optional.""" + + model_config = ConfigDict(extra="allow") + + total_orders: Optional[int] = None + cancel_rate: Optional[float] = None + fill_rate: Optional[float] = None + order_to_trade_ratio: Optional[float] = None + ioc_ratio: Optional[float] = None + post_only_ratio: Optional[float] = None + tpsl_ratio: Optional[float] = None + trigger_order_ratio: Optional[float] = None + unique_coins_traded: Optional[int] = None + uses_tpsl: Optional[bool] = None + uses_builder: Optional[bool] = None + top_builder: Optional[str] = None + avg_order_size_usd: Optional[float] = None + max_order_size_usd: Optional[float] = None + median_cancel_speed_ms: Optional[float] = None + active_hours: Optional[int] = None + total_fills: Optional[int] = None + total_volume_usd: Optional[float] = None + maker_ratio: Optional[float] = None + long_short_ratio: Optional[float] = None + buy_volume_usd: Optional[float] = None + sell_volume_usd: Optional[float] = None + total_fees_usd: Optional[float] = None + realized_pnl_usd: Optional[float] = None + liquidation_count: Optional[int] = None + max_single_fill_usd: Optional[float] = None + unique_fill_coins: Optional[int] = None + uses_twap: Optional[bool] = None + twap_fill_ratio: Optional[float] = None + uses_cloid: Optional[bool] = None + cloid_ratio: Optional[float] = None + uses_priority_gas: Optional[bool] = None + total_priority_gas_paid: Optional[float] = None + total_builder_fees_paid: Optional[float] = None + + +class ClassifiedWallet(BaseModel): + """A wallet and its precomputed behavior metrics.""" + + address: str + """Wallet address.""" + + metrics: WalletClassifyMetrics + """Behavior metrics over ``period``.""" + + period: str + """Metric lookback period (for example ``"24h"``).""" + + +class WalletClassification(BaseModel): + """One page of wallet classification results.""" + + wallets: list[ClassifiedWallet] = Field(default_factory=list) + """Wallets on this page, in the requested sort order.""" + + total: int + """Wallets matching the filters, across every page.""" + + date: str + """Daily snapshot date the metrics describe (``YYYY-MM-DD``).""" + + +# ============================================================================= +# Symbol Universe Types +# ============================================================================= + + +class SymbolEntry(BaseModel): + """One market in the public symbol universe, from ``client.symbols.list()``.""" + + model_config = ConfigDict(extra="allow") + + symbol: str + """Symbol as the venue's routes take it (for example ``BTC``, ``km:US500``, + ``HYPE-USDC``, ``#0``).""" + + exchange: str + """Venue family: ``"hyperliquid"``, ``"hip3"``, ``"hip4"``, ``"spot"``, + ``"lighter"`` or ``"rh-lighter"``.""" + + coverage_from: Optional[datetime] = None + """Earliest data for the symbol (UTC).""" + + coverage_to: Optional[datetime] = None + """Latest data for the symbol (UTC), when it has ended.""" + + data_types: list[str] = Field(default_factory=list) + """Data types served for the symbol (for example ``"trades"``, ``"l2_orderbook"``).""" + + coverage_by_type: dict[str, datetime] = Field(default_factory=dict) + """Earliest data per data type (UTC).""" + + size_per_day: dict[str, float] = Field(default_factory=dict) + """Approximate size per day by data type.""" + + slug: Optional[str] = None + """HIP-4 slug, when available.""" + + outcome_pair: Optional[list[str]] = None + """HIP-4: the two side symbols of the outcome.""" + + display_title: Optional[str] = None + """HIP-4: human-readable title.""" + + is_settled: Optional[bool] = None + """HIP-4: whether the outcome has settled.""" + + is_active: Optional[bool] = None + """Whether the market is active, when the venue reports it.""" + + +# ============================================================================= +# Webhook Types +# ============================================================================= +# +# Webhook delivery is a paid feature. Free has no endpoints, subscriptions, +# watched wallets or deliveries, but every plan keeps the two previews +# (estimate and dry-run), so a rule can be designed and sized before +# upgrading. ``client.webhooks.limits()`` reports what the plan allows. +# +# Every model here keeps unknown fields. The webhook surface gains fields +# faster than the SDK ships, and an extra key must never raise in a receiver. + + +class WebhookEventTypeParam(BaseModel): + """A tunable parameter an event type declares. + + Values sent under ``params`` are checked against this declaration, and a + declared parameter left unset is stored at its default. + """ + + model_config = ConfigDict(extra="allow") + + type: Optional[str] = None + """Value type: ``"integer"``, ``"number"``, ``"string"`` or ``"array_of_number"``.""" + + unit: Optional[str] = None + """Unit the value is expressed in, when it has one (for example ``"s"``).""" + + default: Any = None + """Value used when the parameter is not supplied.""" + + min: Optional[float] = None + """Lowest accepted value, when bounded below.""" + + max: Optional[float] = None + """Highest accepted value, when bounded above.""" + + enum: Optional[list[Any]] = None + """Accepted values, when the parameter is a fixed choice.""" + + description: Optional[str] = None + """What the parameter changes.""" + + +class WebhookEventTypeMetric(BaseModel): + """A metric an event carries, and so a metric a condition may be written against.""" + + model_config = ConfigDict(extra="allow") + + type: Optional[str] = None + """Value type, which decides the operators a condition may use: for example + ``"number"``, ``"integer"``, ``"string"``, ``"boolean"`` or ``"timestamp"``.""" + + unit: Optional[str] = None + """Unit the metric is expressed in, when it has one (for example ``"USD"``).""" + + values: Optional[list[str]] = None + """Accepted values, when the metric is a fixed choice.""" + + description: Optional[str] = None + """What the metric measures, including when it is null.""" + + +class WebhookCostFloor(BaseModel): + """The smallest occurrence an event type reports at all.""" + + model_config = ConfigDict(extra="allow") + + metric: Optional[str] = None + """Metric the floor applies to (for example ``"notional_usd"``).""" + + min: Optional[float] = None + """Lowest value still reported.""" + + +class WebhookEventType(BaseModel): + """One entry in the event catalog, from ``client.webhooks.event_types()``. + + Subscriptions are validated against this declaration, so it is the + authority on the filters, parameters, metrics and operators a + configuration may use. Read them from here rather than hardcoding them. + """ + + model_config = ConfigDict(extra="allow") + + type: str + """Event type identifier, for example ``"market.liquidation"``. Send it as + ``event_type`` when creating a subscription.""" + + schema_version: int + """Version of the delivered payload shape for this event type.""" + + live: bool + """True when the type accepts subscriptions. A type that is published but + not yet live is refused at create time.""" + + scope: str + """Who the event is about: ``"public"`` (market wide), ``"user"`` (your own + account and platform activity) or ``"addresses"`` (only wallets on your + watched list).""" + + venues: list[str] = Field(default_factory=list) + """Venues the type covers. Empty when the type is not venue scoped.""" + + filters: list[str] = Field(default_factory=list) + """Filter keys a configuration accepts. A key not listed is refused.""" + + params: dict[str, WebhookEventTypeParam] = Field(default_factory=dict) + """Tunable parameters, keyed by parameter name.""" + + metrics: dict[str, WebhookEventTypeMetric] = Field(default_factory=dict) + """Metrics the event carries, keyed by name. Conditions may only use these.""" + + cost_floor: Optional[WebhookCostFloor] = None + """The smallest occurrence the type reports. ``None`` when it has no floor.""" + + latency_class: str + """Rough delivery latency from the occurrence to the first attempt: + ``"seconds"`` or ``"minutes"``.""" + + description: str + """What the event reports and what one occurrence means.""" + + filters_example: Optional[dict[str, Any]] = None + """A worked example of a configuration for this type.""" + + operators: dict[str, list[str]] = Field(default_factory=dict) + """Operator vocabulary grouped by metric type, plus the ``any`` group that + applies to every metric.""" + + +class WebhookEndpoint(BaseModel): + """A delivery destination. + + The signing secret is not part of this shape: it is returned once when the + endpoint is created (:class:`WebhookEndpointCreated`) and again when it is + rotated (:class:`WebhookEndpointSecret`), never on a list. + """ + + model_config = ConfigDict(extra="allow") + + id: str + """Endpoint identifier (UUID).""" + + url: str + """HTTPS destination that receives deliveries.""" + + description: str = "" + """Your own label for the endpoint.""" + + status: str + """``"active"`` is serving, ``"disabled"`` means you switched it off, and + ``"auto_disabled"`` means a long run of failed deliveries switched it off + for you. Bring it back with ``enable_endpoint()``.""" + + consecutive_failures: int = 0 + """Failed attempts since the last success. Resets to zero on a delivery that lands.""" + + created_at: datetime + """When the endpoint was created (UTC).""" + + +class WebhookEndpointCreated(WebhookEndpoint): + """A newly created endpoint, with its signing secret. + + Creation and rotation are the only responses that carry the secret. Store + it now: no later call returns it. + """ + + secret: str + """Signing secret (``whsec_`` followed by 64 hex characters). Pass the whole + string to :class:`~oxarchive.WebhookVerifier`; the prefix is part of the key.""" + + note: Optional[str] = None + """The API's advisory that the secret is shown once.""" + + +class WebhookEndpointSecret(BaseModel): + """A freshly rotated signing secret.""" + + model_config = ConfigDict(extra="allow") + + secret: str + """The new signing secret. The previous one keeps verifying for 24 hours.""" + + note: Optional[str] = None + """The API's advisory on how long the previous secret keeps verifying.""" + + +class WebhookSubscriptionCondition(BaseModel): + """One condition on an event metric; every condition must hold for a delivery.""" + + model_config = ConfigDict(extra="allow") + + metric: str + """A metric the event type declares (for example ``"notional_usd"``).""" + + op: str + """Comparison, in canonical spelling: ``greater_than``, + ``greater_than_or_equal``, ``less_than``, ``less_than_or_equal``, ``equal``, + ``not_equal``, ``between``, ``not_between``, ``in``, ``not_in``, + ``contains``, ``not_contains``, ``starts_with``, ``ends_with``, ``before``, + ``after``, ``is_empty`` or ``is_not_empty``. Symbol spellings such as + ``">="`` are accepted on the way in and stored canonically.""" + + value: Any = None + """What to compare against: a number, string, boolean or RFC 3339 + timestamp; a ``[low, high]`` pair for ``between`` and ``not_between``; a + non-empty list for ``in`` and ``not_in``. Left out for ``is_empty`` and + ``is_not_empty``.""" + + +class WebhookSubscriptionConfig(BaseModel): + """What a subscription matches on. + + Every key is checked against the event type's catalog declaration, so an + unknown key, an undeclared parameter or a condition on an undeclared + metric is refused rather than ignored. The stored form is normalised: + venues and addresses lowercased, declared parameters filled in at their + defaults, operators in canonical spelling. A declared parameter may also + appear at the top level; such keys are kept as extra fields. + """ + + model_config = ConfigDict(extra="allow") + + venue: Optional[Union[str, list[str]]] = None + """Venue or venues to match, from the event type's ``venues``. A single + string, a pipe separated string or a list. ``None`` matches every covered + venue.""" + + symbols: Optional[list[str]] = None + """Instrument symbols to match. ``None`` matches every symbol.""" + + addresses: Optional[list[str]] = None + """Wallets to match, for an address scoped type. Each must already be on + your watched list. ``None`` matches all of them.""" + + params: Optional[dict[str, Any]] = None + """Parameter values, keyed by the names the event type declares.""" + + conditions: Optional[list[WebhookSubscriptionCondition]] = None + """Conditions on the event's metrics, all of which must hold. At most 16.""" + + min_notional_usd: Optional[float] = None + """Shorthand for a ``notional_usd`` at-or-above condition, kept for + compatibility. It is stored as a condition and mirrored back here as the + loosest notional lower bound the configuration carries.""" + + +class WebhookSubscription(BaseModel): + """A rule: one event type and one configuration, delivered to one endpoint.""" + + model_config = ConfigDict(extra="allow") + + id: str + """Subscription identifier (UUID).""" + + endpoint_id: str + """Endpoint that receives this rule's deliveries.""" + + event_type: str + """Event type this rule subscribes to.""" + + filters: WebhookSubscriptionConfig = Field(default_factory=WebhookSubscriptionConfig) + """The stored, normalised configuration. The API names it ``filters``; the + SDK methods take it as ``config`` (see :attr:`config`).""" + + enabled: bool + """Your own on and off switch. Resuming a paused rule never changes it.""" + + created_at: datetime + """When the rule was created (UTC).""" + + status: str + """``"active"`` is serving. ``"auto_paused"`` means delivery was paused for + you and nothing is being sent; it stays paused until you resume it.""" + + pause_message: Optional[str] = None + """Why the rule is paused and what clears it, in plain words. Present only + while the rule is paused.""" + + paused_at: Optional[datetime] = None + """Start of the current gap. ``None`` while the rule is serving.""" + + pause_reason: Optional[str] = None + """Machine readable cause of the current pause: ``"deliveries_per_day_cap"`` + (the account reached its daily delivery limit) or ``"plan_no_webhooks"`` + (the plan does not include webhook delivery). ``None`` while serving.""" + + suppressed_count: int = 0 + """Matches observed but not delivered since the current pause began. A + lower bound, not a total.""" + + suppressed_first_at: Optional[datetime] = None + """First suppressed match of the current pause.""" + + suppressed_last_at: Optional[datetime] = None + """Most recent suppressed match of the current pause.""" + + last_paused_at: Optional[datetime] = None + """Start of the last pause that has already ended.""" + + last_resumed_at: Optional[datetime] = None + """When that pause ended.""" + + last_pause_reason: Optional[str] = None + """Cause of the last ended pause, in the same vocabulary as ``pause_reason``.""" + + last_suppressed_count: int = 0 + """Matches suppressed during the last pause that has already ended.""" + + last_suppressed_first_at: Optional[datetime] = None + """First suppressed match of that pause.""" + + last_suppressed_last_at: Optional[datetime] = None + """Most recent suppressed match of that pause.""" + + @property + def config(self) -> WebhookSubscriptionConfig: + """Alias for :attr:`filters`, matching the SDK's request vocabulary.""" + return self.filters + + +class WebhookResumeReplayWindow(BaseModel): + """The missed window of a resume, as a range to re-read for yourself.""" + + model_config = ConfigDict(extra="allow") + + start: Optional[datetime] = None + """Window start (UTC).""" + + end: Optional[datetime] = None + """Window end (UTC).""" + + +class WebhookResumeGap(BaseModel): + """The window a resume just closed. + + Nothing is buffered while a rule is paused, so this describes what was + missed rather than replaying it. Re-read the window through the REST + routes to recover it. + """ + + model_config = ConfigDict(extra="allow") + + paused_at: Optional[datetime] = None + """Start of the gap.""" + + resumed_at: Optional[datetime] = None + """End of the gap.""" + + replay_window: WebhookResumeReplayWindow = Field( + default_factory=WebhookResumeReplayWindow + ) + """The gap as a window to re-read.""" + + reason: Optional[str] = None + """Cause of the pause that was cleared. ``None`` on a bulk resume whose + rules were paused for more than one reason.""" + + reasons: Optional[list[str]] = None + """Distinct causes across the resumed rules. Bulk resume only.""" + + pause_message: Optional[str] = None + """The cause in plain words.""" + + suppressed_count: Optional[int] = None + """Matches suppressed inside the window. ``None`` when the rules were address + scoped, because their occurrences were never looked at.""" + + counted: bool = False + """Whether anything inside the window was counted. False means the count is + ``None`` because nothing was looked at, not because nothing happened.""" + + suppressed_first_at: Optional[datetime] = None + """First suppressed match inside the window.""" + + suppressed_last_at: Optional[datetime] = None + """Most recent suppressed match inside the window.""" + + uncounted_subscriptions: Optional[int] = None + """How many resumed rules were address scoped and so not counted. Bulk resume only.""" + + note: Optional[str] = None + """What can and cannot be recovered for the window, and how.""" + + +class WebhookSubscriptionResume(BaseModel): + """Result of ``resume_subscription()``.""" + + model_config = ConfigDict(extra="allow") + + subscription: WebhookSubscription + """The subscription after the resume.""" + + gap: Optional[WebhookResumeGap] = None + """The window the resume closed. ``None`` when the rule was already + serving, in which case nothing changed.""" + + note: Optional[str] = None + """Present only when nothing changed, to say why.""" + + +class WebhookSubscriptionResumeAll(BaseModel): + """Result of ``resume_all_subscriptions()``.""" + + model_config = ConfigDict(extra="allow") + + subscriptions: list[WebhookSubscription] = Field(default_factory=list) + """The rules that were put back into service.""" + + resumed_count: int = 0 + """How many rules were put back into service.""" + + gap: Optional[WebhookResumeGap] = None + """The window the resume closed, across the rules it cleared. ``None`` when + nothing was paused.""" + + note: Optional[str] = None + """Present only when nothing changed, to say why.""" + + +class WebhookDelivery(BaseModel): + """One record in an endpoint's delivery log. + + An event and an endpoint share a single record for their whole life, so a + repeat delivery rewrites this record rather than adding another. + """ + + model_config = ConfigDict(extra="allow") + + id: str + """Delivery identifier. Pass it to ``redeliver()``.""" + + event_id: str + """Event identifier, sent as the ``0xa-event-id`` header. Stable across + retries and repeat deliveries: deduplicate on it.""" + + event_type: str + """Event type delivered.""" + + state: str + """``"pending"`` (queued or waiting to retry), ``"delivered"``, ``"failed"`` + (the last attempt's result) or ``"exhausted"`` (the retry window closed + without success).""" + + attempts: int = 0 + """Attempts made so far.""" + + last_status_code: Optional[int] = None + """HTTP status your receiver returned on the last attempt.""" + + last_error: Optional[str] = None + """Why the last attempt failed. ``None`` when it succeeded.""" + + last_latency_ms: Optional[int] = None + """How long the last attempt took, in milliseconds.""" + + next_attempt_at: datetime + """When the next attempt is due (UTC).""" + + delivered_at: Optional[datetime] = None + """When the delivery landed. ``None`` until it does.""" + + created_at: datetime + """When the delivery was queued. A repeat delivery resets it.""" + + payload: dict[str, Any] = Field(default_factory=dict) + """The event body as sent, with its ``id``, ``type``, ``schema_version``, + ``observed_at`` and ``data``. For inspection only: verify signatures + against the raw request bytes your receiver got, never against a + re-serialised copy of this.""" + + +class WebhookDeliveryQueued(BaseModel): + """A delivery that has just been queued, from ``test_endpoint()``.""" + + model_config = ConfigDict(extra="allow") + + delivery_id: str + """Delivery identifier. Read it back from ``list_deliveries()``.""" + + event_id: str + """Event identifier carried in the delivered payload and the ``0xa-event-id`` header.""" + + +class WebhookRedelivery(BaseModel): + """A past delivery queued for another attempt, re-queued in place.""" + + model_config = ConfigDict(extra="allow") + + delivery_id: str + """Delivery identifier, the same one that was asked for.""" + + event_id: str + """Event identifier, unchanged, so a receiver that already processed the + event can deduplicate on it.""" + + event_type: str + """Event type being delivered again.""" + + state: str + """Always ``"pending"`` immediately after a repeat delivery is queued.""" + + attempts: int = 0 + """Attempt counter, restarted from zero.""" + + next_attempt_at: datetime + """When the attempt is due, which is immediately.""" + + note: Optional[str] = None + """The API's note on what the repeat delivery rewrites on the record.""" + + +class WebhookWatchedAddress(BaseModel): + """A wallet on your watched list. Address scoped event types report only on these.""" + + model_config = ConfigDict(extra="allow") + + id: str + """Watched address identifier. Pass it to ``delete_address()``.""" + + address: str + """The wallet, stored lowercase.""" + + label: str = "" + """Your own label for the wallet, at most 64 characters.""" + + created_at: datetime + """When the wallet was added (UTC).""" + + +class WebhookLimitUsage(BaseModel): + """One cap: what the plan allows, what is in use and what is left.""" + + model_config = ConfigDict(extra="allow") + + used: int + """In use now.""" + + limit: int + """What the plan allows. Zero on a plan without webhook delivery.""" + + remaining: int + """What is left, never below zero. A plan change can leave an account + legitimately over a cap.""" + + +class WebhookDeliveryBudget(BaseModel): + """Today's delivery budget. It resets on its own; a paused rule does not.""" + + model_config = ConfigDict(extra="allow") + + used: int + """Deliveries today.""" + + limit: Optional[int] = None + """Deliveries a day the plan allows. ``None`` when the plan has no ceiling.""" + + remaining: Optional[int] = None + """Deliveries left today, never below zero. ``None`` when there is no ceiling.""" + + unlimited: bool = False + """True when the plan has no daily ceiling.""" + + resets_at: datetime + """When the budget resets (UTC).""" + + resets_at_note: Optional[str] = None + """Present only while something is paused, to say that the reset time is + the budget's and not the pause's.""" + + +class WebhookPausedSubscriptions(BaseModel): + """Paused rules on the account. Always present, so zero needs no special case.""" + + model_config = ConfigDict(extra="allow") + + count: int = 0 + """How many rules are paused and delivering nothing.""" + + earliest_paused_at: Optional[datetime] = None + """Start of the oldest pause still in force.""" + + reasons: list[str] = Field(default_factory=list) + """Distinct causes across the paused rules.""" + + message: Optional[str] = None + """What is paused and what clears it. Present only when something is paused.""" + + +class WebhookLimits(BaseModel): + """What the plan allows for webhooks and what is in use, from ``limits()``.""" + + model_config = ConfigDict(extra="allow") + + plan: str + """The plan webhook decisions are priced at (for example ``"pro"``).""" + + plan_label: Optional[str] = None + """The plan's display name.""" + + included: bool + """Whether the plan has webhook delivery at all. False on Free.""" + + preview_included: bool + """Whether the estimate and the dry-run are available. True on every plan.""" + + endpoints: WebhookLimitUsage + """Endpoint cap and usage.""" + + subscriptions: WebhookLimitUsage + """Subscription cap and usage.""" + + watched_addresses: WebhookLimitUsage + """Watched wallet cap and usage.""" + + deliveries_per_day: WebhookDeliveryBudget + """Today's delivery budget.""" + + paused_subscriptions: WebhookPausedSubscriptions + """Paused rules, reported next to the budget because a paused rule does not + restart when the budget resets.""" + + notice: Optional[str] = None + """Why the caps are zero, and what to do about it. Present only on a plan + without webhook delivery.""" + + +class WebhookPreviewWindow(BaseModel): + """The window a preview vouches for. + + It can start later than the one asked for when a scan reached its row cap. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + from_: datetime = Field(alias="from") + """Start of the window the answer covers. Named ``from_`` because ``from`` + is a Python keyword.""" + + to: datetime + """End of the window, which is the moment of the request.""" + + +class WebhookPreviewOccurrence(BaseModel): + """One occurrence a preview says would have been delivered.""" + + model_config = ConfigDict(extra="allow") + + observed_at_estimate: datetime + """The occurrence's own timestamp. A real delivery's ``observed_at`` is this + plus the time it takes to see the occurrence.""" + + data: dict[str, Any] = Field(default_factory=dict) + """The occurrence body, the same ``data`` a delivery would carry.""" + + +class WebhookDryRun(BaseModel): + """Which occurrences a would-be subscription would have delivered, from ``dry_run()``.""" + + model_config = ConfigDict(extra="allow") + + event_type: str + """Event type that was evaluated.""" + + window: WebhookPreviewWindow + """The window the answer covers.""" + + matched: int = 0 + """Occurrences that matched inside the window, before ``limit`` is applied.""" + + truncated: bool = False + """True when fewer occurrences are returned than matched, or a scan hit its + row cap and the window was narrowed.""" + + occurrences: list[WebhookPreviewOccurrence] = Field(default_factory=list) + """Matches, newest first, at most ``limit``.""" + + +class WebhookEstimateDayCount(BaseModel): + """One 24 hour bin of an estimate.""" + + model_config = ConfigDict(extra="allow") + + date: date + """UTC date the bin ends on.""" + + count: int + """Occurrences that would have been delivered in the bin.""" + + +class WebhookEstimateRung(BaseModel): + """The daily rate the same configuration would have had at another threshold.""" + + model_config = ConfigDict(extra="allow") + + value: float + """Threshold on the primary metric.""" + + per_day: float + """Deliveries a day at that threshold, everything else unchanged.""" + + +class WebhookEstimateDistribution(BaseModel): + """Quantiles of the primary metric over the matched occurrences.""" + + model_config = ConfigDict(extra="allow") + + n: int + """Occurrences the quantiles are computed over.""" + + p50: float + """Median.""" + + p90: float + """90th percentile.""" + + p99: float + """99th percentile.""" + + max: float + """Largest value seen.""" + + +class WebhookEstimateBasis(BaseModel): + """How an estimate was produced.""" + + model_config = ConfigDict(extra="allow") + + mode: str + """``"exact"`` counted every occurrence, ``"sampled"`` scaled the counts + from a capped scan (the note says by how much), and ``"replayed"`` re-ran a + windowed rule over history at your own parameters.""" + + note: Optional[str] = None + """What qualifies the numbers, when anything does.""" + + +class WebhookEstimate(BaseModel): + """How often a would-be subscription would have fired, from ``estimate()``. + + Compare ``per_day_p50`` and ``per_day_max`` with the plan's daily delivery + budget before turning a rule on. + """ + + model_config = ConfigDict(extra="allow") + + event_type: str + """Event type that was evaluated.""" + + window: WebhookPreviewWindow + """The window the answer covers.""" + + days: int + """Days covered. Shorter than requested when the event type caps its own window.""" + + total: int + """Occurrences that would have been delivered across the window.""" + + per_day: list[WebhookEstimateDayCount] = Field(default_factory=list) + """One entry per day, oldest first, zero filled.""" + + per_day_p50: float = 0.0 + """Median deliveries a day.""" + + per_day_max: int = 0 + """Busiest day in the window.""" + + primary_metric: Optional[str] = None + """The metric the ladder and the distribution are about. ``None`` when the type has none.""" + + ladder: list[WebhookEstimateRung] = Field(default_factory=list) + """Daily rates at other thresholds, ascending. Empty without a primary metric.""" + + distribution: Optional[WebhookEstimateDistribution] = None + """Quantiles of the primary metric. ``None`` without a primary metric or a match.""" + + sample: list[WebhookPreviewOccurrence] = Field(default_factory=list) + """A sample of matches, newest first, in the dry-run's shape.""" + + basis: WebhookEstimateBasis + """How the estimate was produced.""" diff --git a/oxarchive/webhook_signature.py b/oxarchive/webhook_signature.py new file mode 100644 index 0000000..b267675 --- /dev/null +++ b/oxarchive/webhook_signature.py @@ -0,0 +1,577 @@ +"""Verification for inbound 0xArchive webhook deliveries. + +0xArchive signs every delivery with HMAC-SHA256 over the exact bytes it puts +on the wire. This module verifies that signature, enforces a replay window, +and hands back the parsed event. + +The one rule that matters: verify the RAW REQUEST BODY, before any JSON +parser touches it. The signature covers the exact bytes that were sent, and +their key order and spacing need not match what any JSON library produces. +Re-serialising a parsed dict produces different bytes, and the signature will +not match. + +Example (Flask):: + + from flask import Flask, request + from oxarchive import WebhookVerifier, WebhookSignatureError + + app = Flask(__name__) + verifier = WebhookVerifier(os.environ["OXARCHIVE_WEBHOOK_SECRET"]) + + @app.post("/webhooks/0xarchive") + def receive(): + try: + event = verifier.verify(request.get_data(), request.headers) + except WebhookSignatureError: + # A bad signature is never worth retrying. Answer 4xx so the + # delivery is not replayed at you for 24 hours. + return "", 400 + if already_processed(event.id): # delivery is at-least-once + return "", 200 + enqueue(event) # do the real work out of band + return "", 200 + +Example (FastAPI):: + + @app.post("/webhooks/0xarchive") + async def receive(request: Request): + event = verifier.verify(await request.body(), request.headers) + ... + +Both read the raw body. ``request.get_json()`` and a Pydantic model do not. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import re +import time +from dataclasses import dataclass, field +from typing import Any, Iterable, Mapping, Optional, Sequence, Union + +__all__ = [ + "DEFAULT_TOLERANCE_SECONDS", + "SIGNATURE_HEADER", + "EVENT_ID_HEADER", + "EVENT_TYPE_HEADER", + "WebhookSignature", + "WebhookEvent", + "WebhookSignatureError", + "WebhookVerifier", + "parse_signature_header", + "verify_webhook_signature", + "verify_webhook", +] + +SIGNATURE_HEADER = "0xa-signature" +"""Header carrying the timestamp and one or more HMAC signatures.""" + +EVENT_ID_HEADER = "0xa-event-id" +"""Header carrying the event UUID. Deduplicate on this value.""" + +EVENT_TYPE_HEADER = "0xa-event-type" +"""Header carrying the event type, for example ``account.fill``.""" + +DEFAULT_TOLERANCE_SECONDS = 300 +"""Default replay window, in seconds (5 minutes). + +0xArchive re-signs with a fresh timestamp on every attempt, including retries +and manual redeliveries, so a legitimate delivery is never stale by more than +network and clock skew. A tight window is safe here. +""" + +_SIGNATURE_SCHEME = "v1" + +# The server emits `t` as a plain decimal integer and each `v1` as 64 +# lowercase hex characters. Both patterns are enforced rather than assumed, +# because the header is attacker-controlled and Python is generous in ways +# that turn a rejection into a crash: `int()` happily parses Unicode digits, +# underscores, and surrounding whitespace, and `hmac.compare_digest` raises +# TypeError on a string with any non-ASCII character in it. A receiver that +# raises where it should have returned 4xx answers 5xx instead, and a 5xx is +# the one response that makes 0xArchive replay the delivery for 24 hours. +# +# The digit count is bounded for the same reason. Python 3.11 and later +# refuse `int()` on a string of more than 4300 digits, so an unbounded pattern +# waves `t=` followed by five thousand nines through the shape check and into +# a ValueError. Nineteen digits is the full width of the signed 64-bit integer +# the server puts there. A real timestamp is ten. +_DECIMAL_INTEGER = re.compile(r"\A-?[0-9]{1,19}\Z") +_LOWER_HEX = re.compile(r"\A[0-9a-f]+\Z") + + +class WebhookSignatureError(Exception): + """A delivery failed verification. + + Respond to the sender with a 4xx status when this is raised. A 5xx tells + 0xArchive to retry, which replays the same unverifiable delivery at you + for up to 24 hours. + + Attributes: + reason: Machine-readable cause. One of ``missing_header``, + ``malformed_header``, ``timestamp_out_of_tolerance``, + ``no_secrets``, or ``signature_mismatch``. + """ + + def __init__(self, message: str, reason: str): + super().__init__(message) + self.message = message + self.reason = reason + + +@dataclass(frozen=True) +class WebhookSignature: + """The parsed contents of the ``0xa-signature`` header.""" + + timestamp: str + """The timestamp exactly as it appeared in the header. + + This literal substring is what goes back into the signed string. Never + reformat it: parse a copy for the freshness check instead. + """ + + timestamp_seconds: int + """``timestamp`` as an integer. Unix SECONDS, not milliseconds.""" + + signatures: tuple[str, ...] + """Every ``v1=`` value in the header, lowercased. + + Normally one. Two during a secret rotation overlap, where the second is + the same payload signed with the previous secret. + """ + + +@dataclass(frozen=True) +class WebhookEvent: + """A delivery whose signature has been verified.""" + + id: str + """The event UUID, from ``0xa-event-id``. + + Stable across retries and across manual redelivery. This is the value to + deduplicate on: delivery is at-least-once. + """ + + type: str + """The event type, from ``0xa-event-type``, for example ``account.fill``.""" + + body: bytes + """The exact bytes that were signed and verified.""" + + signature: WebhookSignature + """The verified signature envelope.""" + + payload: dict[str, Any] = field(default_factory=dict) + """The body parsed as JSON. + + Read the event data from here. Do not re-serialise it and expect the + bytes to match ``body``. + """ + + +def _as_bytes(body: Union[bytes, bytearray, memoryview, str]) -> bytes: + """Coerce a body to the bytes that were signed. + + A ``str`` is encoded as UTF-8, which is what the server emitted. Passing + raw bytes straight through avoids the question entirely and is preferred. + """ + if isinstance(body, str): + return body.encode("utf-8") + if isinstance(body, (bytearray, memoryview)): + return bytes(body) + return body + + +def _lookup_header(headers: Mapping[str, Any], name: str) -> Optional[str]: + """Case-insensitive header lookup. + + HTTP header names are case-insensitive and 0xArchive emits them + lowercase, but proxies and frameworks re-case freely, so nothing here + depends on the emitted spelling. + """ + getter = getattr(headers, "get", None) + if getter is not None: + # Multidicts such as Werkzeug's and Starlette's are already + # case-insensitive; try the cheap path first. + found = getter(name) + if found is not None: + return str(found) + + wanted = name.lower() + for key, value in headers.items(): + if str(key).lower() == wanted: + return str(value) + return None + + +def _normalise_secrets(secret: Union[str, Iterable[str]]) -> tuple[str, ...]: + """Accept one secret or several, and drop empties. + + Anything that is not text is refused here, where it surfaces as a startup + error with a message that names the fix. A secret read as bytes is the + case worth naming: ``bytes`` is iterable, so it used to fall through as a + sequence of integers, build a verifier that looked healthy, and then raise + ``AttributeError`` inside the HMAC on every delivery, which is a 5xx and + therefore 24 hours of retries. + """ + if isinstance(secret, str): + candidates: Sequence[Any] = [secret] + elif isinstance(secret, (bytes, bytearray, memoryview)): + candidates = [secret] + else: + candidates = list(secret) + + for item in candidates: + if isinstance(item, (bytes, bytearray, memoryview)): + raise TypeError( + "A signing secret must be a string. Decode it first: " + 'secret.decode("utf-8").' + ) + if not isinstance(item, str): + raise TypeError( + f"A signing secret must be a string, not {type(item).__name__}." + ) + return tuple(s for s in candidates if s) + + +def parse_signature_header(header: str) -> WebhookSignature: + """Parse a ``0xa-signature`` header value. + + The grammar is ``t=,v1=<64 hex>[,v1=<64 hex>]``: comma + separated, no spaces. Every ``v1`` is collected, not just the first. A + header with only the first signature read is the classic rotation bug: + it works until the day someone rotates a secret, then fails intermittently + for 24 hours. + + Args: + header: The raw header value. + + Returns: + The parsed timestamp and every signature in the header. + + Raises: + WebhookSignatureError: The header is malformed, has no timestamp, has + a non-integer timestamp, or carries no ``v1`` signature. + """ + timestamp: Optional[str] = None + signatures: list[str] = [] + + for element in header.split(","): + key, sep, value = element.strip().partition("=") + if not sep: + continue + if key == "t" and timestamp is None: + timestamp = value + elif key == _SIGNATURE_SCHEME: + signatures.append(value.lower()) + + if timestamp is None: + raise WebhookSignatureError( + f"{SIGNATURE_HEADER} has no 't=' timestamp.", "malformed_header" + ) + if not signatures: + raise WebhookSignatureError( + f"{SIGNATURE_HEADER} has no 'v1=' signature.", "malformed_header" + ) + if not _DECIMAL_INTEGER.match(timestamp): + raise WebhookSignatureError( + f"{SIGNATURE_HEADER} timestamp '{timestamp}' is not a decimal integer.", + "malformed_header", + ) + timestamp_seconds = int(timestamp) + + return WebhookSignature( + timestamp=timestamp, + timestamp_seconds=timestamp_seconds, + signatures=tuple(signatures), + ) + + +def verify_webhook_signature( + body: Union[bytes, bytearray, memoryview, str], + signature_header: str, + secret: Union[str, Iterable[str]], + *, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, + now: Optional[float] = None, +) -> WebhookSignature: + """Verify a signature header against a body and one or more secrets. + + The signed string is ``.``: the literal timestamp from + the header, one ASCII full stop, then the raw body bytes. Nothing else is + covered, so neither the event id, the event type, the destination URL, nor + any other header is authenticated. + + Args: + body: The RAW request body. Bytes are preferred; a ``str`` is encoded + as UTF-8. Never pass a re-serialised dict. + signature_header: The ``0xa-signature`` header value. + secret: The endpoint signing secret (``whsec_`` followed by 64 hex + characters), or several of them. Pass both the new and the + previous secret while a rotation window is open. The ``whsec_`` + prefix is part of the key: do not strip it, and do not decode the + hex. + tolerance_seconds: Replay window in seconds, compared as an absolute + difference so a receiver clock running behind the server still + verifies. Pass ``0`` to disable the check, which is only + reasonable when replaying a captured delivery in a test. + now: Unix seconds to treat as the current time. For tests. + + Returns: + The verified signature envelope. + + Raises: + WebhookSignatureError: No secrets were supplied, the header is + malformed, the timestamp is outside the tolerance, or no + signature matched. + """ + secrets = _normalise_secrets(secret) + if not secrets: + raise WebhookSignatureError("No signing secret was supplied.", "no_secrets") + + parsed = parse_signature_header(signature_header) + + if tolerance_seconds > 0: + current = time.time() if now is None else now + drift = abs(int(current) - parsed.timestamp_seconds) + if drift > tolerance_seconds: + raise WebhookSignatureError( + f"Delivery timestamp is {drift}s away from now, outside the " + f"{tolerance_seconds}s tolerance.", + "timestamp_out_of_tolerance", + ) + + signed_payload = parsed.timestamp.encode("ascii") + b"." + _as_bytes(body) + + matched = False + for key in secrets: + expected = hmac.new( + key.encode("utf-8"), signed_payload, hashlib.sha256 + ).hexdigest() + for candidate in parsed.signatures: + # A candidate that is not exactly as long as the digest, or is + # not lowercase hex, cannot match. Screen it out here rather than + # handing it to compare_digest, which raises TypeError on any + # non-ASCII string: a malformed signature must be a rejection, + # never an exception the receiver did not plan for. + if len(candidate) != len(expected) or not _LOWER_HEX.match(candidate): + continue + # compare_digest rather than ==, so a mismatch does not leak the + # position of the first differing character through timing. The + # loop does not break early: at most two secrets by two + # signatures, so finishing it costs nothing. + if hmac.compare_digest(candidate, expected): + matched = True + if not matched: + raise WebhookSignatureError( + "No signature in the header matched the supplied secrets.", + "signature_mismatch", + ) + + return parsed + + +def verify_webhook( + body: Union[bytes, bytearray, memoryview, str], + headers: Mapping[str, Any], + secret: Union[str, Iterable[str]], + *, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, + now: Optional[float] = None, +) -> WebhookEvent: + """Verify a delivery and return the parsed event. + + Args: + body: The RAW request body, ideally as bytes. + headers: The request headers. Any mapping works; the lookup is + case-insensitive. + secret: One signing secret, or several during a rotation window. + tolerance_seconds: Replay window in seconds. Defaults to 5 minutes. + now: Unix seconds to treat as the current time. For tests. + + Returns: + The verified event, with its id, type, raw bytes, and parsed payload. + + Raises: + WebhookSignatureError: The ``0xa-signature`` header is missing or + malformed, the timestamp is stale, or no signature matched. + """ + signature_header = _lookup_header(headers, SIGNATURE_HEADER) + if not signature_header: + raise WebhookSignatureError( + f"Request has no {SIGNATURE_HEADER} header.", "missing_header" + ) + + raw = _as_bytes(body) + signature = verify_webhook_signature( + raw, + signature_header, + secret, + tolerance_seconds=tolerance_seconds, + now=now, + ) + + try: + payload = json.loads(raw.decode("utf-8")) if raw else {} + except (UnicodeDecodeError, ValueError): + payload = {} + if not isinstance(payload, dict): + payload = {} + + return WebhookEvent( + id=_lookup_header(headers, EVENT_ID_HEADER) or "", + type=_lookup_header(headers, EVENT_TYPE_HEADER) or "", + body=raw, + signature=signature, + payload=payload, + ) + + +class WebhookVerifier: + """Holds the signing secrets for one endpoint and verifies deliveries. + + Rotating a secret is a three-step move, and this class is built for the + middle step. ``POST /v1/webhooks/endpoints/{id}/rotate`` returns the new + secret and keeps the previous one valid for 24 hours, signing every + delivery with both. Hold both here, deploy, then drop the old one before + the window closes. + + Two constraints the server imposes, worth knowing before you script a + rotation: + + - Only ONE previous secret is carried. Rotating twice inside the window + overwrites it and the original stops verifying immediately. + - The window is measured on the server. A receiver cannot extend it. + + Example:: + + verifier = WebhookVerifier(current_secret) + + # Mid-rotation: accept both until the old one is retired. + verifier = WebhookVerifier([new_secret, previous_secret]) + + event = verifier.verify(raw_body_bytes, request.headers) + """ + + def __init__( + self, + secret: Union[str, Iterable[str]], + *, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, + ): + """Create a verifier. + + Args: + secret: One signing secret, or several to accept during a + rotation window. + tolerance_seconds: Replay window in seconds. Defaults to 5 + minutes. + + Raises: + ValueError: No non-empty secret was supplied. + TypeError: A secret was supplied that is not a string. + """ + secrets = _normalise_secrets(secret) + if not secrets: + raise ValueError( + "WebhookVerifier needs at least one signing secret. Endpoint " + "secrets are returned once, by POST /v1/webhooks/endpoints and " + "by POST /v1/webhooks/endpoints/{id}/rotate." + ) + self._secrets = secrets + self.tolerance_seconds = tolerance_seconds + + @property + def secrets(self) -> tuple[str, ...]: + """The secrets this verifier will accept, in the order supplied.""" + return self._secrets + + def add_secret(self, secret: str) -> None: + """Start accepting another secret, newest first. + + Call this with the secret returned by a rotation, before deploying + the code that stops using the old one. + + Args: + secret: The additional signing secret. + + Raises: + ValueError: The secret is empty. + TypeError: The secret is not a string. + """ + normalised = _normalise_secrets([secret]) + if not normalised: + raise ValueError("Signing secret must not be empty.") + if normalised[0] not in self._secrets: + self._secrets = (normalised[0],) + self._secrets + + def remove_secret(self, secret: str) -> None: + """Stop accepting a secret, once its rotation window has closed. + + Args: + secret: The signing secret to drop. + + Raises: + ValueError: Dropping it would leave no secrets at all. + """ + remaining = tuple(s for s in self._secrets if s != secret) + if not remaining: + raise ValueError("A verifier must keep at least one signing secret.") + self._secrets = remaining + + def verify( + self, + body: Union[bytes, bytearray, memoryview, str], + headers: Mapping[str, Any], + *, + now: Optional[float] = None, + ) -> WebhookEvent: + """Verify a delivery and return the parsed event. + + Args: + body: The RAW request body, ideally as bytes. + headers: The request headers, looked up case-insensitively. + now: Unix seconds to treat as the current time. For tests. + + Returns: + The verified event. + + Raises: + WebhookSignatureError: The delivery did not verify. Answer 4xx. + """ + return verify_webhook( + body, + headers, + self._secrets, + tolerance_seconds=self.tolerance_seconds, + now=now, + ) + + def is_valid( + self, + body: Union[bytes, bytearray, memoryview, str], + headers: Mapping[str, Any], + *, + now: Optional[float] = None, + ) -> bool: + """Verify a delivery, returning a bool instead of raising. + + Prefer :meth:`verify`: the exception carries a ``reason`` that tells + a stale clock apart from a wrong secret, and you usually want that in + the log line. + + Args: + body: The RAW request body. + headers: The request headers. + now: Unix seconds to treat as the current time. For tests. + + Returns: + True when the delivery verified. + """ + try: + self.verify(body, headers, now=now) + except WebhookSignatureError: + return False + return True diff --git a/oxarchive/websocket.py b/oxarchive/websocket.py index f83831d..dc96327 100644 --- a/oxarchive/websocket.py +++ b/oxarchive/websocket.py @@ -182,6 +182,18 @@ ) """HIP-3, HIP-4, and Hyperliquid Spot L4 channels without replay support.""" +FULL_DEPTH_L2_CHANNELS: frozenset[str] = frozenset({"orderbook_full", "hip3_orderbook_full"}) +"""Full-depth L2 book channels (Hyperliquid core and HIP-3), aggregated from +the order-level book. Live subscriptions only: a subscription opens with an +``l4_snapshot`` of every price level, followed by ``l4_batch`` messages of +changed levels.""" + +FULL_DEPTH_LIVE_ONLY_ERROR = ( + "orderbook_full and hip3_orderbook_full support live subscriptions only. " + "Stored full-depth history is served over REST by l2_orderbook.history() " + "and l2_orderbook.diffs()." +) + L4_LIVE_ONLY_ERROR = ( "HIP-3, HIP-4, and Hyperliquid Spot L4 channels support live subscriptions only. " "Use subscribe for current streams." @@ -202,9 +214,15 @@ def _bulk_stream_deprecation(name: str) -> str: def _validate_historical_l4_channel(channel: WsChannel) -> None: - """Reject historical operations for L4 channels that are live-only.""" + """Reject historical operations for live-only channels, before anything is sent. + + Covers the HIP-3, HIP-4 and Hyperliquid Spot L4 channels and the + full-depth L2 channels. + """ if channel in L4_LIVE_ONLY_CHANNELS: raise ValueError(L4_LIVE_ONLY_ERROR) + if channel in FULL_DEPTH_L2_CHANNELS: + raise ValueError(FULL_DEPTH_LIVE_ONLY_ERROR) def _validate_live_subscription(channel: WsChannel, interval_ms: Optional[int] = None) -> None: @@ -786,6 +804,38 @@ def unsubscribe_hip3_liquidations(self, coin: str) -> None: """Unsubscribe from live HIP-3 liquidations for a coin.""" self.unsubscribe("hip3_liquidations", coin) + # -- Full-depth L2 (Hyperliquid core and HIP-3) -------------------------- + + def subscribe_orderbook_full(self, coin: str) -> None: + """Subscribe to the live full-depth Hyperliquid L2 book for a coin. + + Every price level, not just the top 20. The server first sends an + ``l4_snapshot`` whose ``data`` holds ``bids`` and ``asks`` (each level + ``{"px", "sz", "n"}``) with level counts, totals, ``mid_price``, + ``spread`` and ``spread_bps``, then ``l4_batch`` messages whose items + are changed levels ``{"side", "px", "sz", "n", "bn"}``; a level whose + ``sz`` is 0 has been removed. Receive them with :meth:`on_l4_snapshot` + and :meth:`on_l4_batch`, applying batches in the order received. + Live only: stored full-depth history is served over REST by + ``l2_orderbook.history()`` and ``l2_orderbook.diffs()``. + """ + self.subscribe("orderbook_full", coin) + + def unsubscribe_orderbook_full(self, coin: str) -> None: + """Unsubscribe from the live full-depth Hyperliquid L2 book.""" + self.unsubscribe("orderbook_full", coin) + + def subscribe_hip3_orderbook_full(self, coin: str) -> None: + """Subscribe to the live full-depth HIP-3 L2 book (case-sensitive coin). + + Same messages as :meth:`subscribe_orderbook_full`. + """ + self.subscribe("hip3_orderbook_full", coin) + + def unsubscribe_hip3_orderbook_full(self, coin: str) -> None: + """Unsubscribe from the live full-depth HIP-3 L2 book.""" + self.unsubscribe("hip3_orderbook_full", coin) + # -- HIP-4 outcome markets ----------------------------------------------- def subscribe_hip4_orderbook(self, coin: str) -> None: @@ -1042,10 +1092,12 @@ async def replay( Hyperliquid core ``l4_diffs`` and ``l4_orders`` replay as one typed ``l4_snapshot`` followed by ordered ``l4_batch`` messages. HIP-3, - HIP-4, and Hyperliquid Spot L4 channels are live-only and are rejected - here. All six ``lighter_*`` channels and all five ``rh_lighter_*`` - channels (Lighter on Robinhood Chain; ``rh_lighter_candles`` is - replay-only) support bounded historical replay. Replay rows keep their + HIP-4, and Hyperliquid Spot L4 channels and the full-depth L2 channels + (``orderbook_full``, ``hip3_orderbook_full``) are live-only and are + rejected here with ``ValueError`` before anything is sent. All six + ``lighter_*`` channels and all five ``rh_lighter_*`` channels (Lighter + on Robinhood Chain; ``rh_lighter_candles`` is replay-only) support + bounded historical replay. Replay rows keep their historical shapes, which differ from the live Lighter messages. Args: @@ -1281,8 +1333,10 @@ def on_liquidations(self, handler: LiquidationsHandler) -> None: def on_l4_snapshot(self, handler: Callable[[str, str, dict], None]) -> None: """Set handler for the initial L4 orderbook snapshot. - Both live L4 subscriptions and Hyperliquid core L4 replay emit one - ``l4_snapshot`` before any ``l4_batch`` messages. The generic + Live L4 subscriptions, live full-depth L2 subscriptions + (``orderbook_full``, ``hip3_orderbook_full``) and Hyperliquid core L4 + replay emit one ``l4_snapshot`` before any ``l4_batch`` messages; the + ``channel`` argument tells them apart. The generic :meth:`on_message` handler receives a typed :class:`WsL4Snapshot`; this dedicated callback keeps the established ``(channel, coin, message)`` raw-dict shape. ``message["data"]`` holds the book and @@ -1297,7 +1351,9 @@ def on_l4_batch(self, handler: Callable[[str, str, list], None]) -> None: :class:`WsL4Batch`; this dedicated callback keeps the established ``(channel, coin, records)`` raw-record shape. Records must be applied in the order received. Live batches are emitted in short windows; - replay batches are delivered in server order. + replay batches are delivered in server order. On the full-depth L2 + channels each record is a changed price level, and ``sz`` of 0 + removes the level. """ self._on_l4_batch = handler diff --git a/tests/test_spec_fixes.py b/tests/test_spec_fixes.py new file mode 100644 index 0000000..d10b597 --- /dev/null +++ b/tests/test_spec_fixes.py @@ -0,0 +1,345 @@ +"""Existing methods reconciled with the routes and shapes the API serves. + +A trades cursor is an opaque string and goes back to the API unchanged; HIP-4 +freshness has no funding entry; methods for routes the API does not serve are +gone; parameters it never applied are refused; symbol coverage encodes the +symbol; spot freshness parses its datasets; L3 takes account. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any + +import pytest +from _mock_api import envelope, mock_client + +T_START = 1790640000000 # 2026-09-29T00:00:00Z +T_END = 1790726400000 # 2026-09-30T00:00:00Z + + +TRADE: dict[str, Any] = { + "coin": "BTC", + "side": "B", + "price": "82923.0", + "size": "0.01", + "timestamp": "2026-09-29T00:00:00.578Z", + "trade_id": 218303497631402, +} + + +@pytest.mark.parametrize( + "venue, path, cursor", + [ + ("hyperliquid", "/v1/hyperliquid/trades/BTC", "1790640000578_218303497631402"), + ("lighter", "/v1/lighter/trades/BTC", "1790540000288_32247366750_1"), + ], +) +def test_trades_cursor_is_passed_back_unchanged(venue: str, path: str, cursor: str) -> None: + client, api = mock_client(lambda p, q: envelope([TRADE], next_cursor=cursor)) + trades = getattr(client, venue).trades + + first = trades.list("BTC", start=T_START, end=T_END, limit=1) + trades.list("BTC", start=T_START, end=T_END, limit=1, cursor=first.next_cursor) + asyncio.run(trades.alist("BTC", start=T_START, end=T_END, limit=1, cursor=first.next_cursor)) + + base = {"start": str(T_START), "end": str(T_END), "limit": "1"} + assert first.next_cursor == cursor + assert api.calls == [(path, base), (path, {**base, "cursor": cursor})] + [ + (path, {**base, "cursor": cursor}) + ] + + +def test_trades_cursor_still_accepts_milliseconds_and_datetimes() -> None: + client, api = mock_client(lambda p, q: envelope([])) + + client.hyperliquid.trades.list("BTC", start=T_START, end=T_END, cursor=T_START) + client.hyperliquid.trades.list( + "BTC", start=T_START, end=T_END, cursor=datetime(2026, 9, 29, tzinfo=timezone.utc) + ) + + assert [q["cursor"] for _, q in api.calls] == [str(T_START), str(T_START)] + + +def test_hip4_freshness_parses_without_funding() -> None: + client, api = mock_client( + lambda p, q: envelope( + { + "coin": "#0", + "symbol": "#0", + "exchange": "hip4", + "measured_at": "2026-09-29T02:53:20.018625249Z", + "orderbook": {}, + "trades": {}, + "open_interest": {}, + } + ) + ) + + freshness = client.hyperliquid.hip4.get_freshness("#0") + + assert api.calls == [("/v1/hyperliquid/hip4/freshness/0", {})] + assert freshness.funding is None and freshness.exchange == "hip4" + + +# --------------------------------------------------------------------------- +# Methods whose routes the API does not serve are gone +# --------------------------------------------------------------------------- + + +def test_methods_for_unserved_routes_are_removed() -> None: + client, _ = mock_client(lambda p, q: envelope([])) + hl, hip3, hip4, spot = ( + client.hyperliquid, + client.hyperliquid.hip3, + client.hyperliquid.hip4, + client.spot, + ) + + assert not hasattr(hip4, "l2_orderbook") + for name in ("trigger_levels", "trigger_levels_history"): + assert not hasattr(hip4.orders, name) and not hasattr(hip4.orders, "a" + name) + for name in ("flow", "tpsl", "trigger_levels", "trigger_levels_history"): + assert not hasattr(spot.orders, name) and not hasattr(spot.orders, "a" + name) + assert not hasattr(hip3.liquidations, "by_user") + assert not hasattr(hip3.liquidations, "aby_user") + + # The routes the API does serve keep their methods. + assert hasattr(hl, "l2_orderbook") and hasattr(hip3, "l2_orderbook") + assert hasattr(hl.orders, "trigger_levels") and hasattr(hip3.orders, "trigger_levels") + assert hasattr(hip4.orders, "flow") and hasattr(hip4.orders, "tpsl") + assert hasattr(spot.orders, "history") and hasattr(spot.orders, "ahistory") + assert hasattr(hl.liquidations, "by_user") and hasattr(hip3.liquidations, "volume") + + +def test_hip4_flow_and_tpsl_still_reach_their_routes() -> None: + client, api = mock_client(lambda p, q: envelope([])) + + client.hyperliquid.hip4.orders.flow("#0", start=T_START, end=T_END) + client.hyperliquid.hip4.orders.tpsl("#0", start=T_START, end=T_END) + asyncio.run(client.hyperliquid.hip4.orders.ahistory("#0", start=T_START, end=T_END)) + + assert [path for path, _ in api.calls] == [ + "/v1/hyperliquid/hip4/orders/0/flow", + "/v1/hyperliquid/hip4/orders/0/tpsl", + "/v1/hyperliquid/hip4/orders/0/history", + ] + + +# --------------------------------------------------------------------------- +# Parameters the API never applied are refused before sending +# --------------------------------------------------------------------------- + + +def test_trades_side_is_refused_before_sending() -> None: + client, api = mock_client(lambda p, q: envelope([])) + + with pytest.raises(TypeError, match="does not take 'side'.*Trade.side"): + client.hyperliquid.trades.list("BTC", start=T_START, end=T_END, side="buy") + with pytest.raises(TypeError, match="does not take 'side'"): + asyncio.run(client.lighter.trades.alist("BTC", start=T_START, end=T_END, side="sell")) + + assert api.requests == [] + + +@pytest.mark.parametrize("name", ["user", "status", "order_type"]) +def test_spot_order_history_filters_are_refused_before_sending(name: str) -> None: + client, api = mock_client(lambda p, q: envelope([])) + + with pytest.raises(TypeError, match=f"does not take '{name}'"): + client.spot.orders.history("HYPE-USDC", start=T_START, end=T_END, **{name: "x"}) + with pytest.raises(TypeError, match=f"does not take '{name}'"): + asyncio.run( + client.spot.orders.ahistory("HYPE-USDC", start=T_START, end=T_END, **{name: "x"}) + ) + + assert api.requests == [] + + +def test_spot_order_history_sends_only_the_window_and_paging() -> None: + client, api = mock_client(lambda p, q: envelope([], next_cursor="c2")) + + page = client.spot.orders.history("HYPE-USDC", start=T_START, end=T_END, cursor="c1", limit=5) + + assert api.calls == [ + ( + "/v1/hyperliquid/spot/orders/HYPE-USDC/history", + {"start": str(T_START), "end": str(T_END), "cursor": "c1", "limit": "5"}, + ) + ] + assert page.next_cursor == "c2" + + +def test_core_order_history_keeps_its_filters() -> None: + client, api = mock_client(lambda p, q: envelope([])) + + client.hyperliquid.orders.history( + "BTC", start=T_START, end=T_END, user="0xabc", status="filled", order_type="limit" + ) + + assert api.calls[0][1] == { + "start": str(T_START), + "end": str(T_END), + "user": "0xabc", + "status": "filled", + "order_type": "limit", + } + + +@pytest.mark.parametrize( + "resource", + [ + lambda c: c.hyperliquid.l2_orderbook, + lambda c: c.hyperliquid.hip3.l2_orderbook, + lambda c: c.hyperliquid.l4_orderbook, + lambda c: c.spot.l4_orderbook, + lambda c: c.lighter.l3_orderbook, + ], + ids=["l2", "hip3-l2", "l4", "spot-l4", "l3"], +) +def test_depth_on_history_is_refused_before_sending(resource: Any) -> None: + client, api = mock_client(lambda p, q: envelope([])) + book = resource(client) + + with pytest.raises(TypeError, match="does not take 'depth'.*get\\(\\) only"): + book.history("BTC", start=T_START, end=T_END, depth=20) + with pytest.raises(TypeError, match="does not take 'depth'"): + asyncio.run(book.ahistory("BTC", start=T_START, end=T_END, depth=20)) + + assert api.requests == [] + + +def test_depth_still_applies_to_get() -> None: + client, api = mock_client(lambda p, q: envelope({"bids": [], "asks": []})) + + client.hyperliquid.l2_orderbook.get("BTC", depth=10) + client.hyperliquid.l4_orderbook.get("BTC", depth=10) + + assert [q for _, q in api.calls] == [{"depth": "10"}, {"depth": "10"}] + + +# --------------------------------------------------------------------------- +# L3 account +# --------------------------------------------------------------------------- + + +def test_l3_get_sends_account() -> None: + client, api = mock_client(lambda p, q: envelope({"orders": []})) + + client.lighter.l3_orderbook.get("btc", depth=20, account=702384) + asyncio.run(client.lighter.l3_orderbook.aget("BTC", account=702384)) + + assert api.calls == [ + ("/v1/lighter/l3orderbook/BTC", {"depth": "20", "account": "702384"}), + ("/v1/lighter/l3orderbook/BTC", {"account": "702384"}), + ] + + +def test_l3_history_sends_account() -> None: + client, api = mock_client(lambda p, q: envelope([], next_cursor="1790640373586")) + + page = client.lighter.l3_orderbook.history( + "BTC", start=T_START, end=T_END, limit=2, account=702384 + ) + asyncio.run(client.lighter.l3_orderbook.ahistory("BTC", start=T_START, end=T_END)) + + base = {"start": str(T_START), "end": str(T_END)} + assert api.calls == [ + ("/v1/lighter/l3orderbook/BTC/history", {**base, "limit": "2", "account": "702384"}), + ("/v1/lighter/l3orderbook/BTC/history", base), + ] + assert page.next_cursor == "1790640373586" + + +# --------------------------------------------------------------------------- +# Symbol coverage and spot freshness +# --------------------------------------------------------------------------- + +COVERAGE: dict[str, Any] = { + "exchange": "hip3", + "symbol": "km:US500", + "data_types": {}, +} + + +@pytest.mark.parametrize( + "exchange, symbol, raw_path", + [ + ("hip3", "km:US500", "/v1/data-quality/coverage/hip3/km%3AUS500"), + ("spot", "HYPE-USDC", "/v1/data-quality/coverage/spot/HYPE-USDC"), + ("hip4", "#0", "/v1/data-quality/coverage/hip4/%230"), + ("rh-lighter", "AAPL-USDG", "/v1/data-quality/coverage/rh-lighter/AAPL-USDG"), + ("Hyperliquid", "BTC", "/v1/data-quality/coverage/hyperliquid/BTC"), + ], +) +def test_symbol_coverage_sends_the_symbol_as_one_encoded_segment( + exchange: str, symbol: str, raw_path: str +) -> None: + client, api = mock_client(lambda p, q: {**COVERAGE, "symbol": symbol}) + + result = client.data_quality.symbol_coverage(exchange, symbol) + asyncio.run(client.data_quality.asymbol_coverage(exchange, symbol)) + + assert api.raw_paths == [raw_path, raw_path] + assert result.symbol == symbol + + +def test_symbol_coverage_keeps_the_symbol_case() -> None: + client, api = mock_client(lambda p, q: COVERAGE) + + client.data_quality.symbol_coverage("hip3", "xyz:XYZ100") + + assert api.calls[0][0] == "/v1/data-quality/coverage/hip3/xyz:XYZ100" + + +SPOT_FRESHNESS: dict[str, Any] = { + "coin": "HYPE-USDC", + "exchange": "spot", + "l4_checkpoints": {"lag_ms": 82358, "last_updated": "2026-09-29T03:16:29.346Z"}, + "l4_diffs": {"lag_ms": 1614, "last_updated": "2026-09-29T03:17:50.090Z"}, + "measured_at": "2026-09-29T03:17:51.704462628Z", + "orderbook": {"lag_ms": 4936, "last_updated": "2026-09-29T03:17:46.768Z"}, + "orders": {"lag_ms": 7589, "last_updated": "2026-09-29T03:17:44.115Z"}, + "symbol": "HYPE-USDC", + "trades": {"lag_ms": 6660, "last_updated": "2026-09-29T03:17:45.044Z"}, + "twap": {"lag_ms": 207694, "last_updated": "2026-09-29T03:14:24.010Z"}, +} + + +def test_spot_freshness_parses_the_datasets_the_api_returns() -> None: + client, api = mock_client(lambda p, q: envelope(SPOT_FRESHNESS)) + + fresh = client.spot.get_freshness("HYPE-USDC") + afresh = asyncio.run(client.spot.aget_freshness("HYPE-USDC")) + + assert api.calls == [("/v1/hyperliquid/spot/freshness/HYPE-USDC", {})] * 2 + assert fresh == afresh + assert fresh.symbol == "HYPE-USDC" and fresh.exchange == "spot" + assert fresh.orderbook is not None and fresh.orderbook.lag_ms == 4936 + assert fresh.twap is not None + assert fresh.twap.last_updated == datetime(2026, 9, 29, 3, 14, 24, 10000, tzinfo=timezone.utc) + assert fresh.model_extra == {} + assert sorted(fresh.tables) == [ + "l4_checkpoints", + "l4_diffs", + "orderbook", + "orders", + "trades", + "twap", + ] + assert fresh.tables["l4_diffs"].lag_ms == 1614 + + +def test_spot_freshness_tables_skip_missing_datasets() -> None: + client, _ = mock_client( + lambda p, q: envelope( + {"symbol": "PURR-USDC", "coin": "PURR-USDC", "exchange": "spot", + "measured_at": "2026-09-29T03:17:51Z", "trades": {"lag_ms": None}} + ) + ) + + fresh = client.spot.get_freshness("PURR-USDC") + + assert list(fresh.tables) == ["trades"] + assert fresh.orderbook is None and fresh.tables["trades"].lag_ms is None diff --git a/tests/test_surface_parity.py b/tests/test_surface_parity.py new file mode 100644 index 0000000..2167862 --- /dev/null +++ b/tests/test_surface_parity.py @@ -0,0 +1,616 @@ +"""Cumulative volume delta, HIP-3 oracle, HIP-4 questions, wallet +classification, the symbol universe, and order-flow cursor forwarding. + +Paths, query parameters and parsing, sync and async. Response bodies are +shaped like the API's JSON. +""" + +from __future__ import annotations + +import asyncio +from datetime import date, datetime, timedelta, timezone +from typing import Any, Callable + +import pytest +from _mock_api import envelope, mock_client + +from oxarchive import ( + ClassifiedWallet, + CursorResponse, + CvdBucket, + Hip3OracleDiscoveryBounds, + Hip3OracleExternalPrice, + Hip4Question, + ResponseMeta, + SymbolEntry, + WalletClassification, +) + +T_START = 1790640000000 # 2026-09-29T00:00:00Z +T_END = 1790726400000 # 2026-09-30T00:00:00Z + + +def _utc(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def _bucket(ts: int, delta: float, cumulative: float) -> dict[str, Any]: + return { + "timestamp": ts, + "buy_volume": 1000.0 + max(delta, 0), + "sell_volume": 1000.0 - min(delta, 0), + "delta": delta, + "cumulative_delta": cumulative, + } + + +PAGE_NOTICE = ( + "cumulative_delta runs from the first bucket of this page; " + "to join pages, rebuild it from delta" +) + +# --------------------------------------------------------------------------- +# Cumulative volume delta +# --------------------------------------------------------------------------- + + +def test_core_cvd_sends_the_window_and_parses_buckets_and_meta() -> None: + client, api = mock_client( + lambda path, q: envelope( + [_bucket(T_START, 736138.35, 736138.35), _bucket(T_START + 60000, -50.5, 736087.85)], + next_cursor=str(T_START + 60000), + notice=PAGE_NOTICE, + ) + ) + + page = client.hyperliquid.cvd.history( + "btc", + start=datetime(2026, 9, 29), # naive: UTC + end="2026-09-30T00:00:00Z", + interval="1m", + limit=2, + ) + + assert api.calls == [ + ( + "/v1/hyperliquid/cvd/BTC", + {"start": str(T_START), "end": str(T_END), "interval": "1m", "limit": "2"}, + ) + ] + assert isinstance(page, CursorResponse) + assert all(isinstance(b, CvdBucket) for b in page.data) + assert page.data[0].timestamp == T_START + assert page.data[1].delta == -50.5 and page.data[1].cumulative_delta == 736087.85 + assert page.next_cursor == str(T_START + 60000) + assert isinstance(page.meta, ResponseMeta) and page.meta.notice == PAGE_NOTICE + + +def test_cvd_passes_the_cursor_back_unchanged_and_defaults_to_the_last_day() -> None: + client, api = mock_client(lambda path, q: envelope([])) + + client.hyperliquid.cvd.history("ETH", start=T_START, end=T_END, cursor="1790640240000") + client.hyperliquid.cvd.history("ETH") + + assert api.calls == [ + ( + "/v1/hyperliquid/cvd/ETH", + {"start": str(T_START), "end": str(T_END), "cursor": "1790640240000"}, + ), + ("/v1/hyperliquid/cvd/ETH", {}), + ] + + +def test_hip3_cvd_keeps_the_symbol_case_and_prefix() -> None: + client, api = mock_client(lambda path, q: envelope([_bucket(T_START, 1.0, 1.0)])) + + page = asyncio.run( + client.hyperliquid.hip3.cvd.ahistory("km:US500", start=T_START, interval="4h") + ) + + assert api.calls == [ + ("/v1/hyperliquid/hip3/cvd/km:US500", {"start": str(T_START), "interval": "4h"}) + ] + assert page.data[0].delta == 1.0 and page.next_cursor is None + + +def test_core_cvd_async_matches_sync() -> None: + client, api = mock_client(lambda path, q: envelope([_bucket(T_START, 2.0, 2.0)])) + + sync = client.hyperliquid.cvd.history("BTC", start=T_START, end=T_END) + asyn = asyncio.run(client.hyperliquid.cvd.ahistory("BTC", start=T_START, end=T_END)) + + assert api.calls[0] == api.calls[1] + assert sync == asyn + + +@pytest.mark.parametrize("interval", ["2h", "1M", "tick"]) +def test_cvd_rejects_an_unknown_interval_before_sending(interval: str) -> None: + client, api = mock_client(lambda path, q: envelope([])) + + with pytest.raises(ValueError, match="interval must be one of"): + client.hyperliquid.cvd.history("BTC", interval=interval) # type: ignore[arg-type] + assert api.requests == [] + + +@pytest.mark.parametrize("limit", [0, 10001]) +def test_cvd_rejects_a_limit_out_of_range(limit: int) -> None: + client, api = mock_client(lambda path, q: envelope([])) + + with pytest.raises(ValueError, match="limit must be between 1 and 10000"): + client.hyperliquid.hip3.cvd.history("km:US500", limit=limit) + assert api.requests == [] + + +def _cvd_pages() -> Callable[[str, dict[str, str]], dict[str, Any]]: + pages = { + None: envelope( + [_bucket(T_START, 5.0, 5.0), _bucket(T_START + 60000, 3.0, 8.0)], + next_cursor=str(T_START + 60000), + notice=PAGE_NOTICE, + ), + # A short page that still carries a cursor: keep going. + str(T_START + 60000): envelope( + [_bucket(T_START + 120000, -1.0, -1.0)], + next_cursor=str(T_START + 120000), + notice=PAGE_NOTICE, + ), + str(T_START + 120000): envelope([_bucket(T_START + 180000, 4.0, 4.0)]), + } + return lambda path, q: pages[q.get("cursor")] + + +def test_cvd_iterate_follows_cursors_with_unchanged_arguments() -> None: + client, api = mock_client(_cvd_pages()) + + buckets = list( + client.hyperliquid.cvd.iterate("BTC", start=T_START, end=T_END, interval="1m", limit=2) + ) + + assert [b.timestamp for b in buckets] == [T_START + i * 60000 for i in range(4)] + assert sum(b.delta for b in buckets) == 11.0 + base = {"start": str(T_START), "end": str(T_END), "interval": "1m", "limit": "2"} + assert api.calls == [ + ("/v1/hyperliquid/cvd/BTC", base), + ("/v1/hyperliquid/cvd/BTC", {**base, "cursor": str(T_START + 60000)}), + ("/v1/hyperliquid/cvd/BTC", {**base, "cursor": str(T_START + 120000)}), + ] + + +def test_cvd_aiterate_follows_cursors() -> None: + client, api = mock_client(_cvd_pages()) + + async def collect() -> list[CvdBucket]: + return [b async for b in client.hyperliquid.hip3.cvd.aiterate("km:US500", start=T_START)] + + buckets = asyncio.run(collect()) + + assert len(buckets) == 4 + assert [q.get("cursor") for _, q in api.calls] == [ + None, + str(T_START + 60000), + str(T_START + 120000), + ] + assert all(path == "/v1/hyperliquid/hip3/cvd/km:US500" for path, _ in api.calls) + + +# --------------------------------------------------------------------------- +# HIP-3 oracle +# --------------------------------------------------------------------------- + +BOUNDS: dict[str, Any] = { + "block_number": 1164897235, + "bound_fraction": 0.04, + "lower_bound": 719.6544, + "max_leverage": 25, + "reference_price": 749.64, + "reference_source": "external", + "symbol": "km:US500", + "timestamp": 1790649576004, + "upper_bound": 779.6256, +} + +EXTERNAL_PRICE: dict[str, Any] = { + "block_number": 1164897235, + "external_price": 749.64, + "mark_price": 750.97, + "symbol": "km:US500", + "timestamp": 1790649576004, +} + + +def test_discovery_bounds_path_and_parsing() -> None: + client, api = mock_client(lambda path, q: envelope(BOUNDS)) + + bounds = client.hyperliquid.hip3.oracle.discovery_bounds("km:US500") + abounds = asyncio.run(client.hyperliquid.hip3.oracle.adiscovery_bounds("km:US500")) + + assert api.calls == [("/v1/hyperliquid/hip3/oracle/discovery-bounds/km:US500", {})] * 2 + assert isinstance(bounds, Hip3OracleDiscoveryBounds) and bounds == abounds + assert bounds.reference_source == "external" and bounds.max_leverage == 25 + assert bounds.lower_bound == pytest.approx( + bounds.reference_price * (1 - bounds.bound_fraction) + ) + assert bounds.upper_bound == pytest.approx( + bounds.reference_price * (1 + bounds.bound_fraction) + ) + assert bounds.block_number == 1164897235 and bounds.timestamp == 1790649576004 + + +def test_external_price_path_and_parsing_including_nulls() -> None: + client, api = mock_client(lambda path, q: envelope(EXTERNAL_PRICE)) + empty, _ = mock_client( + lambda path, q: envelope({**EXTERNAL_PRICE, "external_price": None, "mark_price": None}) + ) + + price = client.hyperliquid.hip3.oracle.external_price("xyz:XYZ100") + aprice = asyncio.run(client.hyperliquid.hip3.oracle.aexternal_price("xyz:XYZ100")) + missing = empty.hyperliquid.hip3.oracle.external_price("km:US500") + + assert api.calls == [("/v1/hyperliquid/hip3/oracle/external-price/xyz:XYZ100", {})] * 2 + assert isinstance(price, Hip3OracleExternalPrice) and price == aprice + assert (price.external_price, price.mark_price) == (749.64, 750.97) + assert missing.external_price is None and missing.mark_price is None + + +# --------------------------------------------------------------------------- +# HIP-4 questions +# --------------------------------------------------------------------------- + +QUESTION: dict[str, Any] = { + "question_id": 0, + "name": "Recurring", + "description": ( + "class:priceBucket|underlying:BTC|expiry:20260508-0600|" + "priceThresholds:79303,82540|period:1d" + ), + "fallback_outcome_id": 6, + "named_outcome_ids": [7, 8, 9], + "settled_named_outcomes": [], + "first_seen_at": "2026-05-08T05:57:28.326Z", + "last_updated_at": "2026-05-08T05:57:28.326Z", +} + + +def test_questions_list_pages_with_the_question_id_cursor() -> None: + client, api = mock_client( + lambda path, q: envelope([QUESTION, {**QUESTION, "question_id": 1}], next_cursor="1") + ) + + page = client.hyperliquid.hip4.questions.list(limit=2) + apage = asyncio.run(client.hyperliquid.hip4.questions.alist(cursor=page.next_cursor, limit=2)) + + assert api.calls == [ + ("/v1/hyperliquid/hip4/questions", {"limit": "2"}), + ("/v1/hyperliquid/hip4/questions", {"cursor": "1", "limit": "2"}), + ] + assert all(isinstance(q, Hip4Question) for q in page.data) + assert page.next_cursor == "1" and apage.next_cursor == "1" + assert page.meta is not None and page.meta.count == 2 + question = page.data[0] + assert question.named_outcome_ids == [7, 8, 9] + assert question.fallback_outcome_id == 6 and question.settled_named_outcomes == [] + assert question.first_seen_at == _utc("2026-05-08T05:57:28.326Z") + + +def test_question_get_and_the_flat_helpers() -> None: + client, api = mock_client(lambda path, q: envelope({**QUESTION, "extra_field": "kept"})) + + one = client.hyperliquid.hip4.questions.get(0) + two = asyncio.run(client.hyperliquid.hip4.questions.aget(0)) + three = client.hyperliquid.hip4.get_question(0) + four = asyncio.run(client.hyperliquid.hip4.aget_question(0)) + + assert api.calls == [("/v1/hyperliquid/hip4/questions/0", {})] * 4 + assert one == two == three == four + assert one.model_extra == {"extra_field": "kept"} + + +def test_hip4_list_questions_flat_helper_forwards_paging() -> None: + client, api = mock_client(lambda path, q: envelope([QUESTION])) + + client.hyperliquid.hip4.list_questions(cursor="41", limit=100) + asyncio.run(client.hyperliquid.hip4.alist_questions(cursor="41", limit=100)) + + assert api.calls == [("/v1/hyperliquid/hip4/questions", {"cursor": "41", "limit": "100"})] * 2 + + +# --------------------------------------------------------------------------- +# Wallet classification +# --------------------------------------------------------------------------- + +METRICS: dict[str, Any] = { + "total_orders": 99693611, + "cancel_rate": 0.4983595688995557, + "fill_rate": 0.0016403358084802445, + "order_to_trade_ratio": 143.55992534981473, + "ioc_ratio": 0.0, + "post_only_ratio": 0.0, + "tpsl_ratio": 0.0, + "trigger_order_ratio": 0.0, + "unique_coins_traded": 166, + "uses_tpsl": False, + "uses_builder": False, + "avg_order_size_usd": 552.7562187473471, + "max_order_size_usd": 40000.42404, + "median_cancel_speed_ms": 678.0, + "active_hours": 24, + "total_fills": 694439, + "total_volume_usd": 185558445.308452, + "maker_ratio": 1.0, + "long_short_ratio": 0.9988400002728354, + "buy_volume_usd": 92725379.4887142, + "sell_volume_usd": 92833065.81973696, + "total_fees_usd": -1855.2356810011863, + "realized_pnl_usd": 727.6003800000058, + "liquidation_count": 1203, + "max_single_fill_usd": 40000.415219999995, + "unique_fill_coins": 165, + "uses_twap": False, + "twap_fill_ratio": 0.0, + "uses_cloid": True, + "cloid_ratio": 1.0, + "uses_priority_gas": False, + "total_priority_gas_paid": 0.0, + "total_builder_fees_paid": 0.0, +} + +CLASSIFY: dict[str, Any] = { + "wallets": [ + { + "address": "0x4e60e3a4a32d63d245aa4e4ce304b4254b088f95", + "metrics": METRICS, + "period": "24h", + } + ], + "total": 6434, + "date": "2026-09-28", +} + + +def test_classify_sends_every_filter_and_parses_the_page() -> None: + client, api = mock_client(lambda path, q: envelope(CLASSIFY)) + + result = client.hyperliquid.wallets.classify( + min_orders=1000, + min_volume_usd=1_000_000, + sort="total_volume_usd", + order="asc", + limit=50, + offset=100, + uses_twap=True, + uses_priority_gas=False, + min_cancel_rate=0.1, + max_cancel_rate=0.9, + date=date(2026, 9, 28), + ) + + assert api.calls == [ + ( + "/v1/hyperliquid/wallets/classify", + { + "min_orders": "1000", + "min_volume_usd": "1000000", + "sort": "total_volume_usd", + "order": "asc", + "limit": "50", + "offset": "100", + "uses_twap": "true", + "uses_priority_gas": "false", + "min_cancel_rate": "0.1", + "max_cancel_rate": "0.9", + "date": "2026-09-28", + }, + ) + ] + assert isinstance(result, WalletClassification) + assert result.total == 6434 and result.date == "2026-09-28" + wallet = result.wallets[0] + assert isinstance(wallet, ClassifiedWallet) and wallet.period == "24h" + assert wallet.metrics.total_orders == 99693611 + assert wallet.metrics.uses_cloid is True + assert wallet.metrics.top_builder is None + assert wallet.metrics.total_fees_usd == pytest.approx(-1855.2356810011863) + + +def test_hip3_classify_path_and_no_filters_by_default() -> None: + client, api = mock_client(lambda path, q: envelope(CLASSIFY)) + + result = asyncio.run(client.hyperliquid.hip3.wallets.aclassify()) + + assert api.calls == [("/v1/hyperliquid/hip3/wallets/classify", {})] + assert result.wallets[0].address.startswith("0x4e60") + + +@pytest.mark.parametrize( + "value, sent", + [ + ("2026-09-28", "2026-09-28"), + (date(2026, 9, 28), "2026-09-28"), + (datetime(2026, 9, 28, 23, 30), "2026-09-28"), # naive: UTC + # 01:30 on the 29th at UTC+3 is 22:30 on the 28th in UTC + (datetime(2026, 9, 29, 1, 30, tzinfo=timezone(timedelta(hours=3))), "2026-09-28"), + ], +) +def test_classify_date_is_a_utc_calendar_date(value: Any, sent: str) -> None: + client, api = mock_client(lambda path, q: envelope(CLASSIFY)) + + client.hyperliquid.wallets.classify(date=value) + + assert api.calls == [("/v1/hyperliquid/wallets/classify", {"date": sent})] + + +# --------------------------------------------------------------------------- +# Symbol universe +# --------------------------------------------------------------------------- + +SYMBOLS: dict[str, Any] = { + "symbols": [ + { + "symbol": "BTC", + "exchange": "hyperliquid", + "coverage_from": "2023-04-15T00:00:00Z", + "data_types": ["l2_orderbook", "trades", "liquidations"], + "coverage_by_type": {"trades": "2023-04-15T00:00:00Z"}, + "size_per_day": {"trades": 120.5}, + }, + { + "symbol": "#0", + "exchange": "hip4", + "coverage_from": "2026-05-02T00:00:00Z", + "coverage_to": "2026-05-03T06:00:05Z", + "data_types": ["l2_orderbook", "trades"], + "coverage_by_type": {"trades": "2026-05-02T00:00:00Z"}, + "size_per_day": {"trades": 3.93}, + "slug": "btc-above-78213-yes-may-03-0600", + "outcome_pair": ["#0", "#1"], + "display_title": "BTC above 78,213 on May 3 at 06:00 UTC? Yes", + "is_settled": True, + }, + ] +} + + +def test_symbols_list_reads_the_unwrapped_response() -> None: + client, api = mock_client(lambda path, q: SYMBOLS) + + symbols = client.symbols.list() + asymbols = asyncio.run(client.symbols.alist()) + + assert api.calls == [("/v1/symbols", {})] * 2 + assert symbols == asymbols + btc, outcome = symbols + assert isinstance(btc, SymbolEntry) + assert btc.exchange == "hyperliquid" and btc.coverage_to is None and btc.slug is None + assert btc.coverage_from == _utc("2023-04-15T00:00:00Z") + assert btc.coverage_by_type["trades"] == _utc("2023-04-15T00:00:00Z") + assert btc.size_per_day == {"trades": 120.5} + assert outcome.outcome_pair == ["#0", "#1"] and outcome.is_settled is True + assert outcome.coverage_to == _utc("2026-05-03T06:00:05Z") + + +# --------------------------------------------------------------------------- +# Order flow forwards the cursor on every venue, sync and async +# --------------------------------------------------------------------------- + +FLOW_CASES: list[tuple[str, Callable[[Any], Any], str]] = [ + ("core", lambda c: c.hyperliquid.orders, "/v1/hyperliquid/orders/BTC/flow"), + ("hip3", lambda c: c.hyperliquid.hip3.orders, "/v1/hyperliquid/hip3/orders/BTC/flow"), + ("hip4", lambda c: c.hyperliquid.hip4.orders, "/v1/hyperliquid/hip4/orders/BTC/flow"), +] + + +@pytest.mark.parametrize("name, resource, path", FLOW_CASES, ids=[c[0] for c in FLOW_CASES]) +def test_order_flow_forwards_the_cursor( + name: str, resource: Callable[[Any], Any], path: str +) -> None: + client, api = mock_client( + lambda p, q: envelope([{"timestamp": T_START}], next_cursor=str(T_START + 60000)) + ) + orders = resource(client) + + page = orders.flow("BTC", start=T_START, end=T_END, interval="1m", cursor="1790640060000") + apage = asyncio.run( + orders.aflow("BTC", start=T_START, end=T_END, interval="1m", cursor="1790640060000") + ) + + expected = { + "start": str(T_START), + "end": str(T_END), + "interval": "1m", + "cursor": "1790640060000", + } + assert api.calls == [(path, expected), (path, expected)] + assert page.next_cursor == apage.next_cursor == str(T_START + 60000) + + +def test_hip4_order_flow_flat_helpers_forward_the_cursor() -> None: + client, api = mock_client(lambda p, q: envelope([])) + + client.hyperliquid.hip4.get_order_flow("#0", start=T_START, end=T_END, cursor="c1") + asyncio.run(client.hyperliquid.hip4.aget_order_flow("0", start=T_START, end=T_END, cursor="c1")) + + expected = {"start": str(T_START), "end": str(T_END), "cursor": "c1"} + assert api.calls == [("/v1/hyperliquid/hip4/orders/0/flow", expected)] * 2 + + + +# --------------------------------------------------------------------------- +# Hyperliquid core breadth +# --------------------------------------------------------------------------- + +CORE_BREADTH: dict[str, Any] = { + "session_date": "2026-09-29", + "calculated_at": "2026-09-29T03:09:00Z", + "value_pct": 11.3636, + "coverage_ratio": 0.7521367521367521, + "counts": { + "candidates": 234, + "eligible": 176, + "above": 20, + "at": 0, + "below": 156, + "excluded_no_session_volume": 56, + "excluded_stale_price": 2, + }, + "namespaces": {"eligible": {}, "above": {}, "at": {}, "below": {}}, +} + + +def test_core_breadth_current_and_history_paths() -> None: + client, api = mock_client( + lambda path, q: envelope(CORE_BREADTH) + if path.endswith("/current") + else envelope([CORE_BREADTH], next_cursor="1787529600000") + ) + + current = client.hyperliquid.breadth.current() + acurrent = asyncio.run(client.hyperliquid.breadth.acurrent()) + history = client.hyperliquid.breadth.history( + start=T_START, end=T_END, interval="1h", cursor="1787529600000", limit=10 + ) + ahistory = asyncio.run(client.hyperliquid.breadth.ahistory(start=T_START)) + + query = { + "start": str(T_START), + "end": str(T_END), + "interval": "1h", + "cursor": "1787529600000", + "limit": "10", + } + assert api.calls == [ + ("/v1/hyperliquid/breadth/above-vwap/current", {}), + ("/v1/hyperliquid/breadth/above-vwap/current", {}), + ("/v1/hyperliquid/breadth/above-vwap", query), + ("/v1/hyperliquid/breadth/above-vwap", {"start": str(T_START)}), + ] + assert current == acurrent + assert current.value_pct == 11.3636 and current.counts.eligible == 176 + assert current.session_date == date(2026, 9, 29) + assert history.next_cursor == ahistory.next_cursor == "1787529600000" + assert history.data[0].counts.above == 20 + + +def test_core_breadth_validation_names_the_venue() -> None: + client, api = mock_client(lambda path, q: envelope([])) + + with pytest.raises(ValueError, match="for Hyperliquid core breadth"): + client.hyperliquid.breadth.history(interval="2h") # type: ignore[arg-type] + with pytest.raises(ValueError, match="for Hyperliquid core breadth"): + client.hyperliquid.breadth.history(limit=1001) + with pytest.raises(ValueError, match="for HIP-3 breadth"): + client.hyperliquid.hip3.breadth.history(limit=0) + assert api.requests == [] + + +def test_hip4_outcomes_settlement_filter_is_sent_as_is_settled() -> None: + client, api = mock_client(lambda path, q: envelope([])) + + client.hyperliquid.hip4.outcomes.list(is_settled=False, limit=5) + client.hyperliquid.hip4.list_outcomes(is_settled=True) + asyncio.run(client.hyperliquid.hip4.alist_outcomes(is_settled=False)) + + assert api.calls == [ + ("/v1/hyperliquid/hip4/outcomes", {"is_settled": "false", "limit": "5"}), + ("/v1/hyperliquid/hip4/outcomes", {"is_settled": "true"}), + ("/v1/hyperliquid/hip4/outcomes", {"is_settled": "false"}), + ] diff --git a/tests/test_webhook_signature.py b/tests/test_webhook_signature.py new file mode 100644 index 0000000..44ceda6 --- /dev/null +++ b/tests/test_webhook_signature.py @@ -0,0 +1,367 @@ +"""Webhook signature verification. + +Deliveries are signed ``HMAC-SHA256(secret, ".")`` and sent as +``0xa-signature: t=,v1=``, with a second ``v1=`` under the +previous secret during a rotation. These tests sign exactly that way. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from typing import Any + +import pytest + +import oxarchive.webhook_signature as webhook_signature +from oxarchive import ( + DEFAULT_TOLERANCE_SECONDS, + EVENT_ID_HEADER, + EVENT_TYPE_HEADER, + SIGNATURE_HEADER, + WebhookEvent, + WebhookSignatureError, + WebhookVerifier, + parse_signature_header, + verify_webhook, + verify_webhook_signature, +) + +NOW = 1_790_650_000 +NEW_SECRET = "whsec_" + "1f" * 32 +OLD_SECRET = "whsec_" + "a0" * 32 +EVENT_ID = "7d2e4b10-1a2b-4c3d-8e9f-0a1b2c3d4e5f" + +# The exact bytes on the wire. Key order and spacing are deliberately not what +# json.dumps would produce, as a real delivery's need not be. +BODY = ( + b'{"id": "7d2e4b10-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "data": {"symbol": "BTC",' + b' "notional_usd": 312000.5}, "type": "market.liquidation",' + b' "observed_at": "2026-09-29T02:00:00.500Z", "schema_version": 1}' +) + + +def sign(secret: str, body: bytes, t: int = NOW) -> str: + return hmac.new(secret.encode("utf-8"), f"{t}.".encode() + body, hashlib.sha256).hexdigest() + + +def headers(*secrets: str, t: int = NOW, body: bytes = BODY) -> dict[str, str]: + signatures = ",".join(f"v1={sign(s, body, t)}" for s in secrets) + return { + SIGNATURE_HEADER: f"t={t},{signatures}", + EVENT_ID_HEADER: EVENT_ID, + EVENT_TYPE_HEADER: "market.liquidation", + "content-type": "application/json", + } + + +# --------------------------------------------------------------------------- +# Valid deliveries +# --------------------------------------------------------------------------- + + +def test_a_valid_delivery_verifies_and_returns_the_event() -> None: + event = WebhookVerifier(NEW_SECRET).verify(BODY, headers(NEW_SECRET), now=NOW) + + assert isinstance(event, WebhookEvent) + assert event.id == EVENT_ID + assert event.type == "market.liquidation" + assert event.body == BODY + assert event.payload["data"]["notional_usd"] == 312000.5 + assert event.signature.timestamp == str(NOW) + assert event.signature.timestamp_seconds == NOW + assert len(event.signature.signatures) == 1 + + +def test_the_functional_entry_points_agree_with_the_verifier() -> None: + hdrs = headers(NEW_SECRET) + + event = verify_webhook(BODY, hdrs, NEW_SECRET, now=NOW) + parsed = verify_webhook_signature(BODY, hdrs[SIGNATURE_HEADER], NEW_SECRET, now=NOW) + + assert event.id == EVENT_ID + assert parsed.timestamp_seconds == NOW + + +def test_header_names_are_matched_case_insensitively() -> None: + hdrs = {k.upper(): v for k, v in headers(NEW_SECRET).items()} + + event = WebhookVerifier(NEW_SECRET).verify(BODY, hdrs, now=NOW) + + assert event.id == EVENT_ID + + +def test_bytearray_memoryview_and_utf8_text_bodies_verify() -> None: + verifier = WebhookVerifier(NEW_SECRET) + hdrs = headers(NEW_SECRET) + + assert verifier.is_valid(bytearray(BODY), hdrs, now=NOW) + assert verifier.is_valid(memoryview(BODY), hdrs, now=NOW) + assert verifier.is_valid(BODY.decode("utf-8"), hdrs, now=NOW) + + +def test_uppercase_hex_in_the_header_is_accepted() -> None: + hdrs = headers(NEW_SECRET) + hdrs[SIGNATURE_HEADER] = hdrs[SIGNATURE_HEADER].upper().replace("T=", "t=").replace( + "V1=", "v1=" + ) + + assert WebhookVerifier(NEW_SECRET).is_valid(BODY, hdrs, now=NOW) + + +def test_the_secret_prefix_is_part_of_the_key() -> None: + stripped = NEW_SECRET[len("whsec_") :] + + assert not WebhookVerifier(stripped).is_valid(BODY, headers(NEW_SECRET), now=NOW) + + +# --------------------------------------------------------------------------- +# Tampering +# --------------------------------------------------------------------------- + + +def test_a_tampered_body_is_rejected() -> None: + tampered = BODY.replace(b"312000.5", b"912000.5") + + with pytest.raises(WebhookSignatureError) as caught: + WebhookVerifier(NEW_SECRET).verify(tampered, headers(NEW_SECRET), now=NOW) + + assert caught.value.reason == "signature_mismatch" + + +def test_a_tampered_timestamp_is_rejected() -> None: + hdrs = headers(NEW_SECRET) + hdrs[SIGNATURE_HEADER] = hdrs[SIGNATURE_HEADER].replace(f"t={NOW}", f"t={NOW + 1}") + + with pytest.raises(WebhookSignatureError) as caught: + WebhookVerifier(NEW_SECRET).verify(BODY, hdrs, now=NOW) + + assert caught.value.reason == "signature_mismatch" + + +def test_a_wrong_secret_is_rejected() -> None: + with pytest.raises(WebhookSignatureError) as caught: + WebhookVerifier(OLD_SECRET).verify(BODY, headers(NEW_SECRET), now=NOW) + + assert caught.value.reason == "signature_mismatch" + + +def test_a_reserialised_body_does_not_verify() -> None: + reserialised = json.dumps(json.loads(BODY), separators=(",", ":")).encode() + + assert reserialised != BODY + assert not WebhookVerifier(NEW_SECRET).is_valid(reserialised, headers(NEW_SECRET), now=NOW) + + +@pytest.mark.parametrize( + "signature", + [ + "zz" * 32, # not hex + "ab" * 31, # too short + "ab" * 33, # too long + "é" * 64, # non-ASCII: must be a rejection, not a TypeError + ], +) +def test_malformed_signatures_are_rejections_not_crashes(signature: str) -> None: + hdrs = headers(NEW_SECRET) + hdrs[SIGNATURE_HEADER] = f"t={NOW},v1={signature}" + + with pytest.raises(WebhookSignatureError) as caught: + WebhookVerifier(NEW_SECRET).verify(BODY, hdrs, now=NOW) + + assert caught.value.reason == "signature_mismatch" + + +# --------------------------------------------------------------------------- +# Rotation: two v1 signatures +# --------------------------------------------------------------------------- + + +def test_during_a_rotation_a_receiver_holding_either_secret_verifies() -> None: + hdrs = headers(NEW_SECRET, OLD_SECRET) + + assert WebhookVerifier(NEW_SECRET).is_valid(BODY, hdrs, now=NOW) + assert WebhookVerifier(OLD_SECRET).is_valid(BODY, hdrs, now=NOW) + assert WebhookVerifier([NEW_SECRET, OLD_SECRET]).is_valid(BODY, hdrs, now=NOW) + + +def test_every_v1_is_read_not_just_the_first() -> None: + parsed = parse_signature_header(headers(NEW_SECRET, OLD_SECRET)[SIGNATURE_HEADER]) + + assert parsed.signatures == (sign(NEW_SECRET, BODY), sign(OLD_SECRET, BODY)) + + +def test_the_old_secret_verifies_when_its_signature_comes_second() -> None: + # A receiver that only reads the first v1 fails here. + hdrs = headers(NEW_SECRET, OLD_SECRET) + + event = verify_webhook(BODY, hdrs, OLD_SECRET, now=NOW) + + assert event.signature.signatures[1] == sign(OLD_SECRET, BODY) + + +def test_a_rotation_roll_with_add_and_remove_secret() -> None: + verifier = WebhookVerifier(OLD_SECRET) + verifier.add_secret(NEW_SECRET) + + assert verifier.secrets == (NEW_SECRET, OLD_SECRET) + assert verifier.is_valid(BODY, headers(NEW_SECRET), now=NOW) + assert verifier.is_valid(BODY, headers(OLD_SECRET), now=NOW) + + verifier.remove_secret(OLD_SECRET) + + assert verifier.secrets == (NEW_SECRET,) + assert not verifier.is_valid(BODY, headers(OLD_SECRET), now=NOW) + with pytest.raises(ValueError): + verifier.remove_secret(NEW_SECRET) + + +def test_adding_a_secret_twice_keeps_one_copy() -> None: + verifier = WebhookVerifier(NEW_SECRET) + verifier.add_secret(NEW_SECRET) + + assert verifier.secrets == (NEW_SECRET,) + + +# --------------------------------------------------------------------------- +# Replay window +# --------------------------------------------------------------------------- + + +def test_the_default_replay_window_is_five_minutes() -> None: + assert DEFAULT_TOLERANCE_SECONDS == 300 + assert WebhookVerifier(NEW_SECRET).tolerance_seconds == 300 + + +def test_an_expired_delivery_is_rejected() -> None: + stale = headers(NEW_SECRET, t=NOW - 301) + + with pytest.raises(WebhookSignatureError) as caught: + WebhookVerifier(NEW_SECRET).verify(BODY, stale, now=NOW) + + assert caught.value.reason == "timestamp_out_of_tolerance" + + +def test_a_delivery_at_the_edge_of_the_window_verifies() -> None: + assert WebhookVerifier(NEW_SECRET).is_valid( + BODY, headers(NEW_SECRET, t=NOW - 300), now=NOW + ) + + +def test_a_timestamp_too_far_in_the_future_is_rejected() -> None: + with pytest.raises(WebhookSignatureError) as caught: + WebhookVerifier(NEW_SECRET).verify(BODY, headers(NEW_SECRET, t=NOW + 301), now=NOW) + + assert caught.value.reason == "timestamp_out_of_tolerance" + + +def test_the_window_is_checked_before_the_signature() -> None: + # An expired delivery with a valid signature is still rejected as expired. + stale = headers(NEW_SECRET, t=NOW - 3600) + + with pytest.raises(WebhookSignatureError) as caught: + verify_webhook(BODY, stale, NEW_SECRET, now=NOW) + + assert caught.value.reason == "timestamp_out_of_tolerance" + + +def test_a_custom_window_and_zero_to_disable_it() -> None: + old = headers(NEW_SECRET, t=NOW - 200) + + assert not WebhookVerifier(NEW_SECRET, tolerance_seconds=120).is_valid(BODY, old, now=NOW) + assert WebhookVerifier(NEW_SECRET, tolerance_seconds=0).is_valid( + BODY, headers(NEW_SECRET, t=NOW - 10**6), now=NOW + ) + + +def test_the_real_clock_is_used_when_now_is_not_given(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(webhook_signature.time, "time", lambda: float(NOW + 10)) + + assert WebhookVerifier(NEW_SECRET).is_valid(BODY, headers(NEW_SECRET)) + monkeypatch.setattr(webhook_signature.time, "time", lambda: float(NOW + 1000)) + assert not WebhookVerifier(NEW_SECRET).is_valid(BODY, headers(NEW_SECRET)) + + +# --------------------------------------------------------------------------- +# Constant-time comparison +# --------------------------------------------------------------------------- + + +def test_signatures_are_compared_with_compare_digest(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[Any, Any]] = [] + real = hmac.compare_digest + + def spy(a: Any, b: Any) -> bool: + calls.append((a, b)) + return real(a, b) + + monkeypatch.setattr(webhook_signature.hmac, "compare_digest", spy) + + assert WebhookVerifier([NEW_SECRET, OLD_SECRET]).is_valid( + BODY, headers(NEW_SECRET, OLD_SECRET), now=NOW + ) + # Two secrets by two signatures: every pair is compared, with no early exit. + assert len(calls) == 4 + + +# --------------------------------------------------------------------------- +# Malformed headers and bad configuration +# --------------------------------------------------------------------------- + + +def test_a_missing_signature_header_is_rejected() -> None: + hdrs = headers(NEW_SECRET) + del hdrs[SIGNATURE_HEADER] + + with pytest.raises(WebhookSignatureError) as caught: + WebhookVerifier(NEW_SECRET).verify(BODY, hdrs, now=NOW) + + assert caught.value.reason == "missing_header" + + +@pytest.mark.parametrize( + "header", + [ + f"v1={'ab' * 32}", # no timestamp + f"t={NOW}", # no signature + f"t=abc,v1={'ab' * 32}", # not an integer + f"t=١٢٣,v1={'ab' * 32}", # Unicode digits int() would accept + f"t=1_000,v1={'ab' * 32}", # underscores int() would accept + f"t={'9' * 5000},v1={'ab' * 32}", # longer than a 64-bit integer + ], +) +def test_malformed_headers_are_rejected(header: str) -> None: + hdrs = headers(NEW_SECRET) + hdrs[SIGNATURE_HEADER] = header + + with pytest.raises(WebhookSignatureError) as caught: + WebhookVerifier(NEW_SECRET).verify(BODY, hdrs, now=NOW) + + assert caught.value.reason == "malformed_header" + + +def test_a_verifier_needs_a_text_secret() -> None: + with pytest.raises(ValueError): + WebhookVerifier("") + with pytest.raises(ValueError): + WebhookVerifier([]) + with pytest.raises(TypeError, match="decode"): + WebhookVerifier(NEW_SECRET.encode()) + with pytest.raises(TypeError): + WebhookVerifier([NEW_SECRET, 7]) # type: ignore[list-item] + + +def test_the_functional_verifier_without_a_secret_is_a_rejection() -> None: + with pytest.raises(WebhookSignatureError) as caught: + verify_webhook_signature(BODY, headers(NEW_SECRET)[SIGNATURE_HEADER], [], now=NOW) + + assert caught.value.reason == "no_secrets" + + +def test_a_body_that_is_not_a_json_object_still_verifies_with_an_empty_payload() -> None: + body = b"[1, 2, 3]" + + event = verify_webhook(body, headers(NEW_SECRET, body=body), NEW_SECRET, now=NOW) + + assert event.payload == {} and event.body == body diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..f3920ac --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,996 @@ +"""Webhooks resource: every route's method, path, query, body and parsing. + +Nothing here reaches the network. Requests go through the real ``Client`` and +``HttpClient`` into an ``httpx.MockTransport``, and response bodies are shaped +like the API's JSON. +""" + +from __future__ import annotations + +import asyncio +import copy +import json +from datetime import date, datetime, timezone +from typing import Any, Callable, Optional + +import httpx +import pytest + +from oxarchive import ( + Client, + OxArchiveError, + WebhookDelivery, + WebhookDeliveryQueued, + WebhookDryRun, + WebhookEndpoint, + WebhookEndpointCreated, + WebhookEndpointSecret, + WebhookEstimate, + WebhookEventType, + WebhookLimits, + WebhookRedelivery, + WebhookSubscription, + WebhookSubscriptionCondition, + WebhookSubscriptionConfig, + WebhookSubscriptionResume, + WebhookSubscriptionResumeAll, + WebhookWatchedAddress, +) + +ENDPOINT_ID = "b9807e65-8952-4d32-a877-a45c8b54c5aa" +SUBSCRIPTION_ID = "f5064079-0163-41c6-b606-b04d17e25a04" +DELIVERY_ID = "3c1f0a52-8d6b-4f0e-9b1a-6f2c4d8e9a10" +EVENT_ID = "7d2e4b10-1a2b-4c3d-8e9f-0a1b2c3d4e5f" +ADDRESS_ID = "cebc5b17-a907-4f0a-bbf4-4de2d8c399a9" +WALLET = "0x6b9e773128f453f5c2c60935ee2de2cbc5390a24" +SECRET = "whsec_" + "ab" * 32 + +# --------------------------------------------------------------------------- +# Fixtures shaped like the API's JSON +# --------------------------------------------------------------------------- + +EVENT_TYPE: dict[str, Any] = { + "type": "market.liquidation", + "schema_version": 1, + "live": True, + "scope": "public", + "venues": ["hyperliquid", "hip3", "lighter"], + "filters": ["venue", "symbols"], + "params": { + "window_s": { + "type": "integer", + "unit": "s", + "default": 300, + "min": 60, + "max": 3600, + "description": "Rolling window.", + }, + "threshold_mode": {"type": "string", "default": "usd", "enum": ["usd", "pct_oi"]}, + }, + "metrics": { + "notional_usd": {"type": "number", "unit": "USD"}, + "side": {"type": "enum", "values": ["long", "short"]}, + }, + "cost_floor": {"metric": "notional_usd", "min": 100, "note": "scan floor"}, + "latency_class": "seconds", + "description": "A liquidation on a covered venue.", + "filters_example": {"venue": "hyperliquid", "symbols": ["BTC"]}, + "operators": { + "any": ["is_empty", "is_not_empty"], + "number": ["greater_than", "between"], + "text": ["equal", "contains"], + }, +} + +LIMITS: dict[str, Any] = { + "plan": "pro", + "plan_label": "Pro", + "included": True, + "preview_included": True, + "endpoints": {"used": 1, "limit": 4, "remaining": 3}, + "subscriptions": {"used": 9, "limit": 40, "remaining": 31}, + "watched_addresses": {"used": 2, "limit": 15, "remaining": 13}, + "deliveries_per_day": { + "used": 412, + "limit": 50000, + "remaining": 49588, + "unlimited": False, + "resets_at": "2026-09-30T00:00:00Z", + "resets_at_note": "The reset time is the budget's, not the pause's.", + }, + "paused_subscriptions": { + "count": 2, + "earliest_paused_at": "2026-09-29T01:15:00Z", + "reasons": ["deliveries_per_day_cap"], + "message": "2 rules are paused.", + }, +} + +ENDPOINT: dict[str, Any] = { + "id": ENDPOINT_ID, + "url": "https://example.com/hooks/0xarchive", + "description": "Desk alerts", + "status": "active", + "consecutive_failures": 0, + "created_at": "2026-09-21T02:02:04.460276Z", + "format": "json", +} + +CONFIG: dict[str, Any] = { + "addresses": [WALLET], + "conditions": [ + {"metric": "notional_usd", "op": "greater_than_or_equal", "value": 250000}, + {"metric": "taker", "op": "equal", "value": True}, + ], + "min_notional_usd": 250000.0, + "params": {"max_age_s": 3600}, +} + +SUBSCRIPTION: dict[str, Any] = { + "id": SUBSCRIPTION_ID, + "endpoint_id": ENDPOINT_ID, + "event_type": "account.fill", + "filters": CONFIG, + "enabled": True, + "created_at": "2026-09-21T02:04:52.698775Z", + "status": "active", + "paused_at": None, + "pause_reason": None, + "suppressed_count": 0, + "suppressed_first_at": None, + "suppressed_last_at": None, + "last_paused_at": None, + "last_resumed_at": None, + "last_pause_reason": None, + "last_suppressed_count": 0, + "last_suppressed_first_at": None, + "last_suppressed_last_at": None, +} + +PAUSED_SUBSCRIPTION: dict[str, Any] = { + **SUBSCRIPTION, + "status": "auto_paused", + "pause_message": "This rule was paused because the account reached its daily limit.", + "paused_at": "2026-09-29T01:15:00Z", + "pause_reason": "deliveries_per_day_cap", + "suppressed_count": 17, + "suppressed_first_at": "2026-09-29T01:15:02Z", + "suppressed_last_at": "2026-09-29T02:40:00Z", +} + +GAP: dict[str, Any] = { + "paused_at": "2026-09-29T01:15:00Z", + "resumed_at": "2026-09-29T03:00:00Z", + "replay_window": {"start": "2026-09-29T01:15:00Z", "end": "2026-09-29T03:00:00Z"}, + "reason": "deliveries_per_day_cap", + "pause_message": "Paused at the daily limit.", + "suppressed_count": 17, + "counted": True, + "suppressed_first_at": "2026-09-29T01:15:02Z", + "suppressed_last_at": "2026-09-29T02:40:00Z", + "note": "Re-read the window from the REST routes.", +} + +DELIVERY: dict[str, Any] = { + "id": DELIVERY_ID, + "event_id": EVENT_ID, + "event_type": "market.liquidation", + "state": "failed", + "attempts": 3, + "last_status_code": 500, + "last_error": "HTTP 500", + "last_latency_ms": 142, + "next_attempt_at": "2026-09-29T02:10:00Z", + "delivered_at": None, + "created_at": "2026-09-29T02:00:00Z", + "payload": { + "id": EVENT_ID, + "type": "market.liquidation", + "schema_version": 1, + "observed_at": "2026-09-29T02:00:00.500Z", + "data": {"symbol": "BTC", "notional_usd": 312000.5}, + }, +} + +REDELIVERY: dict[str, Any] = { + "delivery_id": DELIVERY_ID, + "event_id": EVENT_ID, + "event_type": "market.liquidation", + "state": "pending", + "attempts": 0, + "next_attempt_at": "2026-09-29T02:11:00Z", +} + +ADDRESS: dict[str, Any] = { + "id": ADDRESS_ID, + "address": WALLET, + "label": "Desk 1", + "created_at": "2026-09-21T02:04:12.377291Z", +} + +OCCURRENCE: dict[str, Any] = { + "observed_at_estimate": "2026-09-29T01:59:59.100Z", + "data": {"symbol": "BTC", "notional_usd": 612000.0, "side": "long"}, +} + +DRY_RUN: dict[str, Any] = { + "event_type": "market.liquidation", + "window": {"from": "2026-09-28T02:00:00Z", "to": "2026-09-29T02:00:00Z"}, + "matched": 37, + "truncated": True, + "occurrences": [OCCURRENCE], +} + +ESTIMATE: dict[str, Any] = { + "event_type": "market.liquidation", + "window": {"from": "2026-09-22T02:00:00Z", "to": "2026-09-29T02:00:00Z"}, + "days": 7, + "total": 84, + "per_day": [{"date": "2026-09-23", "count": 12}, {"date": "2026-09-24", "count": 9}], + "per_day_p50": 11, + "per_day_max": 26, + "primary_metric": "notional_usd", + "ladder": [{"value": 250000, "per_day": 3.4}, {"value": 500000, "per_day": 1.1}], + "distribution": {"n": 842, "p50": 18400, "p90": 132000, "p99": 910000, "max": 4210000}, + "sample": [OCCURRENCE], + "basis": {"mode": "sampled", "note": "Scaled from a capped scan."}, +} + + +# --------------------------------------------------------------------------- +# A mocked API that answers by (method, path) +# --------------------------------------------------------------------------- + + +class Recorded: + def __init__(self, request: httpx.Request) -> None: + self.method = request.method + self.path = request.url.path + self.raw_path = request.url.raw_path.decode().split("?", 1)[0] + self.query = dict(request.url.params) + self.body: Optional[Any] = json.loads(request.content) if request.content else None + + +def webhook_client( + answer: Any, status: int = 200 +) -> tuple[Client, list[Recorded]]: + """A client whose every request is answered with ``answer`` and recorded.""" + seen: list[Recorded] = [] + + def handle(request: httpx.Request) -> httpx.Response: + seen.append(Recorded(request)) + return httpx.Response(status, json=copy.deepcopy(answer)) + + client = Client(api_key="0xa_test", base_url="https://api.example.test") + transport = httpx.MockTransport(handle) + http = client._http + http._client = httpx.Client( + base_url=http.base_url, headers=http._get_headers(), transport=transport + ) + http._async_client = httpx.AsyncClient( + base_url=http.base_url, headers=http._get_headers(), transport=transport + ) + return client, seen + + +def ok(data: Any, **extra: Any) -> dict[str, Any]: + return {"success": True, "data": data, **extra} + + +def _utc(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) + + +# --------------------------------------------------------------------------- +# Every operation, sync and async: method, path, query, body +# --------------------------------------------------------------------------- + +Call = Callable[[Client], Any] + +ROUTES: list[tuple[str, Call, Call, str, str, dict[str, str], Optional[dict[str, Any]], Any]] = [ + ( + "event_types", + lambda c: c.webhooks.event_types(), + lambda c: c.webhooks.aevent_types(), + "GET", + "/v1/webhooks/event-types", + {}, + None, + ok([EVENT_TYPE]), + ), + ( + "limits", + lambda c: c.webhooks.limits(), + lambda c: c.webhooks.alimits(), + "GET", + "/v1/webhooks/limits", + {}, + None, + ok(LIMITS), + ), + ( + "list_endpoints", + lambda c: c.webhooks.list_endpoints(), + lambda c: c.webhooks.alist_endpoints(), + "GET", + "/v1/webhooks/endpoints", + {}, + None, + ok([ENDPOINT]), + ), + ( + "create_endpoint", + lambda c: c.webhooks.create_endpoint(ENDPOINT["url"], description="Desk alerts"), + lambda c: c.webhooks.acreate_endpoint(ENDPOINT["url"], description="Desk alerts"), + "POST", + "/v1/webhooks/endpoints", + {}, + {"url": ENDPOINT["url"], "description": "Desk alerts"}, + ok({**ENDPOINT, "secret": SECRET}, note="Store the secret now; it is not shown again."), + ), + ( + "delete_endpoint", + lambda c: c.webhooks.delete_endpoint(ENDPOINT_ID), + lambda c: c.webhooks.adelete_endpoint(ENDPOINT_ID), + "DELETE", + f"/v1/webhooks/endpoints/{ENDPOINT_ID}", + {}, + None, + {"success": True}, + ), + ( + "enable_endpoint", + lambda c: c.webhooks.enable_endpoint(ENDPOINT_ID), + lambda c: c.webhooks.aenable_endpoint(ENDPOINT_ID), + "POST", + f"/v1/webhooks/endpoints/{ENDPOINT_ID}/enable", + {}, + None, + {"success": True}, + ), + ( + "rotate_secret", + lambda c: c.webhooks.rotate_secret(ENDPOINT_ID), + lambda c: c.webhooks.arotate_secret(ENDPOINT_ID), + "POST", + f"/v1/webhooks/endpoints/{ENDPOINT_ID}/rotate", + {}, + None, + ok({"secret": SECRET}, note="Previous secret remains valid for 24 hours."), + ), + ( + "test_endpoint", + lambda c: c.webhooks.test_endpoint(ENDPOINT_ID), + lambda c: c.webhooks.atest_endpoint(ENDPOINT_ID), + "POST", + f"/v1/webhooks/endpoints/{ENDPOINT_ID}/test", + {}, + None, + ok({"delivery_id": DELIVERY_ID, "event_id": EVENT_ID}), + ), + ( + "list_deliveries", + lambda c: c.webhooks.list_deliveries(ENDPOINT_ID, limit=200), + lambda c: c.webhooks.alist_deliveries(ENDPOINT_ID, limit=200), + "GET", + f"/v1/webhooks/endpoints/{ENDPOINT_ID}/deliveries", + {"limit": "200"}, + None, + ok([DELIVERY]), + ), + ( + "redeliver", + lambda c: c.webhooks.redeliver(DELIVERY_ID), + lambda c: c.webhooks.aredeliver(DELIVERY_ID), + "POST", + f"/v1/webhooks/deliveries/{DELIVERY_ID}/redeliver", + {}, + None, + ok(REDELIVERY, note="The record is reset in place."), + ), + ( + "list_subscriptions", + lambda c: c.webhooks.list_subscriptions(), + lambda c: c.webhooks.alist_subscriptions(), + "GET", + "/v1/webhooks/subscriptions", + {}, + None, + ok([SUBSCRIPTION, PAUSED_SUBSCRIPTION]), + ), + ( + "create_subscription", + lambda c: c.webhooks.create_subscription(ENDPOINT_ID, "account.fill", CONFIG), + lambda c: c.webhooks.acreate_subscription(ENDPOINT_ID, "account.fill", CONFIG), + "POST", + "/v1/webhooks/subscriptions", + {}, + {"endpoint_id": ENDPOINT_ID, "event_type": "account.fill", "filters": CONFIG}, + ok(SUBSCRIPTION), + ), + ( + "update_subscription", + lambda c: c.webhooks.update_subscription(SUBSCRIPTION_ID, config=CONFIG, enabled=False), + lambda c: c.webhooks.aupdate_subscription( + SUBSCRIPTION_ID, config=CONFIG, enabled=False + ), + "PATCH", + f"/v1/webhooks/subscriptions/{SUBSCRIPTION_ID}", + {}, + {"filters": CONFIG, "enabled": False}, + ok({**SUBSCRIPTION, "enabled": False}), + ), + ( + "delete_subscription", + lambda c: c.webhooks.delete_subscription(SUBSCRIPTION_ID), + lambda c: c.webhooks.adelete_subscription(SUBSCRIPTION_ID), + "DELETE", + f"/v1/webhooks/subscriptions/{SUBSCRIPTION_ID}", + {}, + None, + {"success": True}, + ), + ( + "resume_subscription", + lambda c: c.webhooks.resume_subscription(SUBSCRIPTION_ID), + lambda c: c.webhooks.aresume_subscription(SUBSCRIPTION_ID), + "POST", + f"/v1/webhooks/subscriptions/{SUBSCRIPTION_ID}/resume", + {}, + None, + ok(SUBSCRIPTION, gap=GAP), + ), + ( + "resume_all_subscriptions", + lambda c: c.webhooks.resume_all_subscriptions(), + lambda c: c.webhooks.aresume_all_subscriptions(), + "POST", + "/v1/webhooks/subscriptions/resume", + {}, + None, + ok([SUBSCRIPTION], resumed_count=1, gap=GAP), + ), + ( + "dry_run", + lambda c: c.webhooks.dry_run( + "market.liquidation", {"venue": "hyperliquid"}, lookback_s=86400, limit=5 + ), + lambda c: c.webhooks.adry_run( + "market.liquidation", {"venue": "hyperliquid"}, lookback_s=86400, limit=5 + ), + "POST", + "/v1/webhooks/subscriptions/dry-run", + {}, + { + "event_type": "market.liquidation", + "config": {"venue": "hyperliquid"}, + "lookback_s": 86400, + "limit": 5, + }, + ok(DRY_RUN), + ), + ( + "estimate", + lambda c: c.webhooks.estimate( + "market.liquidation", {"min_notional_usd": 250000}, lookback_days=7 + ), + lambda c: c.webhooks.aestimate( + "market.liquidation", {"min_notional_usd": 250000}, lookback_days=7 + ), + "POST", + "/v1/webhooks/subscriptions/estimate", + {}, + { + "event_type": "market.liquidation", + "config": {"min_notional_usd": 250000}, + "lookback_days": 7, + }, + ok(ESTIMATE), + ), + ( + "list_addresses", + lambda c: c.webhooks.list_addresses(), + lambda c: c.webhooks.alist_addresses(), + "GET", + "/v1/webhooks/addresses", + {}, + None, + ok([ADDRESS], limit=15), + ), + ( + "add_address", + lambda c: c.webhooks.add_address(WALLET, label="Desk 1"), + lambda c: c.webhooks.aadd_address(WALLET, label="Desk 1"), + "POST", + "/v1/webhooks/addresses", + {}, + {"address": WALLET, "label": "Desk 1"}, + ok(ADDRESS, limit=15), + ), + ( + "delete_address", + lambda c: c.webhooks.delete_address(ADDRESS_ID), + lambda c: c.webhooks.adelete_address(ADDRESS_ID), + "DELETE", + f"/v1/webhooks/addresses/{ADDRESS_ID}", + {}, + None, + {"success": True}, + ), +] + + +def test_every_spec_operation_has_a_route_here() -> None: + assert len(ROUTES) == 21 + assert len({(method, path) for _, _, _, method, path, _, _, _ in ROUTES}) == 21 + + +@pytest.mark.parametrize( + "name, sync_call, _async_call, method, path, query, body, answer", + ROUTES, + ids=[r[0] for r in ROUTES], +) +def test_sync_request_shape( + name: str, + sync_call: Call, + _async_call: Call, + method: str, + path: str, + query: dict[str, str], + body: Optional[dict[str, Any]], + answer: Any, +) -> None: + client, seen = webhook_client(answer) + + sync_call(client) + + assert len(seen) == 1 + assert (seen[0].method, seen[0].path, seen[0].query) == (method, path, query) + assert seen[0].body == body + + +@pytest.mark.parametrize( + "name, sync_call, async_call, method, path, query, body, answer", + ROUTES, + ids=[r[0] for r in ROUTES], +) +def test_async_matches_sync( + name: str, + sync_call: Call, + async_call: Call, + method: str, + path: str, + query: dict[str, str], + body: Optional[dict[str, Any]], + answer: Any, +) -> None: + client, seen = webhook_client(answer) + + sync_result = sync_call(client) + async_result = asyncio.run(async_call(client)) + + assert len(seen) == 2 + assert (seen[1].method, seen[1].path, seen[1].query, seen[1].body) == ( + method, + path, + query, + body, + ) + assert async_result == sync_result + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def test_event_types_parse_params_metrics_and_cost_floor() -> None: + client, _ = webhook_client(ok([EVENT_TYPE])) + + [event_type] = client.webhooks.event_types() + + assert isinstance(event_type, WebhookEventType) + assert event_type.type == "market.liquidation" and event_type.live is True + assert event_type.params["window_s"].default == 300 + assert event_type.params["window_s"].max == 3600 + assert event_type.params["threshold_mode"].enum == ["usd", "pct_oi"] + assert event_type.metrics["notional_usd"].unit == "USD" + assert event_type.metrics["side"].values == ["long", "short"] + assert event_type.cost_floor is not None + assert (event_type.cost_floor.metric, event_type.cost_floor.min) == ("notional_usd", 100) + assert event_type.cost_floor.model_extra == {"note": "scan floor"} + assert event_type.operators["text"] == ["equal", "contains"] + + +def test_event_type_without_a_cost_floor() -> None: + client, _ = webhook_client(ok([{**EVENT_TYPE, "cost_floor": None}])) + + assert client.webhooks.event_types()[0].cost_floor is None + + +def test_limits_parse_caps_budget_and_paused_count() -> None: + client, _ = webhook_client(ok(LIMITS)) + + limits = client.webhooks.limits() + + assert isinstance(limits, WebhookLimits) + assert limits.plan == "pro" and limits.included and limits.preview_included + assert (limits.endpoints.used, limits.endpoints.limit, limits.endpoints.remaining) == (1, 4, 3) + assert limits.deliveries_per_day.remaining == 49588 + assert limits.deliveries_per_day.resets_at == _utc("2026-09-30T00:00:00Z") + assert limits.paused_subscriptions.count == 2 + assert limits.paused_subscriptions.reasons == ["deliveries_per_day_cap"] + assert limits.notice is None + + +def test_limits_on_a_plan_without_delivery_and_without_a_daily_ceiling() -> None: + unlimited = copy.deepcopy(LIMITS) + unlimited["deliveries_per_day"].update(limit=None, remaining=None, unlimited=True) + unlimited["paused_subscriptions"] = {"count": 0, "earliest_paused_at": None, "reasons": []} + unlimited["notice"] = "Webhook delivery is not included on the Free plan." + client, _ = webhook_client(ok(unlimited)) + + limits = client.webhooks.limits() + + assert limits.deliveries_per_day.limit is None + assert limits.deliveries_per_day.unlimited is True + assert limits.paused_subscriptions.earliest_paused_at is None + assert limits.notice is not None + + +def test_endpoints_keep_unknown_fields_and_never_carry_a_secret() -> None: + client, _ = webhook_client(ok([ENDPOINT])) + + [endpoint] = client.webhooks.list_endpoints() + + assert isinstance(endpoint, WebhookEndpoint) + assert not isinstance(endpoint, WebhookEndpointCreated) + assert endpoint.created_at == _utc("2026-09-21T02:02:04.460276Z") + assert endpoint.model_extra == {"format": "json"} + assert not hasattr(endpoint, "secret") + + +def test_create_endpoint_returns_the_secret_and_the_note() -> None: + client, _ = webhook_client( + ok({**ENDPOINT, "secret": SECRET}, note="Store the secret now; it is not shown again.") + ) + + created = client.webhooks.create_endpoint("https://example.com/hooks/0xarchive") + + assert isinstance(created, WebhookEndpointCreated) + assert isinstance(created, WebhookEndpoint) + assert created.secret == SECRET + assert created.note == "Store the secret now; it is not shown again." + + +def test_create_endpoint_sends_an_empty_description_by_default() -> None: + client, seen = webhook_client(ok({**ENDPOINT, "secret": SECRET})) + + client.webhooks.create_endpoint("https://example.com/hooks/0xarchive") + + assert seen[0].body == {"url": "https://example.com/hooks/0xarchive", "description": ""} + + +def test_rotate_secret_folds_the_envelope_note_into_the_model() -> None: + client, _ = webhook_client(ok({"secret": SECRET}, note="Previous secret remains valid.")) + + rotated = client.webhooks.rotate_secret(ENDPOINT_ID) + + assert isinstance(rotated, WebhookEndpointSecret) + assert rotated.secret == SECRET + assert rotated.note == "Previous secret remains valid." + + +def test_a_note_inside_data_is_not_overwritten_by_the_envelope_note() -> None: + client, _ = webhook_client(ok({"secret": SECRET, "note": "inner"}, note="outer")) + + assert client.webhooks.rotate_secret(ENDPOINT_ID).note == "inner" + + +def test_writes_that_return_an_ack_return_none() -> None: + client, _ = webhook_client({"success": True}) + + assert client.webhooks.delete_endpoint(ENDPOINT_ID) is None + assert client.webhooks.enable_endpoint(ENDPOINT_ID) is None + assert client.webhooks.delete_subscription(SUBSCRIPTION_ID) is None + assert client.webhooks.delete_address(ADDRESS_ID) is None + + +def test_test_endpoint_returns_the_queued_ids() -> None: + client, _ = webhook_client(ok({"delivery_id": DELIVERY_ID, "event_id": EVENT_ID})) + + queued = client.webhooks.test_endpoint(ENDPOINT_ID) + + assert isinstance(queued, WebhookDeliveryQueued) + assert (queued.delivery_id, queued.event_id) == (DELIVERY_ID, EVENT_ID) + + +def test_deliveries_parse_state_timing_and_payload() -> None: + client, seen = webhook_client(ok([DELIVERY])) + + [delivery] = client.webhooks.list_deliveries(ENDPOINT_ID) + + assert seen[0].query == {} + assert isinstance(delivery, WebhookDelivery) + assert delivery.state == "failed" and delivery.attempts == 3 + assert delivery.last_status_code == 500 and delivery.last_latency_ms == 142 + assert delivery.delivered_at is None + assert delivery.next_attempt_at == _utc("2026-09-29T02:10:00Z") + assert delivery.payload["data"]["notional_usd"] == 312000.5 + + +def test_redeliver_keeps_the_ids_and_the_note() -> None: + client, _ = webhook_client(ok(REDELIVERY, note="The record is reset in place.")) + + redelivery = client.webhooks.redeliver(DELIVERY_ID) + + assert isinstance(redelivery, WebhookRedelivery) + assert redelivery.event_id == EVENT_ID and redelivery.state == "pending" + assert redelivery.attempts == 0 + assert redelivery.note == "The record is reset in place." + + +def test_redeliver_to_a_switched_off_endpoint_raises_with_the_reason() -> None: + client, _ = webhook_client( + { + "code": 409, + "error": "This delivery's endpoint is disabled, so nothing would be sent.", + "request_id": "3f2a9c71-5b0e-4d68-9a4c-7e1d2b6f8a05", + }, + status=409, + ) + + with pytest.raises(OxArchiveError) as caught: + client.webhooks.redeliver(DELIVERY_ID) + + assert caught.value.code == 409 + assert "disabled" in caught.value.message + assert caught.value.request_id == "3f2a9c71-5b0e-4d68-9a4c-7e1d2b6f8a05" + + +def test_subscriptions_parse_config_and_pause_state() -> None: + client, _ = webhook_client(ok([SUBSCRIPTION, PAUSED_SUBSCRIPTION])) + + active, paused = client.webhooks.list_subscriptions() + + assert isinstance(active, WebhookSubscription) + assert active.status == "active" and active.pause_message is None + assert active.config is active.filters + assert isinstance(active.filters, WebhookSubscriptionConfig) + assert active.filters.addresses == [WALLET] + assert active.filters.params == {"max_age_s": 3600} + assert active.filters.min_notional_usd == 250000.0 + condition = active.filters.conditions[0] if active.filters.conditions else None + assert isinstance(condition, WebhookSubscriptionCondition) + assert (condition.metric, condition.op, condition.value) == ( + "notional_usd", + "greater_than_or_equal", + 250000, + ) + assert paused.status == "auto_paused" + assert paused.pause_reason == "deliveries_per_day_cap" + assert paused.pause_message is not None + assert paused.paused_at == _utc("2026-09-29T01:15:00Z") + assert paused.suppressed_count == 17 + + +def test_top_level_declared_params_in_a_stored_config_are_kept() -> None: + stored = {"venue": "hyperliquid", "threshold_mode": "pct_oi", "threshold_pct": 1} + client, _ = webhook_client(ok([{**SUBSCRIPTION, "filters": stored}])) + + [sub] = client.webhooks.list_subscriptions() + + assert sub.filters.venue == "hyperliquid" + assert sub.filters.model_extra == {"threshold_mode": "pct_oi", "threshold_pct": 1} + + +def test_create_subscription_without_config_sends_no_filters() -> None: + client, seen = webhook_client(ok(SUBSCRIPTION)) + + client.webhooks.create_subscription(ENDPOINT_ID, "market.listed") + + assert seen[0].body == {"endpoint_id": ENDPOINT_ID, "event_type": "market.listed"} + + +def test_a_config_model_is_sent_without_unset_fields_and_keeps_extras() -> None: + config = WebhookSubscriptionConfig( + venue=["hyperliquid", "hip3"], + conditions=[ + WebhookSubscriptionCondition(metric="notional_usd", op=">=", value=100000), + WebhookSubscriptionCondition(metric="liquidated_user", op="is_not_empty"), + ], + threshold_mode="usd", # type: ignore[call-arg] + ) + client, seen = webhook_client(ok(SUBSCRIPTION)) + + client.webhooks.create_subscription(ENDPOINT_ID, "market.liquidation", config) + + assert seen[0].body == { + "endpoint_id": ENDPOINT_ID, + "event_type": "market.liquidation", + "filters": { + "venue": ["hyperliquid", "hip3"], + "conditions": [ + {"metric": "notional_usd", "op": ">=", "value": 100000}, + {"metric": "liquidated_user", "op": "is_not_empty"}, + ], + "threshold_mode": "usd", + }, + } + + +def test_update_subscription_sends_only_the_fields_given() -> None: + client, seen = webhook_client(ok({**SUBSCRIPTION, "enabled": False})) + + updated = client.webhooks.update_subscription(SUBSCRIPTION_ID, enabled=False) + client.webhooks.update_subscription(SUBSCRIPTION_ID, config={"symbols": ["BTC"]}) + + assert updated.enabled is False + assert seen[0].body == {"enabled": False} + assert seen[1].body == {"filters": {"symbols": ["BTC"]}} + + +def test_update_subscription_with_nothing_to_change_is_refused_before_sending() -> None: + client, seen = webhook_client(ok(SUBSCRIPTION)) + + with pytest.raises(ValueError, match="config, enabled, or both"): + client.webhooks.update_subscription(SUBSCRIPTION_ID) + with pytest.raises(ValueError, match="config, enabled, or both"): + asyncio.run(client.webhooks.aupdate_subscription(SUBSCRIPTION_ID)) + + assert seen == [] + + +def test_resume_subscription_returns_the_rule_and_the_missed_window() -> None: + client, _ = webhook_client(ok(SUBSCRIPTION, gap=GAP)) + + result = client.webhooks.resume_subscription(SUBSCRIPTION_ID) + + assert isinstance(result, WebhookSubscriptionResume) + assert result.subscription.id == SUBSCRIPTION_ID + assert result.note is None + gap = result.gap + assert gap is not None and gap.counted is True + assert gap.suppressed_count == 17 + assert gap.replay_window.start == _utc("2026-09-29T01:15:00Z") + assert gap.replay_window.end == _utc("2026-09-29T03:00:00Z") + assert gap.reason == "deliveries_per_day_cap" + + +def test_resuming_a_serving_rule_changes_nothing() -> None: + client, _ = webhook_client( + ok(SUBSCRIPTION, gap=None, note="This subscription was already active, so nothing changed.") + ) + + result = client.webhooks.resume_subscription(SUBSCRIPTION_ID) + + assert result.gap is None + assert result.note is not None and "already active" in result.note + + +def test_resume_all_lists_the_rules_and_an_uncounted_gap() -> None: + gap = { + **GAP, + "reason": None, + "reasons": ["deliveries_per_day_cap", "plan_no_webhooks"], + "suppressed_count": None, + "counted": False, + "uncounted_subscriptions": 1, + } + client, _ = webhook_client(ok([SUBSCRIPTION, SUBSCRIPTION], resumed_count=2, gap=gap)) + + result = client.webhooks.resume_all_subscriptions() + + assert isinstance(result, WebhookSubscriptionResumeAll) + assert result.resumed_count == 2 and len(result.subscriptions) == 2 + assert result.gap is not None + assert result.gap.counted is False and result.gap.suppressed_count is None + assert result.gap.reasons == ["deliveries_per_day_cap", "plan_no_webhooks"] + assert result.gap.uncounted_subscriptions == 1 + + +def test_resume_all_with_nothing_paused() -> None: + client, _ = webhook_client( + ok([], resumed_count=0, gap=None, note="Nothing on this account is paused.") + ) + + result = client.webhooks.resume_all_subscriptions() + + assert result.subscriptions == [] and result.resumed_count == 0 + assert result.gap is None and result.note is not None + + +def test_resume_at_the_daily_limit_raises_409() -> None: + client, _ = webhook_client( + {"code": 409, "error": "You are still at today's delivery limit."}, status=409 + ) + + with pytest.raises(OxArchiveError) as caught: + client.webhooks.resume_all_subscriptions() + + assert caught.value.code == 409 + + +def test_dry_run_parses_the_window_and_occurrences() -> None: + client, seen = webhook_client(ok(DRY_RUN)) + + preview = client.webhooks.dry_run("market.liquidation") + + assert seen[0].body == {"event_type": "market.liquidation"} + assert isinstance(preview, WebhookDryRun) + assert preview.matched == 37 and preview.truncated is True + assert preview.window.from_ == _utc("2026-09-28T02:00:00Z") + assert preview.window.to == _utc("2026-09-29T02:00:00Z") + assert preview.occurrences[0].observed_at_estimate == _utc("2026-09-29T01:59:59.100Z") + assert preview.occurrences[0].data["side"] == "long" + + +def test_estimate_parses_days_ladder_distribution_and_basis() -> None: + client, seen = webhook_client(ok(ESTIMATE)) + + est = client.webhooks.estimate( + "market.liquidation", WebhookSubscriptionConfig(min_notional_usd=250000) + ) + + assert seen[0].body == { + "event_type": "market.liquidation", + "config": {"min_notional_usd": 250000.0}, + } + assert isinstance(est, WebhookEstimate) + assert est.days == 7 and est.total == 84 + assert est.per_day[0].date == date(2026, 9, 23) and est.per_day[0].count == 12 + assert est.per_day_p50 == 11 and est.per_day_max == 26 + assert [(r.value, r.per_day) for r in est.ladder] == [(250000, 3.4), (500000, 1.1)] + assert est.distribution is not None and est.distribution.p99 == 910000 + assert est.basis.mode == "sampled" and est.basis.note is not None + assert est.sample[0].data["notional_usd"] == 612000.0 + + +def test_estimate_without_a_primary_metric() -> None: + bare = {**ESTIMATE, "primary_metric": None, "ladder": [], "distribution": None} + client, _ = webhook_client(ok(bare)) + + est = client.webhooks.estimate("market.listed") + + assert est.primary_metric is None and est.ladder == [] and est.distribution is None + + +def test_watched_addresses_parse() -> None: + lister, _ = webhook_client(ok([ADDRESS], limit=15)) + adder, _ = webhook_client(ok(ADDRESS, limit=15)) + + [watched] = lister.webhooks.list_addresses() + added = adder.webhooks.add_address(WALLET) + + assert isinstance(watched, WebhookWatchedAddress) + assert (watched.id, watched.address, watched.label) == (ADDRESS_ID, WALLET, "Desk 1") + assert watched.created_at == _utc("2026-09-21T02:04:12.377291Z") + assert added.address == WALLET + + +def test_add_address_sends_an_empty_label_by_default() -> None: + client, seen = webhook_client(ok(ADDRESS)) + + client.webhooks.add_address(WALLET) + + assert seen[0].body == {"address": WALLET, "label": ""} + + +def test_identifiers_are_encoded_as_one_path_segment() -> None: + client, seen = webhook_client({"success": True}) + + client.webhooks.delete_endpoint("../subscriptions") + + assert seen[0].raw_path == "/v1/webhooks/endpoints/..%2Fsubscriptions" + + +def test_a_refused_plan_raises_with_the_server_message() -> None: + client, _ = webhook_client( + {"code": 400, "error": "Webhook delivery is not included on the Free plan."}, status=400 + ) + + with pytest.raises(OxArchiveError) as caught: + asyncio.run(client.webhooks.acreate_endpoint("https://example.com/hooks")) + + assert caught.value.code == 400 + assert "Free plan" in caught.value.message diff --git a/tests/test_websocket_full_depth.py b/tests/test_websocket_full_depth.py new file mode 100644 index 0000000..0667ba1 --- /dev/null +++ b/tests/test_websocket_full_depth.py @@ -0,0 +1,211 @@ +"""Full-depth L2 WebSocket channels: ``orderbook_full`` and ``hip3_orderbook_full``. + +The frames below were captured from the production WebSocket (books truncated +to two levels per side). +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, cast, get_args + +import pytest +from websockets.protocol import State as WsState + +from oxarchive.types import WsChannel, WsL4Batch, WsL4Snapshot, WsSubscribed +from oxarchive.websocket import ( + FULL_DEPTH_L2_CHANNELS, + FULL_DEPTH_LIVE_ONLY_ERROR, + OxArchiveWs, + WsOptions, +) + +SUBSCRIBED: dict[str, Any] = { + "type": "subscribed", + "channel": "orderbook_full", + "coin": "BTC", + "symbol": "BTC", +} + +SNAPSHOT: dict[str, Any] = { + "type": "l4_snapshot", + "channel": "orderbook_full", + "coin": "BTC", + "symbol": "BTC", + "last_block_number": 1164900082, + "timestamp": 1790649779818, + "data": { + "bids": [{"n": 31, "px": 82922.0, "sz": 9.1}, {"n": 4, "px": 82921.0, "sz": 0.52}], + "asks": [{"n": 104, "px": 82923.0, "sz": 18.14181}, {"n": 23, "px": 82924.0, "sz": 2.91}], + "bid_count": 7021, + "ask_count": 6929, + "total_bid_size": 1812.4, + "total_ask_size": 1633.9, + "mid_price": 82922.5, + "spread": 1.0, + "spread_bps": 0.12, + "is_crossed": False, + }, +} + +BATCH: dict[str, Any] = { + "type": "l4_batch", + "channel": "hip3_orderbook_full", + "coin": "xyz:XYZ100", + "symbol": "xyz:XYZ100", + "data": [ + {"bn": 1164900660, "n": 6, "px": 30202.0, "side": "A", "sz": 2.0656}, + {"bn": 1164900660, "n": 0, "px": 30204.0, "side": "A", "sz": 0.0}, + {"bn": 1164900660, "n": 2, "px": 30041.0, "side": "B", "sz": 0.7954}, + ], +} + + +class FakeSocket: + def __init__(self) -> None: + self.state = WsState.OPEN + self.sent: list[dict[str, Any]] = [] + + async def send(self, raw: str) -> None: + self.sent.append(json.loads(raw)) + + +def _connected_client() -> tuple[OxArchiveWs, FakeSocket]: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + socket = FakeSocket() + setattr(ws, "_ws", socket) + return ws, socket + + +def test_both_full_depth_channels_are_ws_channels() -> None: + channels = set(get_args(WsChannel)) + + assert {"orderbook_full", "hip3_orderbook_full"} <= channels + assert FULL_DEPTH_L2_CHANNELS == {"orderbook_full", "hip3_orderbook_full"} + + +@pytest.mark.parametrize("channel", sorted(FULL_DEPTH_L2_CHANNELS)) +def test_full_depth_channels_subscribe_live(channel: str) -> None: + channel = cast(WsChannel, channel) + ws, socket = _connected_client() + + asyncio.run(ws.subscribe_async(channel, "BTC")) + + assert ws._subscriptions == {f"{channel}:BTC"} + assert socket.sent == [{"op": "subscribe", "channel": channel, "symbol": "BTC"}] + + +def test_full_depth_helpers_send_subscribe_and_unsubscribe() -> None: + ws, socket = _connected_client() + + async def run() -> None: + ws.subscribe_orderbook_full("BTC") + ws.subscribe_hip3_orderbook_full("xyz:XYZ100") + await asyncio.sleep(0) + ws.unsubscribe_orderbook_full("BTC") + await asyncio.sleep(0) + + asyncio.run(run()) + + assert socket.sent == [ + {"op": "subscribe", "channel": "orderbook_full", "symbol": "BTC"}, + {"op": "subscribe", "channel": "hip3_orderbook_full", "symbol": "xyz:XYZ100"}, + {"op": "unsubscribe", "channel": "orderbook_full", "symbol": "BTC"}, + ] + assert ws._subscriptions == {"hip3_orderbook_full:xyz:XYZ100"} + + +def test_hip3_full_depth_unsubscribe_helper() -> None: + ws, socket = _connected_client() + + async def run() -> None: + ws.subscribe_hip3_orderbook_full("km:US500") + ws.unsubscribe_hip3_orderbook_full("km:US500") + await asyncio.sleep(0) + + asyncio.run(run()) + + assert socket.sent[-1] == { + "op": "unsubscribe", + "channel": "hip3_orderbook_full", + "symbol": "km:US500", + } + assert ws._subscriptions == set() + + +def test_full_depth_frames_are_typed_and_reach_the_l4_handlers() -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + messages: list[object] = [] + snapshots: list[tuple[str, str, dict[str, Any]]] = [] + batches: list[tuple[str, str, list[dict[str, Any]]]] = [] + ws.on_message(messages.append) + ws.on_l4_snapshot(lambda channel, coin, message: snapshots.append((channel, coin, message))) + ws.on_l4_batch(lambda channel, coin, records: batches.append((channel, coin, records))) + + for frame in (SUBSCRIBED, SNAPSHOT, BATCH): + ws._handle_message(json.dumps(frame)) + + ack, snapshot, batch = messages + assert isinstance(ack, WsSubscribed) and ack.channel == "orderbook_full" + assert isinstance(snapshot, WsL4Snapshot) and snapshot.channel == "orderbook_full" + assert snapshot.last_block_number == 1164900082 + assert snapshot.data["ask_count"] == 6929 + assert isinstance(batch, WsL4Batch) and batch.channel == "hip3_orderbook_full" + assert snapshots[0][0:2] == ("orderbook_full", "BTC") + assert snapshots[0][2]["data"]["bids"][0] == {"n": 31, "px": 82922.0, "sz": 9.1} + channel, coin, records = batches[0] + assert (channel, coin) == ("hip3_orderbook_full", "xyz:XYZ100") + assert [r["px"] for r in records] == [30202.0, 30204.0, 30041.0] + assert records[1]["sz"] == 0.0 # a removed level + + +def _offline_client() -> tuple[OxArchiveWs, list[dict[str, Any]]]: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + sent: list[dict[str, Any]] = [] + + async def fake_send(message: dict[str, Any]) -> None: + sent.append(message) + + setattr(ws, "_send", fake_send) + return ws, sent + + +@pytest.mark.parametrize("channel", sorted(FULL_DEPTH_L2_CHANNELS)) +def test_full_depth_channels_are_live_only_for_replay(channel: str) -> None: + channel = cast(WsChannel, channel) + ws, sent = _offline_client() + + with pytest.raises(ValueError) as caught: + asyncio.run(ws.replay(channel, "BTC", start=1_757_000_000_000, speed=10)) + + assert str(caught.value) == FULL_DEPTH_LIVE_ONLY_ERROR + assert "l2_orderbook.history()" in FULL_DEPTH_LIVE_ONLY_ERROR + assert sent == [] + + +@pytest.mark.parametrize("channel", sorted(FULL_DEPTH_L2_CHANNELS)) +def test_full_depth_channels_are_live_only_for_multi_replay(channel: str) -> None: + channel = cast(WsChannel, channel) + ws, sent = _offline_client() + + with pytest.raises(ValueError, match="live subscriptions only"): + asyncio.run(ws.multi_replay(["orderbook", channel], "BTC", start=1_757_000_000_000)) + + assert sent == [] + + +def test_the_top_of_book_orderbook_still_replays() -> None: + ws, sent = _offline_client() + + asyncio.run(ws.replay("orderbook", "BTC", start=1_757_000_000_000, speed=10)) + + assert sent == [ + { + "op": "replay", + "channel": "orderbook", + "symbol": "BTC", + "start": 1_757_000_000_000, + "speed": 10, + } + ]