From 454c19d542dd3afa3cf5d7d6b8ba776a0950d8b2 Mon Sep 17 00:00:00 2001 From: Path Dependable <104703139+nuttykenzo@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:39:02 +0000 Subject: [PATCH 1/4] fix: align Python SDK candle contract --- CHANGELOG.md | 2 +- README.md | 10 +++--- oxarchive/exchanges.py | 2 +- oxarchive/resources/candles.py | 9 ++--- oxarchive/resources/openinterest.py | 3 +- tests/test_coverage_contract.py | 53 +++++++++++++++++------------ 6 files changed, 46 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcee665..ea02be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added - HIP-4 candle history at `client.hyperliquid.hip4.candles.history()` and its async equivalent. -- **Hyperliquid Spot candle history.** Added `client.spot.candles.history()` and `ahistory()` for `/v1/hyperliquid/spot/candles/{symbol}`. Coverage starts at `2025-03-22T10:50:22Z`; supported intervals are `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, with opaque cursor pagination and a 1,000-row page cap. +- **Hyperliquid Spot candle history.** Added `client.spot.candles.history()` and `ahistory()` for `/v1/hyperliquid/spot/candles/{symbol}`. Coverage starts at `2025-03-22T10:50:22Z`; supported intervals are `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, with numeric timestamp-string cursor pagination and a 1,000-row page cap. ### Changed - Coverage copy now states HIP-4 outcome-side OI at roughly 10-second cadence, Lighter L3 at 250 orders per side from March 5, 2026, and Lighter per-fill trade history from August 27, 2025. diff --git a/README.md b/README.md index 59a8593..4366ab0 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ history = client.hyperliquid.orderbook.history( | Hyperliquid | April 2023+ | Core perpetuals; coverage varies by schema and route. | | Hyperliquid HIP-3 | February 2026+ for served history | Builder perps; funding and OI update at roughly 10 seconds. | | Hyperliquid HIP-4 | May 2026+ | Outcome markets. Candles and outcome-side OI are served from 2026-05-02; OI updates at ~10s. No funding or liquidations. | -| Hyperliquid Spot | Trades and candles from 2025-03-22; candle coverage starts exactly 2025-03-22T10:50:22Z; orderbook, L4, TWAP, and orders from 2026-05 | 326 authenticated inventory rows using dashed canonical symbols (`HYPE-USDC`, `PURR-USDC`). Candle intervals are 1m/5m/15m/30m/1h/4h/1d/1w with a 1,000-row page cap and opaque cursors. No funding/OI/liquidations. | +| Hyperliquid Spot | Trades and candles from 2025-03-22; candle coverage starts exactly 2025-03-22T10:50:22Z; orderbook, L4, TWAP, and orders from 2026-05 | 326 authenticated inventory rows using dashed canonical symbols (`HYPE-USDC`, `PURR-USDC`). Candle intervals are 1m/5m/15m/30m/1h/4h/1d/1w with a 1,000-row page cap and numeric timestamp-string cursors; pass each `next_cursor` back unchanged. No funding/OI/liquidations. | | Lighter.xyz | Observed global per-fill trade floor August 27, 2025; exact starts vary by market. L3 from March 5, 2026+ | Maker/taker trade context; L3 caps at 250 orders per side; funding/OI update at ~10s. | ## Async Support @@ -405,7 +405,7 @@ us500 = await client.hyperliquid.hip3.instruments.aget("km:US500") #### 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-4 candle pages are capped at 1,000 rows. Lighter candle pages remain capped at 10,000 rows, matching Hyperliquid core candles. HIP-4 has **no funding and no liquidations**. The ``mark_price`` field on HIP-4 OI/summary responses is an **implied probability in [0, 1]**, not a USD price. +HIP-4 binary-outcome markets resolve to ``Yes`` (side 0) or ``No`` (side 1) at expiry. Each outcome has two per-side coins (``#N``, where ``N = 10*outcome_id + side``). The SDK accepts both the bare numeric (``"0"``) and ``#``-prefixed (``"#0"``) forms. On REST paths it sends the bare form (the backend routes both to the same record). HIP-4 serves 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. ```python # Outcome-level metadata (one row per outcome_id; sides folded into side_specs). @@ -444,7 +444,7 @@ summary = client.hyperliquid.hip4.get_summary("0") # mark_price is in #### Hyperliquid Spot -Hyperliquid spot pairs live at `/v1/hyperliquid/spot` and are accessible via `client.spot`. Symbols use dashed canonical form (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format (`PURR/USDC` or `@107`) internally. Spot has **no funding, no open interest, or liquidations**. Candle history is served at `/v1/hyperliquid/spot/candles/{symbol}` from exactly `2025-03-22T10:50:22Z`, supports `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, and accepts a maximum of 1,000 rows per page with opaque cursors. +Hyperliquid spot pairs live at `/v1/hyperliquid/spot` and are accessible via `client.spot`. Symbols use dashed canonical form (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format (`PURR/USDC` or `@107`) internally. Spot has **no funding, no open interest, or liquidations**. Candle history is served at `/v1/hyperliquid/spot/candles/{symbol}` from exactly `2025-03-22T10:50:22Z`, supports `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, and accepts a maximum of 1,000 rows per page with numeric timestamp-string cursors; pass each `next_cursor` back unchanged. Trade history goes back to 2025-03-22. Orderbook, L4, TWAP, and order lifecycle are live-only from 2026-05-05. @@ -581,7 +581,7 @@ hip3_current = await client.hyperliquid.hip3.open_interest.acurrent("km:US500") | `coin` | `str` | Yes | Coin symbol (e.g., `'BTC'`, `'ETH'`) | | `start` | `Timestamp` | Yes | Start timestamp | | `end` | `Timestamp` | Yes | End timestamp | -| `cursor` | `str` | No | Opaque cursor from the previous response for pagination | +| `cursor` | `str` | No | Numeric timestamp string returned as `next_cursor`; pass it back unchanged | | `limit` | `int` | No | Max results (default: 100, max: 1000) | | `interval` | `str` | No | Aggregation interval: `'5m'`, `'15m'`, `'30m'`, `'1h'`, `'4h'`, `'1d'`. Omit for raw rows: HIP-3, HIP-4 outcome-side OI, and Lighter update at ~10s. | @@ -764,7 +764,7 @@ hip3_prices = await client.hyperliquid.hip3.aget_price_history("km:US500", start ### Candles (OHLCV) -Get historical OHLCV candle data aggregated from trades. Core Hyperliquid and Lighter candle pages accept up to 10,000 rows; HIP-4 and Hyperliquid Spot candle pages accept up to 1,000 rows. Hyperliquid Spot candle coverage starts exactly at `2025-03-22T10:50:22Z`. Candle pagination cursors are opaque strings. Pass ``result.next_cursor`` back unchanged. +Get historical OHLCV candle data aggregated from trades. Core Hyperliquid, HIP-3, and Lighter candle pages accept up to 10,000 rows; HIP-4 and Hyperliquid Spot candle pages accept up to 1,000 rows. Hyperliquid Spot candle coverage starts exactly at `2025-03-22T10:50:22Z`. Candle pagination cursors are numeric timestamp strings returned as `next_cursor`; pass each one back unchanged. ```python # Get candle history (start is required) diff --git a/oxarchive/exchanges.py b/oxarchive/exchanges.py index b1e1024..58dead9 100644 --- a/oxarchive/exchanges.py +++ b/oxarchive/exchanges.py @@ -358,7 +358,7 @@ def __init__(self, http: HttpClient): """Open interest""" self.candles = Hip3CandlesResource(http, base_path, coin_transform=coin_transform) - """OHLCV candle data (max 1,000 rows per page)""" + """OHLCV candle data (max 10,000 rows per page)""" self.liquidations = LiquidationsResource(http, base_path, coin_transform=coin_transform) """Liquidation events""" diff --git a/oxarchive/resources/candles.py b/oxarchive/resources/candles.py index 1adb24c..5b01a74 100644 --- a/oxarchive/resources/candles.py +++ b/oxarchive/resources/candles.py @@ -80,9 +80,10 @@ def history( start: Start timestamp (required) end: End timestamp (required) interval: Candle interval (1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w). Default: 1h - cursor: Opaque cursor string from the previous response's next_cursor + cursor: Numeric timestamp string returned as the previous response's + next_cursor; pass it back unchanged limit: Maximum number of results (default: 100, max: 10000 for - Hyperliquid core and Lighter candles; HIP-3, HIP-4, and Spot + Hyperliquid core, HIP-3, and Lighter candles; HIP-4 and Spot have a max of 1000) Returns: @@ -164,7 +165,7 @@ def _resolve_symbol(symbol, kwargs): class Hip3CandlesResource(CandlesResource): - """HIP-3 OHLCV candles with a 1,000-row page cap.""" + """HIP-3 OHLCV candles with a 10,000-row page cap.""" def __init__( self, @@ -173,7 +174,7 @@ def __init__( coin_transform=lambda coin: coin, ): super().__init__(http, base_path, coin_transform) - self._max_limit = 1000 + self._max_limit = 10000 self._limit_label = "HIP-3 candles" diff --git a/oxarchive/resources/openinterest.py b/oxarchive/resources/openinterest.py index 3f84329..bc53e3e 100644 --- a/oxarchive/resources/openinterest.py +++ b/oxarchive/resources/openinterest.py @@ -71,7 +71,8 @@ def history( symbol: The symbol (e.g., 'BTC', 'ETH') start: Start timestamp (required) end: End timestamp (required) - cursor: Opaque cursor string from the previous response's next_cursor + cursor: Numeric timestamp string returned as the previous response's + next_cursor; pass it back unchanged limit: Maximum number of results (default: 100, max: 1000) interval: Aggregation interval (e.g., '5m', '15m', '30m', '1h', '4h', '1d'). Raw cadence is route-specific. HIP-3, HIP-4 outcome-side OI, diff --git a/tests/test_coverage_contract.py b/tests/test_coverage_contract.py index aed979b..31d2d70 100644 --- a/tests/test_coverage_contract.py +++ b/tests/test_coverage_contract.py @@ -24,7 +24,7 @@ def __init__(self, response: dict[str, Any] | None = None) -> None: "volume": 10, } ], - "meta": {"next_cursor": "next-cursor"}, + "meta": {"next_cursor": "1777708800000"}, } def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: @@ -50,7 +50,7 @@ def test_hip4_exposes_candle_history_without_funding() -> None: params = http.calls[0][1] assert params is not None assert params["interval"] == "1m" - assert result.next_cursor == "next-cursor" + assert result.next_cursor == "1777708800000" assert result.data[0].close == 0.25 assert not hasattr(client, "funding") assert type(client.candles).__name__ == "Hip4CandlesResource" @@ -87,7 +87,7 @@ def test_hip4_accepts_advertised_integer_symbols() -> None: assert http.calls[0][0] == "/v1/hyperliquid/hip4/candles/0" -def test_hip4_candle_cursor_is_forwarded_opaque_and_limit_is_capped() -> None: +def test_hip4_candle_cursor_is_forwarded_as_a_numeric_string_and_limit_is_capped() -> None: http = FakeHttp() client = Hip4Client(cast(HttpClient, http)) @@ -106,15 +106,15 @@ def test_hip4_candle_cursor_is_forwarded_opaque_and_limit_is_capped() -> None: start="2026-05-02T08:00:00Z", end="2026-05-02T09:00:00Z", limit=1000, - cursor="opaque:hip4:candle:page-2", + cursor="1777712400000", ) assert http.calls[0][1] is not None - assert http.calls[0][1]["cursor"] == "opaque:hip4:candle:page-2" + assert http.calls[0][1]["cursor"] == "1777712400000" assert http.calls[0][1]["limit"] == 1000 -def test_lighter_candles_keep_the_10000_limit_and_opaque_cursor() -> None: +def test_lighter_candles_keep_the_10000_limit_and_numeric_timestamp_cursor() -> None: http = FakeHttp() client = LighterClient(cast(HttpClient, http)) @@ -123,13 +123,13 @@ def test_lighter_candles_keep_the_10000_limit_and_opaque_cursor() -> None: start="2025-08-01T00:00:00Z", end="2025-08-01T01:00:00Z", limit=10000, - cursor="opaque:lighter:candle:page-2", + cursor="1754010000000", ) assert result.data[0].close == 0.25 assert http.calls[0][0] == "/v1/lighter/candles/BTC" assert http.calls[0][1] is not None - assert http.calls[0][1]["cursor"] == "opaque:lighter:candle:page-2" + assert http.calls[0][1]["cursor"] == "1754010000000" assert http.calls[0][1]["limit"] == 10000 @@ -145,7 +145,7 @@ def test_hip4_open_interest_uses_the_family_model_on_history_and_current() -> No "mark_price": "0.6502", "mid_price": "0.65038", }, - "meta": {"next_cursor": "opaque:hip4:oi:page-2"}, + "meta": {"next_cursor": "1777708800000"}, } http = FakeHttp(response) client = Hip4Client(cast(HttpClient, http)) @@ -165,7 +165,7 @@ def test_hip4_open_interest_uses_the_family_model_on_history_and_current() -> No ) assert isinstance(history.data[0], Hip4OpenInterestRecord) assert history.data[0].symbol == "#0" - assert history.next_cursor == "opaque:hip4:oi:page-2" + assert history.next_cursor == "1777708800000" client.open_interest.history( "#0", @@ -174,7 +174,7 @@ def test_hip4_open_interest_uses_the_family_model_on_history_and_current() -> No cursor=history.next_cursor, ) assert http.calls[-1][1] is not None - assert http.calls[-1][1]["cursor"] == "opaque:hip4:oi:page-2" + assert http.calls[-1][1]["cursor"] == "1777708800000" def test_non_hip4_open_interest_keeps_the_generic_model() -> None: @@ -225,7 +225,8 @@ def test_public_copy_keeps_family_specific_coverage() -> None: assert "price levels per side" not in l3_resource assert "self.candles = CandlesResource" in exchanges assert "HIP-4 candle pages are capped at 1,000" in readme - assert "Lighter candle pages remain capped at 10,000" in readme + assert "HIP-3 and Lighter candle pages accept up to 10,000 rows" in readme + assert "Candle pagination cursors are numeric timestamp strings" in readme assert "client.spot.candles.history" in readme assert "2025-03-22T10:50:22Z" in readme assert "1m/5m/15m/30m/1h/4h/1d/1w" in readme @@ -251,7 +252,7 @@ def test_spot_exposes_verified_candle_history_and_preserves_negative_capabilitie end="2025-03-22T11:50:22Z", interval="5m", limit=1000, - cursor="opaque:spot:candle:page-2", + cursor="1742644222000", ) assert type(client.candles).__name__ == "SpotCandlesResource" @@ -260,9 +261,9 @@ def test_spot_exposes_verified_candle_history_and_preserves_negative_capabilitie assert params is not None assert params["interval"] == "5m" assert params["limit"] == 1000 - assert params["cursor"] == "opaque:spot:candle:page-2" + assert params["cursor"] == "1742644222000" assert result.data[0].close == 0.25 - assert result.next_cursor == "next-cursor" + assert result.next_cursor == "1777708800000" assert not hasattr(client, "funding") assert not hasattr(client, "open_interest") assert not hasattr(client, "liquidations") @@ -277,20 +278,30 @@ def test_spot_exposes_verified_candle_history_and_preserves_negative_capabilitie assert len(http.calls) == 1 -def test_hip3_candles_enforce_the_1000_row_route_cap() -> None: +def test_hip3_candles_enforce_the_10000_row_route_cap() -> None: http = FakeHttp() client = Hip3Client(cast(HttpClient, http)) - with pytest.raises(ValueError, match="limit must be between 1 and 1000 for HIP-3 candles"): + client.candles.history( + "xyz:XYZ100", + start="2026-02-01T00:00:00Z", + end="2026-02-02T00:00:00Z", + interval="1h", + limit=10000, + ) + assert http.calls[-1][1] is not None + assert http.calls[-1][1]["limit"] == 10000 + + with pytest.raises(ValueError, match="limit must be between 1 and 10000 for HIP-3 candles"): client.candles.history( "xyz:XYZ100", start="2026-02-01T00:00:00Z", end="2026-02-02T00:00:00Z", interval="1h", - limit=1001, + limit=10001, ) - assert http.calls == [] + assert len(http.calls) == 1 def test_spot_candle_history_supports_all_verified_intervals_and_async() -> None: @@ -315,9 +326,9 @@ def test_spot_candle_history_supports_all_verified_intervals_and_async() -> None start="2025-03-22T10:50:22Z", end="2025-03-22T11:50:22Z", interval="1d", - cursor="opaque:spot:candle:async-page-2", + cursor="1742644222000", ) ) assert result.data[0].open == 0.2 assert http.calls[-1][1] is not None - assert http.calls[-1][1]["cursor"] == "opaque:spot:candle:async-page-2" + assert http.calls[-1][1]["cursor"] == "1742644222000" From 647d4bf1bf33a637dbc97eb47228a14b7eefae30 Mon Sep 17 00:00:00 2001 From: Path Dependable <104703139+nuttykenzo@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:45:51 +0000 Subject: [PATCH 2/4] fix: align Lighter live and replay capabilities --- CHANGELOG.md | 7 ++ README.md | 121 +++++++++++++++------------ oxarchive/__init__.py | 2 +- oxarchive/types.py | 2 + oxarchive/websocket.py | 43 +++++++++- pyproject.toml | 2 +- tests/test_websocket_capabilities.py | 71 ++++++++++++++++ 7 files changed, 190 insertions(+), 58 deletions(-) create mode 100644 tests/test_websocket_capabilities.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ea02be0..3445bf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the `oxarchive` Python SDK are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.9.1] - Unreleased + +### Changed +- Lighter WebSocket channels now support bounded historical replay without + live subscriptions. Current Lighter data remains available through REST; + live subscription calls fail fast with guidance to REST or replay. + ## [1.9.0] - 2026-08-22 ### Added diff --git a/README.md b/README.md index 4366ab0..f21a549 100644 --- a/README.md +++ b/README.md @@ -1224,7 +1224,9 @@ trades = client.trades.list("BTC", start=..., end=...) ## WebSocket Client -The WebSocket client supports two modes: real-time streaming and historical replay. For file-based historical exports, use the [Data Catalog](https://www.0xarchive.io/data). +The WebSocket client supports live subscriptions for supported Hyperliquid channels and historical replay. For file-based historical exports, use the [Data Catalog](https://www.0xarchive.io/data). + +> Lighter channels support historical replay but not live subscriptions through the 0xArchive WebSocket. Use REST for current data and REST, WebSocket replay, or exports for historical data. ```python import asyncio @@ -1307,14 +1309,16 @@ async def main(): await ws.replay( "orderbook", "BTC", start=int(time.time() * 1000) - 86400000, # 24 hours ago - end=int(time.time() * 1000), # Optional + end=int(time.time() * 1000), # End of bounded replay window speed=10 # Optional, defaults to 1x ) - # Lighter.xyz replay with granularity + # Lighter.xyz replay with granularity. Lighter channels are replay-only; + # use the Lighter REST resources for current data. await ws.replay( - "orderbook", "BTC", + "lighter_orderbook", "BTC", start=int(time.time() * 1000) - 86400000, + end=int(time.time() * 1000), speed=10, granularity="10s" # Options: 'checkpoint', '30s', '10s', '1s', 'tick' ) @@ -1387,45 +1391,45 @@ ws = OxArchiveWs(WsOptions( #### Hyperliquid Channels -| Channel | Description | Requires Coin | Historical Support | -|---------|-------------|---------------|-------------------| -| `orderbook` | L2 order book updates | Yes | Yes | -| `trades` | Trade/fill updates | Yes | Yes | -| `candles` | OHLCV candle data | Yes | Yes (replay only) | -| `liquidations` | Liquidation events (May 2025+) | Yes | **Yes (realtime + replay)** | -| `open_interest` | Open interest snapshots | Yes | Yes (replay only) | -| `funding` | Funding rate records | Yes | Yes (replay only) | -| `ticker` | Price and 24h volume | Yes | Real-time only | -| `all_tickers` | All market tickers | No | Real-time only | -| `l4_diffs` | L4 orderbook diffs with user attribution | Yes | Real-time only | -| `l4_orders` | Order lifecycle events with user attribution | Yes | Real-time only | +| Channel | Description | Requires Coin | Live Subscription | Historical Replay | +|---------|-------------|---------------|-------------------|-------------------| +| `orderbook` | L2 order book updates | Yes | Yes | Yes | +| `trades` | Trade/fill updates | Yes | Yes | Yes | +| `candles` | OHLCV candle data | Yes | No | Yes | +| `liquidations` | Liquidation events (May 2025+) | Yes | Yes | Yes | +| `open_interest` | Open interest snapshots | Yes | No | Yes | +| `funding` | Funding rate records | Yes | No | Yes | +| `ticker` | Price and 24h volume | Yes | Yes | No | +| `all_tickers` | All market tickers | No | Yes | No | +| `l4_diffs` | L4 orderbook diffs with user attribution | Yes | Yes | No | +| `l4_orders` | Order lifecycle events with user attribution | Yes | Yes | No | > **Note:** ``liquidations`` and ``hip3_liquidations`` now stream live. Each item shares the trades wire shape (a fill row with ``is_liquidation: true``). The SDK exposes a typed ``on_liquidations`` callback that decodes them into :class:`Liquidation` records. #### HIP-3 Builder Perps Channels -| Channel | Description | Requires Coin | Historical Support | -|---------|-------------|---------------|-------------------| -| `hip3_orderbook` | HIP-3 L2 order book snapshots | Yes | Yes | -| `hip3_trades` | HIP-3 trade/fill updates | Yes | Yes | -| `hip3_candles` | HIP-3 OHLCV candle data | Yes | Yes | -| `hip3_open_interest` | HIP-3 open interest snapshots | Yes | Yes (replay only) | -| `hip3_funding` | HIP-3 funding rate records | Yes | Yes (replay only) | -| `hip3_liquidations` | HIP-3 liquidation events (Feb 2026+) | Yes | **Yes (realtime + replay)** | -| `hip3_l4_diffs` | HIP-3 L4 orderbook diffs | Yes | Real-time only | -| `hip3_l4_orders` | HIP-3 order lifecycle events | Yes | Real-time only | +| Channel | Description | Requires Coin | Live Subscription | Historical Replay | +|---------|-------------|---------------|-------------------|-------------------| +| `hip3_orderbook` | HIP-3 L2 order book snapshots | Yes | Yes | Yes | +| `hip3_trades` | HIP-3 trade/fill updates | Yes | Yes | Yes | +| `hip3_candles` | HIP-3 OHLCV candle data | Yes | Yes | Yes | +| `hip3_open_interest` | HIP-3 open interest snapshots | Yes | No | Yes | +| `hip3_funding` | HIP-3 funding rate records | Yes | No | Yes | +| `hip3_liquidations` | HIP-3 liquidation events (Feb 2026+) | 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 | > **Note:** HIP-3 coins are case-sensitive (e.g., `km:US500`, `xyz:XYZ100`). Do not uppercase them. #### HIP-4 Outcome Market Channels -| Channel | Description | Requires Coin | Historical Support | -|---------|-------------|---------------|-------------------| -| `hip4_orderbook` | HIP-4 L2 order book snapshots | Yes | Stored replay only; live bridge paused | -| `hip4_trades` | HIP-4 trade/fill updates | Yes | Yes | -| `hip4_open_interest` | HIP-4 per-side OI ticks | Yes | Stored replay only; live bridge paused | -| `hip4_l4_diffs` | HIP-4 L4 orderbook diffs | Yes | Real-time only | -| `hip4_l4_orders` | HIP-4 order lifecycle events | Yes | Real-time only | +| Channel | Description | Requires Coin | Live Subscription | Historical Replay | +|---------|-------------|---------------|-------------------|-------------------| +| `hip4_orderbook` | HIP-4 L2 order book snapshots | Yes | No | Yes | +| `hip4_trades` | HIP-4 trade/fill updates | Yes | Yes | Yes | +| `hip4_open_interest` | HIP-4 per-side OI ticks | Yes | No | Yes | +| `hip4_l4_diffs` | HIP-4 L4 orderbook diffs | Yes | Yes | No | +| `hip4_l4_orders` | HIP-4 order lifecycle events | Yes | Yes | No | HIP-4 has no funding or liquidation channels. HIP-4 candles and current outcome-side OI are available over REST; the live HIP-4 order-book and OI bridges are paused, while stored replay remains available. This HIP-4 channel set has no dedicated candle channel. Subscribe with the raw ``#N`` coin form (e.g. ``"#0"``); the SDK passes it through unmodified in the JSON body. When a market settles, the server pushes a single ``outcome_settled`` frame and proactively unsubscribes the client from every ``hip4_*`` channel for that coin. Use :py:meth:`OxArchiveWs.on_outcome_settled` to handle the event: @@ -1441,13 +1445,13 @@ ws.subscribe_hip4_trades("#0") #### Hyperliquid Spot Channels -| Channel | Description | Requires Coin | Historical Support | -|---------|-------------|---------------|-------------------| -| `spot_orderbook` | Spot L2 order book snapshots | Yes | Real-time only | -| `spot_trades` | Spot trade/fill updates | Yes | Real-time only | -| `spot_twap` | Spot TWAP status updates | Yes | Real-time only | -| `spot_l4_diffs` | Spot L4 orderbook diffs | Yes | Real-time only | -| `spot_l4_orders` | Spot L4 order lifecycle events | Yes | Real-time only | +| Channel | Description | Requires Coin | Live Subscription | Historical Replay | +|---------|-------------|---------------|-------------------|-------------------| +| `spot_orderbook` | Spot L2 order book snapshots | Yes | Yes | No | +| `spot_trades` | Spot trade/fill updates | Yes | Yes | No | +| `spot_twap` | Spot TWAP status updates | Yes | Yes | No | +| `spot_l4_diffs` | Spot L4 orderbook diffs | Yes | Yes | No | +| `spot_l4_orders` | Spot L4 order lifecycle events | Yes | Yes | No | > **Note:** Spot symbols are dashed canonical (`HYPE-USDC`, `PURR-USDC`); the server resolves dashed to wire format internally. The existing `on_orderbook` and `on_trades` typed callbacks fire for `spot_orderbook` and `spot_trades`. @@ -1461,14 +1465,16 @@ ws.subscribe_spot_trades("HYPE-USDC") #### Lighter.xyz Channels -| Channel | Description | Requires Coin | Historical Support | -|---------|-------------|---------------|-------------------| -| `lighter_orderbook` | Lighter L2 order book (reconstructed) | Yes | Yes | -| `lighter_trades` | Lighter trade/fill updates | Yes | Yes | -| `lighter_candles` | Lighter OHLCV candle data | Yes | Yes | -| `lighter_open_interest` | Lighter open interest snapshots | Yes | Yes (replay only) | -| `lighter_funding` | Lighter funding rate records | Yes | Yes (replay only) | -| `lighter_l3_orderbook` | Lighter L3 order-level orderbook | Yes | Yes | +| Channel | Description | Requires Coin | Live Subscription | Historical Replay | Current Data Path | +|---------|-------------|---------------|-------------------|-------------------|-------------------| +| `lighter_orderbook` | Lighter L2 order book (reconstructed) | Yes | No | Yes | Lighter REST | +| `lighter_trades` | Lighter trade/fill updates | Yes | No | Yes | Lighter REST | +| `lighter_candles` | Lighter OHLCV candle data | Yes | No | Yes | Lighter REST | +| `lighter_open_interest` | Lighter open interest snapshots | Yes | No | Yes | Lighter REST | +| `lighter_funding` | Lighter funding rate records | Yes | No | Yes | Lighter REST | +| `lighter_l3_orderbook` | Lighter L3 order-level orderbook | Yes | No | Yes | Lighter REST | + +Current Lighter data is available through the REST resources. Historical Lighter data is available through REST, WebSocket replay, or exports. #### Candle Replay @@ -1485,7 +1491,8 @@ await ws.replay( # Lighter.xyz candles await ws.replay( "lighter_candles", "BTC", - start=..., + start=int(time.time() * 1000) - 86400000, + end=int(time.time() * 1000), speed=10, interval="5m" ) @@ -1529,6 +1536,7 @@ await ws.replay( await ws.replay( "funding", "ETH", start=int(time.time() * 1000) - 86400000, + end=int(time.time() * 1000), speed=50, ) @@ -1536,6 +1544,7 @@ await ws.replay( await ws.replay( "hip3_funding", "km:US500", start=int(time.time() * 1000) - 86400000, + end=int(time.time() * 1000), speed=100, ) ``` @@ -1609,21 +1618,27 @@ asyncio.run(main()) await ws.multi_replay( ["orderbook", "trades", "open_interest", "funding"], "BTC", - start=start_ms, speed=10, + start=start_ms, + end=end_ms, + speed=10, ) # Lighter.xyz: orderbook + trades + OI + funding await ws.multi_replay( ["lighter_orderbook", "lighter_trades", "lighter_open_interest", "lighter_funding"], "BTC", - start=start_ms, speed=10, + start=start_ms, + end=end_ms, + speed=10, ) # HIP-3: orderbook + trades + OI + funding await ws.multi_replay( ["hip3_orderbook", "hip3_trades", "hip3_open_interest", "hip3_funding"], "km:US500", - start=start_ms, speed=10, + start=start_ms, + end=end_ms, + speed=10, ) ``` @@ -1690,7 +1705,7 @@ client = Client(api_key="0xa_your_api_key") orderbook: OrderBook = client.hyperliquid.orderbook.get("BTC") result: CursorResponse = client.hyperliquid.trades.list("BTC", start=..., end=...) -# Lighter has real-time data, so recent() is available +# Lighter current data is available, so recent() is available recent: list[Trade] = client.lighter.trades.recent("BTC") # Lighter granularity type hint diff --git a/oxarchive/__init__.py b/oxarchive/__init__.py index db15e42..2eb29dc 100644 --- a/oxarchive/__init__.py +++ b/oxarchive/__init__.py @@ -119,7 +119,7 @@ OxArchiveWs = None # type: ignore WsOptions = None # type: ignore -__version__ = "1.9.0" +__version__ = "1.9.1" __all__ = [ # Client diff --git a/oxarchive/types.py b/oxarchive/types.py index f3b31dc..60887df 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -1016,6 +1016,8 @@ class Candle(BaseModel): Each item shares the trades wire shape (a fill row with ``is_liquidation: true``). - open_interest, funding, lighter_open_interest, lighter_funding, hip3_open_interest, hip3_funding are historical only (replay/stream). +- all six ``lighter_*`` channels support historical replay, not live + subscriptions. Use Lighter REST for current data. - l4_diffs, l4_orders: Hyperliquid L4 order-level data (realtime only). - hip3_l4_diffs, hip3_l4_orders: HIP-3 L4 order-level data (realtime only). - hip4_trades: HIP-4 outcome-market fills (realtime + replay). diff --git a/oxarchive/websocket.py b/oxarchive/websocket.py index 0a30db2..0694e9d 100644 --- a/oxarchive/websocket.py +++ b/oxarchive/websocket.py @@ -88,6 +88,30 @@ DEFAULT_RECONNECT_DELAY = 1.0 DEFAULT_MAX_RECONNECT_ATTEMPTS = 10 +LIGHTER_REPLAY_CHANNELS: frozenset[str] = frozenset( + { + "lighter_orderbook", + "lighter_trades", + "lighter_candles", + "lighter_open_interest", + "lighter_funding", + "lighter_l3_orderbook", + } +) +"""Lighter channels available through historical replay, not live subscription.""" + +LIGHTER_SUBSCRIPTION_ERROR = ( + "Lighter WebSocket channels support replay, not live subscriptions. " + "Use REST for current data or a replay request for stored history." +) + + +def _validate_live_subscription(channel: WsChannel) -> None: + """Reject live subscriptions for channels that only support replay.""" + if channel in LIGHTER_REPLAY_CHANNELS: + raise ValueError(LIGHTER_SUBSCRIPTION_ERROR) + + # Server idle timeout is 60 seconds. The SDK sends pings every 30 seconds # to keep the connection alive. The websockets library also automatically # responds to WebSocket protocol-level ping frames from the server. @@ -335,7 +359,7 @@ def _transform_orderbook(coin: str, raw: dict) -> OrderBook: class OxArchiveWs: - """WebSocket client for real-time data streaming.""" + """WebSocket client for supported live data and historical replay.""" def __init__(self, options: WsOptions): """Initialize the WebSocket client. @@ -446,12 +470,17 @@ async def disconnect(self) -> None: self._ws = None def subscribe(self, channel: WsChannel, coin: Optional[str] = None) -> None: - """Subscribe to a channel. + """Subscribe to a supported live channel. Args: channel: Channel type coin: Coin symbol (required for coin-specific channels) + + Raises: + ValueError: If ``channel`` is a Lighter channel. Lighter supports + replay over WebSocket and current data through REST instead. """ + _validate_live_subscription(channel) key = self._subscription_key(channel, coin) self._subscriptions.add(key) @@ -459,7 +488,12 @@ def subscribe(self, channel: WsChannel, coin: Optional[str] = None) -> None: asyncio.create_task(self._send_subscribe(channel, coin)) async def subscribe_async(self, channel: WsChannel, coin: Optional[str] = None) -> None: - """Subscribe to a channel (async version).""" + """Subscribe asynchronously to a supported live channel. + + Lighter channels are replay-only over WebSocket; use REST for current + data or :meth:`replay` for stored history. + """ + _validate_live_subscription(channel) key = self._subscription_key(channel, coin) self._subscriptions.add(key) @@ -658,6 +692,9 @@ async def replay( ) -> None: """Start historical replay with timing preserved. + All six ``lighter_*`` channels support bounded historical replay. They + do not support live subscriptions; use Lighter REST for current data. + Args: channel: Data channel to replay coin: Trading pair (e.g., 'BTC', 'ETH') diff --git a/pyproject.toml b/pyproject.toml index 2216506..f4fef5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "oxarchive" -version = "1.9.0" +version = "1.9.1" description = "Python SDK for the 0xArchive market data API for Hyperliquid and Lighter" readme = "README.md" license = "MIT" diff --git a/tests/test_websocket_capabilities.py b/tests/test_websocket_capabilities.py new file mode 100644 index 0000000..7717584 --- /dev/null +++ b/tests/test_websocket_capabilities.py @@ -0,0 +1,71 @@ +import asyncio +import re + +import pytest + +from oxarchive.websocket import ( + LIGHTER_REPLAY_CHANNELS, + LIGHTER_SUBSCRIPTION_ERROR, + OxArchiveWs, + WsOptions, +) + + +@pytest.mark.parametrize("channel", sorted(LIGHTER_REPLAY_CHANNELS)) +def test_lighter_channels_reject_live_subscription_before_state_change(channel: str) -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + + with pytest.raises(ValueError, match=re.escape(LIGHTER_SUBSCRIPTION_ERROR)): + ws.subscribe(channel, "BTC") + + assert ws._subscriptions == set() + + +@pytest.mark.parametrize("channel", sorted(LIGHTER_REPLAY_CHANNELS)) +def test_lighter_channels_reject_async_live_subscription_before_state_change(channel: str) -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + + async def run() -> None: + with pytest.raises(ValueError, match=re.escape(LIGHTER_SUBSCRIPTION_ERROR)): + await ws.subscribe_async(channel, "BTC") + + asyncio.run(run()) + assert ws._subscriptions == set() + + +def test_lighter_channels_are_allowed_for_bounded_replay() -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + sent: list[dict] = [] + + async def fake_send(message: dict) -> None: + sent.append(message) + + ws._send = fake_send # type: ignore[method-assign] + + asyncio.run( + ws.replay( + "lighter_orderbook", + "BTC", + start=1_757_000_000_000, + end=1_757_003_600_000, + ) + ) + + assert sent == [ + { + "op": "replay", + "channel": "lighter_orderbook", + "symbol": "BTC", + "start": 1_757_000_000_000, + "speed": 1.0, + "end": 1_757_003_600_000, + } + ] + + +def test_hyperliquid_live_subscription_remains_allowed() -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + + ws.subscribe("orderbook", "BTC") + + assert ws._subscriptions == {"orderbook:BTC"} From 633dd54d0a2770385afe9c2ede70794b25f22594 Mon Sep 17 00:00:00 2001 From: Path Dependable <104703139+nuttykenzo@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:51:27 +0000 Subject: [PATCH 3/4] fix: align Python SDK with G1-G4 contracts --- CHANGELOG.md | 22 ++++- README.md | 58 ++++++++++- oxarchive/__init__.py | 10 ++ oxarchive/exchanges.py | 4 + oxarchive/resources/__init__.py | 2 + oxarchive/resources/breadth.py | 129 ++++++++++++++++++++++++ oxarchive/resources/liquidations.py | 6 +- oxarchive/types.py | 126 +++++++++++++++++++++-- oxarchive/websocket.py | 100 ++++++++++++++----- tests/test_coverage_contract.py | 143 ++++++++++++++++++++++++++- tests/test_websocket_capabilities.py | 120 ++++++++++++++++++++++ 11 files changed, 681 insertions(+), 39 deletions(-) create mode 100644 oxarchive/resources/breadth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3445bf6..485bf8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,26 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [1.9.1] - Unreleased +### Added +- HIP-3 breadth above current UTC-session VWAP via + `client.hyperliquid.hip3.breadth.current()` and cursor-paginated + `.history()`; collection begins on 2026-08-28 and `value_pct` remains null + when no instrument is eligible. +- Typed Hyperliquid core L4 replay frames: `l4_snapshot` is followed by + ordered `l4_batch` events for `l4_diffs` and `l4_orders`. HIP-3, HIP-4, and + Hyperliquid Spot L4 remain live-only. + ### Changed - Lighter WebSocket channels now support bounded historical replay without live subscriptions. Current Lighter data remains available through REST; live subscription calls fail fast with guidance to REST or replay. +- Projected forced-liquidation price-level endpoints refresh about every five + minutes. This is a measured cadence, not an exact five-minute guarantee. + +### Breaking +- Lighter `funding_rate` is now a fractional, non-annualized rate. Consumers + that compensated for the former percent units must remove that conversion; + do not apply a second percent conversion. ## [1.9.0] - 2026-08-22 @@ -29,9 +45,9 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **Liquidation levels**: `liquidations.levels()` / `alevels()` and `levels_history()` / `alevels_history()` on the Hyperliquid and HIP-3 clients. Projected forced-liquidation levels computed from clearinghouse - positions and margin state (~45-minute snapshots, `at=` point-in-time - reads, `side=` filter, cursor-paginated history with `summary=True` - discovery mode). History retained from 2026-07-27. + positions and margin state (snapshots approximately every five minutes, + `at=` point-in-time reads, `side=` filter, cursor-paginated history with + `summary=True` discovery mode). History retained from 2026-07-27. - **Trigger levels**: `orders.trigger_levels()` and `trigger_levels_history()` (+ async variants): the pending stop-loss / take-profit map with 15-minute snapshot history. diff --git a/README.md b/README.md index f21a549..d6bf574 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,32 @@ us500 = await client.hyperliquid.hip3.instruments.aget("km:US500") | xyz (Hyperliquid) | `xyz:XYZ100` | | km (Kinetiq Markets) | `km:US500`, `km:SMALL2000`, `km:GOOGL`, `km:USBOND`, `km:GOLD`, `km:USTECH`, `km:NVDA`, `km:SILVER`, `km:BABA` | +#### HIP-3 Market Breadth + +HIP-3 breadth reports the percentage of eligible instruments trading above their current UTC-session VWAP. The current snapshot is available from `client.hyperliquid.hip3.breadth.current()`, and history is cursor-paginated through `client.hyperliquid.hip3.breadth.history()`: + +```python +current = client.hyperliquid.hip3.breadth.current() +print(f"{current.value_pct}% above VWAP ({current.counts.eligible} eligible)") + +history = client.hyperliquid.hip3.breadth.history( + start="2026-08-28T00:00:00Z", + end="2026-08-29T00:00:00Z", + interval="5m", # 5m, 1h, 1d + limit=1000, +) +while history.next_cursor: + history = client.hyperliquid.hip3.breadth.history( + start="2026-08-28T00:00:00Z", + end="2026-08-29T00:00:00Z", + interval="5m", + cursor=history.next_cursor, + limit=1000, + ) +``` + +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. + #### 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. @@ -532,6 +558,8 @@ history = await client.hyperliquid.funding.ahistory("ETH", start=..., end=...) hip3_current = await client.hyperliquid.hip3.funding.acurrent("km:US500") ``` +**Unit note:** `funding_rate` is a fractional, non-annualized rate. For example, `0.0001` means `0.01%` for the funding interval. This is a breaking normalization for Lighter consumers that previously compensated for percent units; do not apply a second percent conversion. + #### Funding History Parameters | Parameter | Type | Required | Description | @@ -587,7 +615,7 @@ hip3_current = await client.hyperliquid.hip3.open_interest.acurrent("km:US500") ### Liquidations -Get historical liquidation events. Available for Hyperliquid (May 2025+) and HIP-3. +Get historical liquidation events. Available for Hyperliquid (May 2025+) and HIP-3. The projected forced-liquidation price-level endpoints refresh about every five minutes. This is a measured cadence, not an exact five-minute guarantee. ```python # Get liquidation history for a coin (Hyperliquid) @@ -1337,6 +1365,28 @@ async def main(): asyncio.run(main()) ``` +#### Hyperliquid Core L4 Replay + +Historical replay for Hyperliquid core `l4_diffs` and `l4_orders` begins with one typed `WsL4Snapshot` message and continues with ordered `WsL4Batch` messages. Apply each batch in the order received; use the snapshot's `last_block_number` with each event's block/sequence fields as the checkpoint boundary. The dedicated `on_l4_snapshot` and `on_l4_batch` callbacks keep their raw payload shapes; `on_message` receives the typed envelopes. + +```python +from oxarchive import WsL4Batch, WsL4Snapshot + + +def on_message(message): + if isinstance(message, WsL4Snapshot): + rebuild_book_from_snapshot(message.data) + elif isinstance(message, WsL4Batch): + for event in message.data: + apply_l4_event(event) + + +ws.on_message(on_message) +await ws.replay("l4_diffs", "BTC", start=..., end=..., speed=10) +``` + +HIP-3, HIP-4, and Hyperliquid Spot L4 channels remain live-only. They accept live subscriptions and do not accept historical replay. + ### Gap Detection During historical replay, the server automatically detects gaps in the data and notifies the client. This helps identify periods where data may be missing. @@ -1401,8 +1451,10 @@ ws = OxArchiveWs(WsOptions( | `funding` | Funding rate records | Yes | No | Yes | | `ticker` | Price and 24h volume | Yes | Yes | No | | `all_tickers` | All market tickers | No | Yes | No | -| `l4_diffs` | L4 orderbook diffs with user attribution | Yes | Yes | No | -| `l4_orders` | Order lifecycle events with user attribution | Yes | Yes | No | +| `l4_diffs` | L4 orderbook diffs with user attribution | Yes | Yes | Yes | +| `l4_orders` | Order lifecycle events with user attribution | Yes | Yes | Yes | + +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. > **Note:** ``liquidations`` and ``hip3_liquidations`` now stream live. Each item shares the trades wire shape (a fill row with ``is_liquidation: true``). The SDK exposes a typed ``on_liquidations`` callback that decodes them into :class:`Liquidation` records. diff --git a/oxarchive/__init__.py b/oxarchive/__init__.py index 2eb29dc..944e8dd 100644 --- a/oxarchive/__init__.py +++ b/oxarchive/__init__.py @@ -58,6 +58,9 @@ SpotPair, SpotTwapStatus, SpotTableFreshness, + BreadthNamespaceCounts, + BreadthCounts, + BreadthSnapshot, FundingRate, OpenInterest, Liquidation, @@ -91,6 +94,8 @@ WsPong, WsError, WsData, + WsL4Snapshot, + WsL4Batch, # Replay types (Option B) WsReplayStarted, WsReplayPaused, @@ -159,6 +164,9 @@ "SpotPair", "SpotTwapStatus", "SpotTableFreshness", + "BreadthNamespaceCounts", + "BreadthCounts", + "BreadthSnapshot", "LighterGranularity", "FundingRate", "OpenInterest", @@ -193,6 +201,8 @@ "WsPong", "WsError", "WsData", + "WsL4Snapshot", + "WsL4Batch", # Replay Types (Option B) "WsReplayStarted", "WsReplayPaused", diff --git a/oxarchive/exchanges.py b/oxarchive/exchanges.py index 58dead9..b91da6a 100644 --- a/oxarchive/exchanges.py +++ b/oxarchive/exchanges.py @@ -9,6 +9,7 @@ from .http import HttpClient from .resources import ( + BreadthResource, CandlesResource, FundingResource, Hip3CandlesResource, @@ -345,6 +346,9 @@ def __init__(self, http: HttpClient): self.instruments = Hip3InstrumentsResource(http, base_path, coin_transform=coin_transform) """HIP-3 instruments with latest market data""" + self.breadth = BreadthResource(http, base_path) + """Percent of eligible instruments above current UTC-session VWAP.""" + self.orderbook = OrderBookResource(http, base_path, coin_transform=coin_transform) """Order book snapshots (February 2026+)""" diff --git a/oxarchive/resources/__init__.py b/oxarchive/resources/__init__.py index 2c1541d..c4b34f9 100644 --- a/oxarchive/resources/__init__.py +++ b/oxarchive/resources/__init__.py @@ -1,5 +1,6 @@ """Resource modules.""" +from .breadth import BreadthResource from .candles import CandlesResource, Hip3CandlesResource, Hip4CandlesResource, SpotCandlesResource from .data_quality import DataQualityResource from .funding import FundingResource @@ -29,6 +30,7 @@ "Hip3InstrumentsResource", "Hip4InstrumentsResource", "FundingResource", + "BreadthResource", "OpenInterestResource", "Hip4OpenInterestResource", "CandlesResource", diff --git a/oxarchive/resources/breadth.py b/oxarchive/resources/breadth.py new file mode 100644 index 0000000..f9db181 --- /dev/null +++ b/oxarchive/resources/breadth.py @@ -0,0 +1,129 @@ +"""HIP-3 market breadth API resource.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Optional + +from ..http import HttpClient +from ..types import BreadthSnapshot, CursorResponse, Timestamp + +BreadthInterval = Literal["5m", "1h", "1d"] +BREADTH_INTERVALS = frozenset({"5m", "1h", "1d"}) + + +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. + """ + + def __init__( + self, + http: HttpClient, + base_path: str = "/v1/hyperliquid/hip3", + ) -> None: + self._http = http + self._base_path = base_path + 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") + + @staticmethod + def _validate_interval(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") + + @staticmethod + def _convert_timestamp(ts: Optional[Timestamp]) -> Optional[int]: + """Convert an ISO timestamp or datetime to Unix milliseconds.""" + if ts is None: + return None + if isinstance(ts, int): + return ts + if isinstance(ts, datetime): + return int(ts.timestamp() * 1000) + if isinstance(ts, str): + try: + parsed = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return int(parsed.timestamp() * 1000) + except ValueError: + return int(ts) + return None + + @staticmethod + def _history_response(payload: dict) -> CursorResponse[list[BreadthSnapshot]]: + return CursorResponse( + data=[BreadthSnapshot.model_validate(item) for item in payload["data"]], + next_cursor=payload.get("meta", {}).get("next_cursor"), + ) + + def current(self) -> BreadthSnapshot: + """Return the latest validated HIP-3 breadth snapshot.""" + payload = self._http.get(f"{self._base_path}/breadth/above-vwap/current") + return BreadthSnapshot.model_validate(payload["data"]) + + async def acurrent(self) -> BreadthSnapshot: + """Async version of :meth:`current`.""" + payload = await self._http.aget(f"{self._base_path}/breadth/above-vwap/current") + return BreadthSnapshot.model_validate(payload["data"]) + + def history( + self, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + interval: Optional[BreadthInterval] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[BreadthSnapshot]]: + """Return ascending HIP-3 breadth history with cursor pagination. + + ``start`` defaults to 24 hours before ``end`` 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. + """ + self._validate_interval(interval) + self._validate_limit(limit) + payload = self._http.get( + f"{self._base_path}/breadth/above-vwap", + params={ + "start": self._convert_timestamp(start), + "end": self._convert_timestamp(end), + "interval": interval, + "cursor": cursor, + "limit": limit, + }, + ) + return self._history_response(payload) + + async def ahistory( + self, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + interval: Optional[BreadthInterval] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[BreadthSnapshot]]: + """Async version of :meth:`history`.""" + self._validate_interval(interval) + self._validate_limit(limit) + payload = await self._http.aget( + f"{self._base_path}/breadth/above-vwap", + params={ + "start": self._convert_timestamp(start), + "end": self._convert_timestamp(end), + "interval": interval, + "cursor": cursor, + "limit": limit, + }, + ) + return self._history_response(payload) diff --git a/oxarchive/resources/liquidations.py b/oxarchive/resources/liquidations.py index 0bbf0df..dc3461c 100644 --- a/oxarchive/resources/liquidations.py +++ b/oxarchive/resources/liquidations.py @@ -328,8 +328,8 @@ def levels( Get projected forced-liquidation levels for a symbol. Computed from clearinghouse positions and margin state, bucketed - around the snapshot mark price. Snapshots refresh roughly every 45 - minutes; pass ``at`` (epoch ms) for a point-in-time read. History + around the snapshot mark price. Snapshots refresh approximately every + five minutes; pass ``at`` (epoch ms) for a point-in-time read. History begins 2026-07-27. These are projected forced liquidations, not the pending @@ -384,7 +384,7 @@ def levels_history( """ Get historical liquidation-levels snapshots with cursor pagination. - Ascending by snapshot time (about every 45 minutes, retained from + Ascending by snapshot time (approximately every five minutes, retained from 2026-07-27). Pass ``summary=True`` to list snapshots without histograms. Follow ``next_cursor`` as ``cursor`` for the next page. diff --git a/oxarchive/types.py b/oxarchive/types.py index 60887df..cfe0602 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from typing import Any, Generic, Literal, Optional, TypeVar, Union from pydantic import BaseModel, Field @@ -577,6 +577,81 @@ class LighterInstrument(BaseModel): """Whether the instrument is currently tradeable.""" +# ============================================================================= +# HIP-3 Market Breadth Types +# ============================================================================= + + +class BreadthNamespaceCounts(BaseModel): + """Per-builder-namespace counts for a HIP-3 breadth snapshot.""" + + eligible: dict[str, int] + """Eligible instruments by builder namespace.""" + + above: dict[str, int] + """Eligible instruments trading above session VWAP by namespace.""" + + at: dict[str, int] + """Eligible instruments exactly at session VWAP by namespace.""" + + below: dict[str, int] + """Eligible instruments trading below session VWAP by namespace.""" + + +class BreadthCounts(BaseModel): + """Auditable aggregate counts for a HIP-3 breadth snapshot.""" + + candidates: int + """Full HIP-3 candidate universe at calculation time.""" + + eligible: int + """Candidates with a usable completed candle and session volume.""" + + above: int + """Eligible instruments above session VWAP.""" + + at: int + """Eligible instruments whose close equals session VWAP.""" + + below: int + """Eligible instruments below session VWAP.""" + + excluded_no_session_volume: int + """Candidates excluded because they have no session volume.""" + + excluded_stale_price: int + """Candidates excluded because their completed candle is over five minutes old.""" + + +class BreadthSnapshot(BaseModel): + """Validated HIP-3 percent-above-session-VWAP market snapshot. + + Snapshots use the current UTC session and the close of the most recently + completed one-minute candle. ``value_pct`` is unavailable (``None``), not + zero, when no instrument is eligible. The API stores one-minute snapshots + beginning on 2026-08-28 and downsamples history with the last snapshot in + each requested interval bucket. + """ + + session_date: date + """UTC calendar date for the session.""" + + calculated_at: datetime + """Job timestamp; the newest included candle closed at this minute.""" + + value_pct: Optional[float] + """100 * above / eligible, or ``None`` when eligible is zero.""" + + coverage_ratio: float + """Eligible divided by candidates, from 0 to 1.""" + + counts: BreadthCounts + """Aggregate counts whose invariants are enforced by the producer.""" + + namespaces: BreadthNamespaceCounts + """Per-builder-namespace breakdowns for the aggregate counts.""" + + # ============================================================================= # Funding Types # ============================================================================= @@ -592,7 +667,11 @@ class FundingRate(BaseModel): """Funding timestamp (UTC).""" funding_rate: str - """Funding rate as decimal (e.g., 0.0001 = 0.01%).""" + """Fractional, non-annualized funding rate (e.g., 0.0001 = 0.01%). + + Lighter uses this fractional unit as a breaking normalization from its + former percent representation; do not apply a second percent conversion. + """ premium: Optional[str] = None """Premium component of funding rate.""" @@ -745,7 +824,7 @@ class LiquidationLevels(BaseModel): """Projected forced-liquidation levels for one snapshot. Computed from clearinghouse positions and margin state, bucketed around - the snapshot mark price. Snapshots refresh roughly every 45 minutes; + the snapshot mark price. Snapshots refresh approximately every five minutes; ``snapshot_ts`` identifies the snapshot served. """ @@ -1018,14 +1097,15 @@ class Candle(BaseModel): hip3_open_interest, hip3_funding are historical only (replay/stream). - all six ``lighter_*`` channels support historical replay, not live subscriptions. Use Lighter REST for current data. -- l4_diffs, l4_orders: Hyperliquid L4 order-level data (realtime only). -- hip3_l4_diffs, hip3_l4_orders: HIP-3 L4 order-level data (realtime only). +- l4_diffs, l4_orders: Hyperliquid core L4 order-level data. Historical replay + emits one ``l4_snapshot`` followed by ordered ``l4_batch`` messages. +- hip3_l4_diffs, hip3_l4_orders: HIP-3 L4 order-level data (live-only). - 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 (realtime only). +- hip4_l4_diffs, hip4_l4_orders: HIP-4 L4 order-level data (live-only). - HIP-4 has no funding or liquidation channels. Candles are served through REST. - spot_orderbook, spot_trades, spot_twap: Hyperliquid spot (realtime). -- spot_l4_diffs, spot_l4_orders: Hyperliquid spot L4 (realtime only). +- spot_l4_diffs, spot_l4_orders: Hyperliquid spot L4 (live-only). - Spot has no funding / open interest / liquidations WebSocket channels. Candle history is REST-only; subscribe to the supported ``spot_*`` realtime channels for live spot streams. @@ -1078,6 +1158,38 @@ class WsData(BaseModel): data: Union[dict[str, Any], list[dict[str, Any]]] +class WsL4Snapshot(BaseModel): + """Initial L4 orderbook state for a live stream or core replay.""" + + type: Literal["l4_snapshot"] + channel: WsChannel + coin: str + symbol: str + """Canonical wire symbol for the L4 stream.""" + + last_block_number: int + """Highest block applied to this snapshot.""" + + timestamp: int + """Snapshot timestamp in Unix milliseconds.""" + + data: dict[str, Any] + """Full L4 book state.""" + + +class WsL4Batch(BaseModel): + """Ordered L4 diff/order events following an ``l4_snapshot``.""" + + type: Literal["l4_batch"] + channel: WsChannel + coin: str + symbol: str + """Canonical wire symbol for the L4 stream.""" + + data: list[dict[str, Any]] + """Events in server order; each event carries its own block/sequence data.""" + + # ============================================================================= # WebSocket Replay Types (Historical Replay Mode) # ============================================================================= diff --git a/oxarchive/websocket.py b/oxarchive/websocket.py index 0694e9d..34b3d80 100644 --- a/oxarchive/websocket.py +++ b/oxarchive/websocket.py @@ -59,6 +59,8 @@ WsChannel, WsConnectionState, WsData, + WsL4Snapshot, + WsL4Batch, WsError, WsOutcomeSettled, WsPong, @@ -105,6 +107,32 @@ "Use REST for current data or a replay request for stored history." ) +CORE_L4_REPLAY_CHANNELS: frozenset[str] = frozenset({"l4_diffs", "l4_orders"}) +"""Hyperliquid core L4 channels whose replay starts with a snapshot.""" + +L4_LIVE_ONLY_CHANNELS: frozenset[str] = frozenset( + { + "hip3_l4_diffs", + "hip3_l4_orders", + "hip4_l4_diffs", + "hip4_l4_orders", + "spot_l4_diffs", + "spot_l4_orders", + } +) +"""HIP-3, HIP-4, and Hyperliquid Spot L4 channels without replay support.""" + +L4_LIVE_ONLY_ERROR = ( + "HIP-3, HIP-4, and Hyperliquid Spot L4 channels support live subscriptions only. " + "Use subscribe for current streams." +) + + +def _validate_historical_l4_channel(channel: WsChannel) -> None: + """Reject historical operations for L4 channels that are live-only.""" + if channel in L4_LIVE_ONLY_CHANNELS: + raise ValueError(L4_LIVE_ONLY_ERROR) + def _validate_live_subscription(channel: WsChannel) -> None: """Reject live subscriptions for channels that only support replay.""" @@ -143,7 +171,18 @@ class WsOptions: MessageHandler = Callable[ - [Union[WsSubscribed, WsUnsubscribed, WsPong, WsError, WsData, WsOutcomeSettled]], + [ + Union[ + WsSubscribed, + WsUnsubscribed, + WsPong, + WsError, + WsData, + WsL4Snapshot, + WsL4Batch, + WsOutcomeSettled, + ] + ], None, ] OrderbookHandler = Callable[[str, OrderBook], None] @@ -692,8 +731,12 @@ async def replay( ) -> None: """Start historical replay with timing preserved. - All six ``lighter_*`` channels support bounded historical replay. They - do not support live subscriptions; use Lighter REST for current data. + 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 continue to support bounded + historical replay and do not support live subscriptions; use Lighter + REST for current data. Args: channel: Data channel to replay @@ -706,8 +749,10 @@ async def replay( Example: >>> await ws.replay("orderbook", "BTC", start=time.time()*1000 - 86400000, speed=10) + >>> await ws.replay("l4_diffs", "BTC", start=..., speed=10) >>> await ws.replay("candles", "BTC", start=..., speed=10, interval="15m") """ + _validate_historical_l4_channel(channel) msg = { "op": "replay", "channel": channel, @@ -779,6 +824,9 @@ async def multi_replay( ... speed=10, ... ) """ + for channel in channels: + _validate_historical_l4_channel(channel) + msg: dict[str, Any] = { "op": "replay", "channels": channels, @@ -819,6 +867,7 @@ async def stream( >>> await ws.stream("orderbook", "ETH", start=..., end=..., batch_size=1000) >>> await ws.stream("candles", "BTC", start=..., end=..., interval="1h") """ + _validate_historical_l4_channel(channel) msg = { "op": "stream", "channel": channel, @@ -869,6 +918,9 @@ async def multi_stream( ... batch_size=1000, ... ) """ + for channel in channels: + _validate_historical_l4_channel(channel) + msg: dict[str, Any] = { "op": "stream", "channels": channels, @@ -904,20 +956,23 @@ 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. - Sent once after subscribing to an ``l4_diffs``-family channel, before - the batch stream begins. The callback receives - ``(channel, coin, message)`` where ``message`` is the full raw dict: - ``message["data"]`` holds the book (``bids``/``asks``), - ``message["last_block_number"]`` the block of the last applied diff. - Large symbols can be tens of MB of JSON. + Both live L4 subscriptions and Hyperliquid core L4 replay emit one + ``l4_snapshot`` before any ``l4_batch`` messages. 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 + ``message["last_block_number"]`` identifies its applied boundary. """ self._on_l4_snapshot = handler def on_l4_batch(self, handler: Callable[[str, str, list], None]) -> None: - """Set handler for batched L4 data (~100ms windows). + """Set handler for ordered L4 diff/order batches. - The callback receives ``(channel, coin, records)`` where each record - is one raw diff or order event dict. + The generic :meth:`on_message` handler receives a typed + :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. """ self._on_l4_batch = handler @@ -1196,21 +1251,22 @@ def _handle_message(self, raw: str) -> None: liqs = _transform_liquidations(coin, raw_data) self._on_liquidations(coin, liqs) - # L4 channel frames: initial snapshot then ~100ms batches. - # Previously unhandled and silently dropped. + # L4 channel frames: an initial snapshot followed by ordered batches. + # The generic handler receives typed envelopes; the dedicated handlers + # retain their established raw payload callback shapes. elif msg_type == "l4_snapshot": + msg = WsL4Snapshot.model_validate(data) + if self._on_message: + self._on_message(msg) if self._on_l4_snapshot: - self._on_l4_snapshot( - data.get("channel", ""), data.get("coin", ""), data - ) + self._on_l4_snapshot(msg.channel, msg.coin, data) elif msg_type == "l4_batch": + msg = WsL4Batch.model_validate(data) + if self._on_message: + self._on_message(msg) if self._on_l4_batch: - self._on_l4_batch( - data.get("channel", ""), - data.get("coin", ""), - data.get("data", []), - ) + self._on_l4_batch(msg.channel, msg.coin, msg.data) # Replay messages (Option B) elif msg_type == "replay_started" and self._on_replay_start: diff --git a/tests/test_coverage_contract.py b/tests/test_coverage_contract.py index 31d2d70..aff0259 100644 --- a/tests/test_coverage_contract.py +++ b/tests/test_coverage_contract.py @@ -7,7 +7,12 @@ from oxarchive.exchanges import Hip3Client, Hip4Client, LighterClient, SpotClient from oxarchive.http import HttpClient from oxarchive.resources.l3_orderbook import L3OrderBookResource -from oxarchive.types import CandleInterval, Hip4OpenInterestRecord, OpenInterest +from oxarchive.types import ( + BreadthSnapshot, + CandleInterval, + Hip4OpenInterestRecord, + OpenInterest, +) class FakeHttp: @@ -332,3 +337,139 @@ def test_spot_candle_history_supports_all_verified_intervals_and_async() -> None assert result.data[0].open == 0.2 assert http.calls[-1][1] is not None assert http.calls[-1][1]["cursor"] == "1742644222000" + + +def test_g1_to_g4_public_copy_has_current_contracts() -> None: + root = Path(__file__).resolve().parents[1] + readme = (root / "README.md").read_text() + changelog = (root / "CHANGELOG.md").read_text() + types = (root / "oxarchive" / "types.py").read_text() + websocket = (root / "oxarchive" / "websocket.py").read_text() + + assert "client.hyperliquid.hip3.breadth.current()" in readme + assert "history begins on 2026-08-28" in readme.lower() + assert "value_pct" in readme and "not zero" in readme + assert "about every five minutes" in readme + assert "45-minute" not in readme + assert "fractional, non-annualized" in readme + assert "fractional" in changelog and "non-annualized" in changelog + assert "45-minute" not in changelog + assert "l4_snapshot" in websocket and "ordered" in websocket + assert "l4_snapshot" in types and "live-only" in types + + +def _breadth_snapshot(*, value_pct: float | None = 20.93) -> dict[str, Any]: + return { + "session_date": "2026-08-28", + "calculated_at": "2026-08-28T20:54:00Z", + "value_pct": value_pct, + "coverage_ratio": 0.382, + "counts": { + "candidates": 225, + "eligible": 86, + "above": 18, + "at": 0, + "below": 68, + "excluded_no_session_volume": 75, + "excluded_stale_price": 64, + }, + "namespaces": { + "eligible": {"xyz": 41}, + "above": {"xyz": 9}, + "at": {}, + "below": {"xyz": 32}, + }, + } + + +def test_hip3_breadth_exposes_typed_current_and_history_contract() -> None: + snapshot = _breadth_snapshot() + response = { + "data": snapshot, + "meta": {"count": 1, "request_id": "breadth-current", "next_cursor": None}, + } + http = FakeHttp(response) + client = Hip3Client(cast(HttpClient, http)) + + current = client.breadth.current() + + assert isinstance(current, BreadthSnapshot) + assert current.session_date.isoformat() == "2026-08-28" + assert current.value_pct == 20.93 + assert current.counts.eligible == 86 + assert current.namespaces.above["xyz"] == 9 + assert http.calls[0] == ("/v1/hyperliquid/hip3/breadth/above-vwap/current", None) + + response["data"] = [snapshot] + response["meta"] = { + "count": 1, + "request_id": "breadth-history", + "next_cursor": "1788036840000", + } + result = client.breadth.history( + start="2026-08-28T20:00:00Z", + end="2026-08-28T21:00:00Z", + interval="1h", + limit=1000, + cursor="1788036840000", + ) + + assert isinstance(result.data[0], BreadthSnapshot) + assert result.next_cursor == "1788036840000" + path, params = http.calls[1] + assert path == "/v1/hyperliquid/hip3/breadth/above-vwap" + assert params is not None + assert isinstance(params["start"], int) + assert isinstance(params["end"], int) + assert params["interval"] == "1h" + assert params["limit"] == 1000 + assert params["cursor"] == "1788036840000" + + with pytest.raises(ValueError, match="1000"): + client.breadth.history(limit=1001) + with pytest.raises(ValueError, match="interval"): + client.breadth.history(interval=cast(Any, "1m")) + assert len(http.calls) == 2 + + +def test_hip3_breadth_supports_async_methods_and_null_is_not_zero() -> None: + empty_snapshot = _breadth_snapshot(value_pct=None) + empty_snapshot["coverage_ratio"] = 0.0 + empty_snapshot["counts"] = { + "candidates": 0, + "eligible": 0, + "above": 0, + "at": 0, + "below": 0, + "excluded_no_session_volume": 0, + "excluded_stale_price": 0, + } + empty_snapshot["namespaces"] = { + "eligible": {}, + "above": {}, + "at": {}, + "below": {}, + } + response = { + "data": empty_snapshot, + "meta": {"count": 1, "request_id": "breadth-empty", "next_cursor": None}, + } + http = FakeHttp(response) + client = Hip3Client(cast(HttpClient, http)) + + current = asyncio.run(client.breadth.acurrent()) + + assert current.value_pct is None + assert current.counts.eligible == 0 + + response["data"] = [empty_snapshot] + history = asyncio.run( + client.breadth.ahistory( + start=1788033600000, + end=1788037200000, + cursor="1788036840000", + ) + ) + assert history.data[0].value_pct is None + assert http.calls[-1][1] is not None + assert http.calls[-1][1]["cursor"] == "1788036840000" diff --git a/tests/test_websocket_capabilities.py b/tests/test_websocket_capabilities.py index 7717584..00cb132 100644 --- a/tests/test_websocket_capabilities.py +++ b/tests/test_websocket_capabilities.py @@ -1,8 +1,10 @@ import asyncio +import json import re import pytest +from oxarchive.types import WsL4Batch, WsL4Snapshot from oxarchive.websocket import ( LIGHTER_REPLAY_CHANNELS, LIGHTER_SUBSCRIPTION_ERROR, @@ -69,3 +71,121 @@ def test_hyperliquid_live_subscription_remains_allowed() -> None: ws.subscribe("orderbook", "BTC") assert ws._subscriptions == {"orderbook:BTC"} + + +@pytest.mark.parametrize( + "channel", + [ + "hip3_l4_diffs", + "hip3_l4_orders", + "hip4_l4_diffs", + "hip4_l4_orders", + "spot_l4_diffs", + "spot_l4_orders", + ], +) +def test_non_core_l4_channels_remain_live_only_for_replay(channel: str) -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + sent: list[dict] = [] + + async def fake_send(message: dict) -> None: + sent.append(message) + + ws._send = fake_send # type: ignore[method-assign] + + with pytest.raises(ValueError, match="live subscriptions only"): + asyncio.run(ws.replay(channel, "BTC", start=1_757_000_000_000)) + + assert sent == [] + + +@pytest.mark.parametrize("channel", ["hip3_l4_diffs", "hip4_l4_orders", "spot_l4_diffs"]) +def test_non_core_l4_channels_remain_live_only_for_multi_replay(channel: str) -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + sent: list[dict] = [] + + async def fake_send(message: dict) -> None: + sent.append(message) + + ws._send = fake_send # type: ignore[method-assign] + + 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_core_l4_replay_command_remains_allowed() -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + sent: list[dict] = [] + + async def fake_send(message: dict) -> None: + sent.append(message) + + ws._send = fake_send # type: ignore[method-assign] + + asyncio.run( + ws.replay( + "l4_orders", + "BTC", + start=1_757_000_000_000, + end=1_757_003_600_000, + ) + ) + + assert sent == [ + { + "op": "replay", + "channel": "l4_orders", + "symbol": "BTC", + "start": 1_757_000_000_000, + "speed": 1.0, + "end": 1_757_003_600_000, + } + ] + + +def test_l4_replay_frames_are_typed_and_batch_order_is_preserved() -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + messages: list[object] = [] + snapshots: list[dict] = [] + batches: list[list[dict]] = [] + ws.on_message(messages.append) + ws.on_l4_snapshot(lambda _channel, _coin, message: snapshots.append(message)) + ws.on_l4_batch(lambda _channel, _coin, records: batches.append(records)) + + ws._handle_message( + json.dumps( + { + "type": "l4_snapshot", + "channel": "l4_diffs", + "coin": "BTC", + "symbol": "BTC", + "last_block_number": 100, + "timestamp": 1_757_000_000_000, + "data": {"bids": [], "asks": []}, + } + ) + ) + ws._handle_message( + json.dumps( + { + "type": "l4_batch", + "channel": "l4_diffs", + "coin": "BTC", + "symbol": "BTC", + "data": [ + {"block_number": 101, "seq": 1, "side": "B"}, + {"block_number": 101, "seq": 2, "side": "A"}, + ], + } + ) + ) + + assert isinstance(messages[0], WsL4Snapshot) + assert isinstance(messages[1], WsL4Batch) + assert messages[0].symbol == "BTC" + assert messages[1].data[0]["seq"] == 1 + assert messages[1].data[1]["seq"] == 2 + assert snapshots[0]["last_block_number"] == 100 + assert [record["seq"] for record in batches[0]] == [1, 2] From 3f73386f32f987a4bb6a42c7fc951a3872152420 Mon Sep 17 00:00:00 2001 From: Path Dependable <104703139+nuttykenzo@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:16:06 +0000 Subject: [PATCH 4/4] fix: support every HIP-3 breadth interval --- CHANGELOG.md | 3 ++- README.md | 2 +- oxarchive/resources/breadth.py | 4 ++-- tests/test_coverage_contract.py | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 485bf8a..ca45210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - HIP-3 breadth above current UTC-session VWAP via `client.hyperliquid.hip3.breadth.current()` and cursor-paginated `.history()`; collection begins on 2026-08-28 and `value_pct` remains null - when no instrument is eligible. + when no instrument is eligible. History accepts `5m`, `15m`, `30m`, `1h`, + `4h`, and `1d` downsampling intervals. - Typed Hyperliquid core L4 replay frames: `l4_snapshot` is followed by ordered `l4_batch` events for `l4_diffs` and `l4_orders`. HIP-3, HIP-4, and Hyperliquid Spot L4 remain live-only. diff --git a/README.md b/README.md index d6bf574..bc1c9f1 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,7 @@ print(f"{current.value_pct}% above VWAP ({current.counts.eligible} eligible)") history = client.hyperliquid.hip3.breadth.history( start="2026-08-28T00:00:00Z", end="2026-08-29T00:00:00Z", - interval="5m", # 5m, 1h, 1d + interval="5m", # 5m, 15m, 30m, 1h, 4h, 1d limit=1000, ) while history.next_cursor: diff --git a/oxarchive/resources/breadth.py b/oxarchive/resources/breadth.py index f9db181..622a577 100644 --- a/oxarchive/resources/breadth.py +++ b/oxarchive/resources/breadth.py @@ -8,8 +8,8 @@ from ..http import HttpClient from ..types import BreadthSnapshot, CursorResponse, Timestamp -BreadthInterval = Literal["5m", "1h", "1d"] -BREADTH_INTERVALS = frozenset({"5m", "1h", "1d"}) +BreadthInterval = Literal["5m", "15m", "30m", "1h", "4h", "1d"] +BREADTH_INTERVALS = frozenset({"5m", "15m", "30m", "1h", "4h", "1d"}) class BreadthResource: diff --git a/tests/test_coverage_contract.py b/tests/test_coverage_contract.py index aff0259..7994390 100644 --- a/tests/test_coverage_contract.py +++ b/tests/test_coverage_contract.py @@ -432,6 +432,21 @@ def test_hip3_breadth_exposes_typed_current_and_history_contract() -> None: assert len(http.calls) == 2 +@pytest.mark.parametrize("interval", ["5m", "15m", "30m", "1h", "4h", "1d"]) +def test_hip3_breadth_accepts_every_served_history_interval(interval: str) -> None: + response = { + "data": [_breadth_snapshot()], + "meta": {"count": 1, "request_id": "breadth-history", "next_cursor": None}, + } + http = FakeHttp(response) + client = Hip3Client(cast(HttpClient, http)) + + client.breadth.history(interval=cast(Any, interval)) + + assert http.calls[0][1] is not None + assert http.calls[0][1]["interval"] == interval + + def test_hip3_breadth_supports_async_methods_and_null_is_not_zero() -> None: empty_snapshot = _breadth_snapshot(value_pct=None) empty_snapshot["coverage_ratio"] = 0.0