diff --git a/.gitignore b/.gitignore index 4b1ffad..44f2d55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ -# Tests (internal only, not published) +# Tests: only the files listed below are published in this repository; any +# other file under tests/ stays local. No test file is packaged: the sdist +# and wheel allowlists in pyproject.toml cover oxarchive/ and metadata only. tests/* !tests/test_coverage_contract.py +!tests/test_websocket_capabilities.py +!tests/test_positions.py +!tests/test_rh_lighter.py +!tests/_mock_api.py # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 999bca0..d648441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,19 +5,111 @@ 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). -## [Unreleased] +## [1.12.0] - 2026-09-28 + +Builds on 1.11.0. Two venues, Hyperliquid and Lighter; Lighter now has two +deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain +(`client.rh_lighter`). ### Added +- Lighter on Robinhood Chain: `client.rh_lighter` (`RhLighterClient`), served + under `/v1/rh-lighter`. It has the same resources as `client.lighter` except + the L3 order book and the L1 account resolver: `instruments`, `orderbook`, + `trades` (`list()` canonical up to `meta.finalized_through`, `recent()` + preliminary), `candles`, `open_interest`, `funding`, `liquidations`, + `positions`, and `get_freshness()`, `get_summary()`, `get_price_history()`. + Markets are USDG-quoted: perps use uppercase symbols (`BTC`), spot markets + dashed symbols (`AAPL-USDG`). Trades and liquidations start at the venue + launch, 2026-06-26 20:10:26 UTC; order book, open interest and funding + start 2026-08-22 18:43 UTC. Candles are served from 2026-06-26 once enabled + for this deployment. +- Lighter liquidations on both deployments: `client.lighter.liquidations` and + `client.rh_lighter.liquidations` with `history()` and `volume()` (and async + `ahistory()` / `avolume()`), typed as `LighterLiquidation` and + `LighterLiquidationVolume`. A row keeps both sides' account fields. On + Robinhood Chain, rows from before live capture were backfilled from the + venue's finalized export and have `source == "bucket"` and an empty + `raw_json`; rows captured live have `source == "ws"` and the venue's raw + JSON. Volume buckets carry `total_usd` and `count`. +- Account positions on four clients with the same method names: + `client.hyperliquid.positions`, `client.hyperliquid.hip3.positions`, + `client.lighter.positions` and `client.rh_lighter.positions`. + - `get(key, timestamp=None, symbol=None)`: open positions now, or as of any + instant (an exact hour serves the hourly snapshot; any other instant is + reconstructed from the change log). Returns `WalletPositions` + (`positions`, `account`, `account_seen`). On Lighter, `account` holds + position aggregates only (totals, long/short value, `n_positions`). + - `history(key, start, end)`, `changes(key, start, end)`: hourly position + rows and the change log of every leg that moved a position. + - `market(symbol, hour=None, side=None, min_value=None)`, + `market_summary(symbol, start=None, end=None)`, `all(hour)`: every open + position in a market (with `meta.totals`), long/short aggregates, and a + bulk listing across markets at one hour. + - Hyperliquid and HIP-3 only: `account(address)` and + `account_history(address, start, end)`. HIP-3 methods take an optional + `dex`; Lighter market methods take `include_system`. + - `key` is a 0x address on Hyperliquid and HIP-3, and an integer account + index on Lighter. `client.lighter.accounts.by_l1(l1_address)` resolves an + L1 address to its account indices (Lighter mainnet only). + - Cursor-following iterators: `iterate_history()`, `iterate_changes()`, + `iterate_market()`, `iterate_market_summary()`, `iterate_all()`, + `iterate_account_history()`, `client.lighter.accounts.iterate_by_l1()`, + and `aiterate_*` async versions. Every method has an `a`-prefixed async + version. + - Typed models: `Position`, `PositionLeverage`, `PositionCumFunding`, + `PositionChange`, `MarketPosition`, `MarketPositionsSummary`, + `AccountSummary`, `WalletPositions`, `LighterL1Account`, + `LighterL1Accounts`. + - Coverage: Hyperliquid change log from 2025-05-25, HIP-3 from 2025-10-13, + hourly history from 2026-06-07, live every 5 minutes; Lighter mainnet from + 2025-01-17 and Robinhood Chain from 2026-06-26, hourly, live every + 2 minutes. Position rows are billed like trades, 1,000 rows per credit; + the account summary routes and `by_l1()` are billed at the per-request + minimum. +- `ResponseMeta` and `CursorResponse.meta`: the response's `meta`, typed, with + `finalized_through`, `requested_end`, `clamped_to`, `coverage_from`, + `notice`, and the new optional fields `as_of`, `snapshot_ts`, `source`, + `quality`, `stale`, `totals` and `built_through`. Set on account positions, + `trades.list()` and Lighter liquidations; `None` elsewhere. Unknown fields + are kept. `trades.recent()` still returns a plain list, so the + `preliminary_row_count` the API sends on the Lighter `/recent` response is + not surfaced in this release. +- Live Lighter on Robinhood Chain WebSocket channels `rh_lighter_orderbook`, + `rh_lighter_trades`, `rh_lighter_open_interest` and `rh_lighter_funding`, + with the same message shapes as the mainnet `lighter_*` channels, served on + `wss://api.0xarchive.io/ws`. `rh_lighter_orderbook` accepts `interval_ms` + (100 to 5000, default one book per second). New helpers + `subscribe_rh_lighter_orderbook()`, `subscribe_rh_lighter_trades()`, + `subscribe_rh_lighter_open_interest()`, `subscribe_rh_lighter_funding()` + and their `unsubscribe_*` counterparts, and handlers + `on_rh_lighter_orderbook()`, `on_rh_lighter_trades()` and + `on_rh_lighter_market_context()`. Robinhood Chain messages never reach the + mainnet `on_lighter_*` handlers; without a dedicated handler, books and + trades fall back to `on_orderbook()` and `on_trades()`. +- `rh_lighter_candles` for replay. All five `rh_lighter_*` channels support + historical replay and multi-channel replay within their family. +- `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: it arrives with an - API switch, and until then `next_cursor` on an order-flow response is - `None`. + The API does not return `next_cursor` on order flow yet, so `next_cursor` + on an order-flow response is `None`. ### Changed +- `WsChannel` includes the five `rh_lighter_*` channels. +- `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 + `lighter_orderbook`. The out-of-range error names the channel it was + passed for. +- Data quality docstrings list every venue scope: `hyperliquid`, `hip3`, + `hip4`, `spot`, `lighter` and `rh-lighter`. +- `LighterClient` shares its resources with `RhLighterClient` through a common + base class; its paths and behavior are unchanged. - `client.hyperliquid.hip3.breadth.history()` and `ahistory()` accept - `interval="1m"`. The API now serves 1-minute buckets on breadth, open + `interval="1m"`. The API serves 1-minute buckets on breadth, open interest, funding, price and liquidation-volume history for every venue. The other methods already pass `interval` through unchanged. - The order-flow docstring and README list the buckets the API serves: `1m` diff --git a/README.md b/README.md index ab5442b..9c9a5fb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Python client for 0xArchive market data in notebooks, research scripts, and data pipelines. -0xArchive is granular market data infrastructure for Hyperliquid and Lighter.xyz. Hyperliquid includes core perps, HIP-3 builder perps, HIP-4 outcome markets, and Hyperliquid Spot. HIP-3, HIP-4, and Spot live under the Hyperliquid namespace (`/v1/hyperliquid/hip3`, `client.hyperliquid.hip3`, etc.). Lighter.xyz is the second top-level venue API. +0xArchive is granular market data infrastructure for two venues: Hyperliquid and Lighter. Hyperliquid includes core perps, HIP-3 builder perps, HIP-4 outcome markets, and Hyperliquid Spot. HIP-3, HIP-4, and Spot live under the Hyperliquid namespace (`/v1/hyperliquid/hip3`, `client.hyperliquid.hip3`, etc.). Lighter has two deployments: mainnet (`/v1/lighter`, `client.lighter`) and Robinhood Chain (`/v1/rh-lighter`, `client.rh_lighter`). Account positions are available on Hyperliquid, HIP-3, and both Lighter deployments. Use the Python SDK when the workflow already lives in Python and you want typed REST helpers, async support, WebSocket support, pagination, and reconstruction utilities before moving into a larger pipeline. @@ -36,6 +36,14 @@ print(f"Hyperliquid BTC mid price: {hl_orderbook.mid_price}") lighter_orderbook = client.lighter.orderbook.get("BTC") print(f"Lighter BTC mid price: {lighter_orderbook.mid_price}") +# Lighter on Robinhood Chain: the second Lighter deployment (USDG-quoted) +rh_orderbook = client.rh_lighter.orderbook.get("AAPL-USDG") + +# Account positions: what a wallet holds now +positions = client.hyperliquid.positions.get("0xabc...") +for p in positions.data.positions: + print(p.symbol, p.side, p.size, p.unrealized_pnl) + # Hyperliquid HIP-3 builder perps stay under client.hyperliquid.hip3 hip3_instruments = client.hyperliquid.hip3.instruments.list() hip3_orderbook = client.hyperliquid.hip3.orderbook.get("km:US500") @@ -77,6 +85,8 @@ history = client.hyperliquid.orderbook.history( | Hyperliquid HIP-4 | May 2026+ | Outcome markets. Candles and outcome-side OI are served from 2026-05-02; OI updates at ~10s. No funding or liquidations. | | Hyperliquid Spot | Trades and candles from 2025-03-22; candle coverage starts exactly 2025-03-22T10:50:22Z; orderbook, L4, TWAP, and orders from 2026-05 | 326 authenticated inventory rows using dashed canonical symbols (`HYPE-USDC`, `PURR-USDC`). Candle intervals are 1m/5m/15m/30m/1h/4h/1d/1w with a 1,000-row page cap and numeric timestamp-string cursors; pass each `next_cursor` back unchanged. No funding/OI/liquidations. | | Lighter.xyz | Observed global per-fill trade floor January 17, 2025; exact starts vary by market. L3 from March 5, 2026+ | Maker/taker trade context; L3 caps at 250 orders per side; funding/OI update at ~10s. | +| Lighter on Robinhood Chain | Trades and liquidations from 2026-06-26 20:10:26 UTC (venue launch); order book, OI, and funding from 2026-08-22 18:43 UTC | The second Lighter deployment. 84 USDG-quoted markets: 57 perps (`BTC`) and 27 spot (`AAPL-USDG`). Candles from 2026-06-26 once enabled. No L3. | +| Account positions | Hyperliquid change log from 2025-05-25, HIP-3 from 2025-10-13, hourly history from 2026-06-07; Lighter mainnet from 2025-01-17, Robinhood Chain from 2026-06-26 | Live snapshots every 5 minutes (Hyperliquid, HIP-3) or 2 minutes (Lighter). See [Account Positions](#account-positions). | ## Async Support @@ -527,6 +537,50 @@ twap = await client.spot.twap.aby_user("0xabc...", start=..., end=...) fresh = await client.spot.aget_freshness("HYPE-USDC") ``` +#### Lighter on Robinhood Chain + +Lighter has two deployments: mainnet (`client.lighter`) and Robinhood Chain (`client.rh_lighter`, REST root `/v1/rh-lighter`). The Robinhood Chain deployment has the same resources as `client.lighter` except the L3 order book, which is not captured there, and the L1 account resolver. Markets are quoted in USDG. Perps use uppercase symbols (`BTC`); spot markets use dashed symbols (`AAPL-USDG`). Symbols are case-insensitive. Market symbols and ids belong to each deployment, so `BTC` on `client.rh_lighter` is a different market from `BTC` on `client.lighter`. + +Coverage: trades and liquidations from the venue launch, 2026-06-26 20:10:26 UTC; order book, open interest, and funding from 2026-08-22 18:43 UTC; account positions from 2026-06-26. A request that starts before a data type's first date is refused with the API's coverage error. Liquidations from before live capture were backfilled from the venue's finalized export: those rows have `source == "bucket"` and an empty `raw_json`, while rows captured live have `source == "ws"` and the venue's raw JSON. Candles are served from 2026-06-26 once they are enabled for this deployment; until then `candles.history()` raises `OxArchiveError` with the server's message. + +Trades follow the same finalization contract as mainnet Lighter. `trades.list()` returns canonical trades only: `end` is clamped to the finalization watermark, about a day behind, reported as `result.meta.finalized_through`, with `meta.requested_end` and `meta.clamped_to` set when the clamp applied. `trades.recent()` serves the preliminary tier. + +```python +rh = client.rh_lighter + +# Markets +markets = rh.instruments.list() +aapl = rh.instruments.get("AAPL-USDG") + +# Order book (from 2026-08-22 18:43 UTC) +book = rh.orderbook.get("AAPL-USDG") +books = rh.orderbook.history("BTC", start="2026-09-01", end="2026-09-01T01:00:00Z") + +# Trades (from 2026-06-26 20:10:26 UTC) +page = rh.trades.list("BTC", start="2026-09-20", end="2026-09-21", limit=1000) +print(page.meta.finalized_through, page.meta.clamped_to) +recent = rh.trades.recent("BTC") # preliminary tier + +# Open interest and funding (perps) +oi = rh.open_interest.current("BTC") +funding = rh.funding.history("BTC", start="2026-09-01", end="2026-09-02") + +# Liquidations and liquidation volume (from 2026-06-26 20:10:26 UTC) +liqs = rh.liquidations.history("BTC", start="2026-09-01", end="2026-09-08") +volume = rh.liquidations.volume("BTC", start="2026-09-01", end="2026-09-08", interval="1d") + +# Freshness, summary, price history +fresh = rh.get_freshness("BTC") +summary = rh.get_summary("BTC") +prices = rh.get_price_history("BTC", start="2026-09-01", end="2026-09-02", interval="1h") + +# Account positions by Lighter account index (perp markets) +positions = rh.positions.get(4521) + +# Async versions are available on every method +book = await rh.orderbook.aget("AAPL-USDG") +``` + ### Funding Rates ```python @@ -615,7 +669,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. The projected forced-liquidation price-level endpoints refresh about every five minutes. This is a measured cadence, not an exact five-minute guarantee. +Get historical liquidation events. Available for Hyperliquid (May 2025+), HIP-3, and both Lighter deployments (`client.lighter.liquidations`, `client.rh_lighter.liquidations`). The projected forced-liquidation price-level endpoints refresh about every five minutes. This is a measured cadence, not an exact five-minute guarantee. ```python # Get liquidation history for a coin (Hyperliquid) @@ -662,8 +716,24 @@ hip3_volume = client.hyperliquid.hip3.liquidations.volume( interval="1h" ) +# Lighter liquidations (mainnet and Robinhood Chain). A row keeps both sides' +# account fields; see LighterLiquidation. Pass next_cursor back unchanged. +lighter_liquidations = client.lighter.liquidations.history( + "BTC", + start="2026-09-01", + end="2026-09-02", + limit=1000 +) +for liq in lighter_liquidations.data: + print(liq.timestamp, liq.price, liq.size, liq.usd_amount, liq.ask_account, liq.bid_account) + +# Robinhood Chain liquidations start at the venue launch, 2026-06-26 20:10:26 UTC. +# Rows from before live capture have source == "bucket" and an empty raw_json. +rh_liquidations = client.rh_lighter.liquidations.history("BTC", start="2026-07-01", end="2026-07-02") + # Async versions liquidations = await client.hyperliquid.liquidations.ahistory("BTC", start=..., end=...) +lighter_liquidations = await client.lighter.liquidations.ahistory("BTC", start=..., end=...) user_liquidations = await client.hyperliquid.liquidations.aby_user("0x...", start=..., end=...) hip3_liquidations = await client.hyperliquid.hip3.liquidations.ahistory("km:US500", start=..., end=...) hip3_volume = await client.hyperliquid.hip3.liquidations.avolume("km:US500", start=..., end=...) @@ -671,7 +741,7 @@ hip3_volume = await client.hyperliquid.hip3.liquidations.avolume("km:US500", sta ### Liquidation Volume -Get pre-aggregated liquidation volume in time-bucketed intervals. Returns total, long, and short USD volumes per bucket -- 100-1000x less data than individual liquidation records. Available for Hyperliquid and HIP-3. +Get pre-aggregated liquidation volume in time-bucketed intervals. Returns total, long, and short USD volumes per bucket -- 100-1000x less data than individual liquidation records. Available for Hyperliquid and HIP-3, and for both Lighter deployments, where each bucket carries `total_usd` and `count` only (no long/short split). ```python # Get hourly liquidation volume for the last week (Hyperliquid) @@ -693,6 +763,10 @@ hip3_volume = client.hyperliquid.hip3.liquidations.volume( interval="1d" ) +# Lighter liquidation volume (mainnet and Robinhood Chain): total_usd and count per bucket +lighter_volume = client.lighter.liquidations.volume("BTC", start=..., end=..., interval="1h") +rh_volume = client.rh_lighter.liquidations.volume("BTC", start=..., end=..., interval="1d") + # Convenience method on HyperliquidClient (Hyperliquid only) volume = client.hyperliquid.get_liquidation_volume("BTC", start=..., end=..., interval="1h") @@ -716,6 +790,9 @@ print(f"OI last updated: {freshness.open_interest.last_updated}") # Lighter.xyz lighter_freshness = client.lighter.get_freshness("BTC") +# Lighter on Robinhood Chain +rh_freshness = client.rh_lighter.get_freshness("BTC") + # HIP-3 (case-sensitive coins) hip3_freshness = client.hyperliquid.hip3.get_freshness("km:US500") @@ -744,6 +821,9 @@ print(f" Short: ${summary.short_liquidation_volume_24h}") # 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) hip3_summary = client.hyperliquid.hip3.get_summary("km:US500") print(f"Mid price: {hip3_summary.mid_price}") @@ -773,6 +853,9 @@ for snapshot in prices.data: # Lighter.xyz lighter_prices = client.lighter.get_price_history("BTC", start="2026-01-01", end="2026-01-02", interval="1h") +# Lighter on Robinhood Chain +rh_prices = client.rh_lighter.get_price_history("BTC", start="2026-09-01", end="2026-09-02", interval="1h") + # HIP-3 (case-sensitive coins) hip3_prices = client.hyperliquid.hip3.get_price_history("km:US500", start="2026-02-01", end="2026-02-02", interval="1d") @@ -824,6 +907,10 @@ lighter_candles = client.lighter.candles.history( interval="15m" ) +# Lighter on Robinhood Chain candles (from 2026-06-26, once enabled for this +# deployment; until then the call raises OxArchiveError) +rh_candles = client.rh_lighter.candles.history("BTC", start="2026-09-01", end="2026-09-02", interval="1h") + # HIP-3 candles (case-sensitive coins) hip3_candles = client.hyperliquid.hip3.candles.history( "km:US500", @@ -1047,9 +1134,107 @@ hip3_orders = await client.hyperliquid.hip3.orders.ahistory("km:US500", start=.. | `flow(symbol, *, start, end, interval, limit)` | Get order flow aggregation | | `tpsl(symbol, *, start, end, user, triggered, cursor, limit)` | Get TP/SL history | +### 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: + +| Client | Account key | Change log from | Hourly history from | Live snapshot | +|--------|-------------|-----------------|---------------------|---------------| +| `client.hyperliquid.positions` | 0x wallet address | 2025-05-25 | 2026-06-07 | every 5 minutes | +| `client.hyperliquid.hip3.positions` | 0x wallet address (optional `dex`) | 2025-10-13 | 2026-06-07 | every 5 minutes | +| `client.lighter.positions` | integer Lighter account index | 2025-01-17 | 2025-01-17 | every 2 minutes | +| `client.rh_lighter.positions` | integer Lighter account index | 2026-06-26 | 2026-06-26 | every 2 minutes | + +Lighter positions cover perp markets. On Lighter mainnet, `client.lighter.accounts.by_l1(address)` resolves an L1 address to the account indices it owns. + +```python +# Now: the latest live snapshot +now = client.hyperliquid.positions.get("0xabc...") +for p in now.data.positions: + print(p.symbol, p.side, p.size, p.entry_price, p.unrealized_pnl, p.quality) +print(now.data.account) # AccountSummary on the first page of a snapshot, or None +print(now.meta.as_of, now.meta.quality, now.meta.stale) + +# As of an instant: the state after every event before it. An exact UTC hour +# serves the hourly snapshot; any other instant is reconstructed from the +# change log (meta.source == "reconstructed"; mark fields are at the instant). +then = client.hyperliquid.positions.get("0xabc...", timestamp="2026-09-01T12:34:56Z") +print(then.meta.source, then.meta.built_through, then.meta.clamped_to) + +# Hourly history and the change log in [start, end), following cursors +for row in client.hyperliquid.positions.iterate_history("0xabc...", start="2026-09-01", end="2026-09-02"): + print(row.snapshot_ts, row.symbol, row.size) +for leg in client.hyperliquid.positions.iterate_changes("0xabc...", start="2026-09-01", end="2026-09-02", symbol="BTC"): + print(leg.timestamp, leg.event_type, leg.start_position, "->", leg.end_position, leg.closed_pnl) + +# Account summaries (Hyperliquid and HIP-3) +summary = client.hyperliquid.positions.account("0xabc...") +hourly = client.hyperliquid.positions.account_history("0xabc...", start="2026-09-01", end="2026-09-02") + +# HIP-3: dex narrows a wallet to one dex; symbols are case-sensitive +hip3 = client.hyperliquid.hip3.positions.get("0xabc...", dex="xyz") + +# Every open position in a market, largest value first; totals on the first page +page = client.hyperliquid.positions.market("BTC", side="long", min_value=1_000_000) +print(page.meta.totals.long_count, page.meta.totals.top10_value_share) +at_hour = client.hyperliquid.positions.market("BTC", hour="2026-09-25T12:00:00Z") + +# Long/short aggregates: now, or one per hour over [start, end) +now_summary = client.hyperliquid.positions.market_summary("BTC") +series = client.hyperliquid.positions.market_summary("BTC", start="2026-09-20", end="2026-09-21") + +# Bulk: every open position across markets at one hourly snapshot +for row in client.hyperliquid.positions.iterate_all("2026-09-25T12:00:00Z"): + print(row.user_address, row.symbol, row.size) + +# Lighter (mainnet and Robinhood Chain): integer account indices +owned = client.lighter.accounts.by_l1("0xabc...") +for account in owned.data.accounts: + lighter_now = client.lighter.positions.get(int(account.account_index)) + # Without a symbol filter, the first page carries position aggregates + # (total_position_value, total_unrealized_pnl, long_value, short_value, n_positions). + print(lighter_now.data.account) +changes = client.lighter.positions.changes(4521, start="2026-09-01", end="2026-09-02") +print(changes.meta.finalized_through) # legs before it are final +market = client.lighter.positions.market("BTC", include_system=True) # include system accounts +rh_now = client.rh_lighter.positions.get(4521) + +# Async versions of every method (aget, ahistory, achanges, amarket, ...) +# and of every iterator (aiterate_history, aiterate_changes, ...) +now = await client.hyperliquid.positions.aget("0xabc...") +async for leg in client.lighter.positions.aiterate_changes(4521, start=..., end=...): + ... +``` + +**Methods** (on every positions client unless noted): + +| Method | Returns | +|--------|---------| +| `get(key, *, timestamp, symbol, dex, cursor, limit)` | `CursorResponse[WalletPositions]`: `positions`, `account`, `account_seen` | +| `history(key, *, start, end, symbol, dex, cursor, limit)` | Hourly `Position` rows | +| `changes(key, *, start, end, symbol, dex, cursor, limit)` | `PositionChange` legs | +| `market(symbol, *, hour, side, min_value, include_system, cursor, limit)` | `MarketPosition` rows; `meta.totals` on the first page | +| `market_summary(symbol, *, start, end, include_system, cursor, limit)` | `MarketPositionsSummary` (one now, or one per hour) | +| `all(hour, *, include_system, cursor, limit)` | `MarketPosition` rows across every market at one hour | +| `account(address, *, dex)` | `AccountSummary` rows (Hyperliquid and HIP-3 only) | +| `account_history(address, *, start, end, dex, cursor, limit)` | Hourly `AccountSummary` rows (Hyperliquid and HIP-3 only) | +| `iterate_history`, `iterate_changes`, `iterate_market`, `iterate_market_summary`, `iterate_all`, `iterate_account_history` | Iterators that follow `next_cursor` for you | +| `client.lighter.accounts.by_l1(l1_address, *, cursor, limit)` | `LighterL1Accounts`: `l1_address`, `total_accounts`, `accounts` (Lighter mainnet only) | + +`key` is a 0x address on Hyperliquid and HIP-3 and an integer account index on Lighter. `dex` applies to HIP-3 and `include_system` to Lighter. Timestamps accept Unix milliseconds, ISO strings, or datetimes; `hour` must be an exact UTC hour. + +Things to know: + +- **Numbers are decimal strings.** A flat position is `"0"`. A value is `None` when it is unknown, never a guess. +- **`meta`** carries the context of every response: `as_of` (the instant the state describes), `snapshot_ts`, `source` (`snapshot`, `reconstructed`, or `changes`), `quality`, `stale` (the live snapshot is older than 12 minutes), `built_through` (reads are clamped to it; `clamped_to` and `requested_end` show when they were), `finalized_through` (everything before it is final), and `totals` on market listings. +- **Quality is per row.** `quality` is `complete`, `partial` (for example no mark), or `degraded`; Lighter rows can also be `preliminary`, `unreconciled`, or `incomplete`. +- **Empty results are explained.** When a wallet has no open positions, `account_seen` is `flat`, `never_seen` (no activity recorded in the covered history; see `meta.notice` and `meta.coverage_from`), or `outside_coverage`. +- **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. + ### Data Quality Monitoring -Monitor data coverage, incidents, latency, and SLA compliance across venue APIs. +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). ```python # Get overall system health status @@ -1111,7 +1296,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 specific exchange | +| `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 | | `list_incidents(...)` | List incidents with filtering and pagination | | `get_incident(incident_id)` | Get specific incident details | @@ -1255,12 +1440,14 @@ trades = client.trades.list("BTC", start=..., end=...) ## WebSocket Client -The WebSocket client supports live subscriptions for supported Hyperliquid and Lighter.xyz channels 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 and Lighter channels (mainnet and Robinhood Chain) and historical replay. For file-based historical exports, use the [Data Catalog](https://www.0xarchive.io/data). > WebSocket bulk streaming has been discontinued. For large dataset downloads, use the S3 Parquet bulk export in the [Data Catalog](https://www.0xarchive.io/data). The `stream()`, `multi_stream()`, and `stream_stop()` methods remain for compatibility but are deprecated: each call emits a `DeprecationWarning`, and the server answers with an error message (a `WsError` on `on_message`) instead of data. The `on_batch`, `on_stream_start`, `on_stream_progress`, and `on_stream_complete` handler setters are deprecated too: setting one emits a `DeprecationWarning`, and the handler is never called. > Lighter supports live subscriptions on `lighter_orderbook`, `lighter_trades`, `lighter_open_interest`, and `lighter_funding` at `wss://api.0xarchive.io/ws` (the client default). `lighter_candles` and `lighter_l3_orderbook` remain replay-only. All six Lighter channels support historical replay. +> Lighter on Robinhood Chain supports live subscriptions on `rh_lighter_orderbook`, `rh_lighter_trades`, `rh_lighter_open_interest`, and `rh_lighter_funding` at `wss://api.0xarchive.io/ws` only, with the same message shapes as the mainnet Lighter channels. `rh_lighter_candles` is replay-only. All five channels support historical replay. + ```python import asyncio from oxarchive import OxArchiveWs, WsOptions @@ -1376,6 +1563,28 @@ The generic form works too: `ws.subscribe("lighter_orderbook", "BTC", interval_m **Falling behind**: if your connection falls behind `lighter_trades`, `lighter_open_interest`, or `lighter_funding`, the server sends an error notice (for example `Dropped ~N live lighter_trades messages for BTC: ...`) and continues. If the lag persists, it stops that subscription with `Stopped the lighter_trades stream for BTC: your connection is too slow to keep up. Re-subscribe to resume.`; call the subscribe method again to resume. `lighter_orderbook` always sends the newest book; a book skipped between intervals loses nothing because each book is a full state. +### Live Lighter on Robinhood Chain Data + +The Robinhood Chain deployment of Lighter streams live on its own channels: `rh_lighter_orderbook`, `rh_lighter_trades`, `rh_lighter_open_interest`, and `rh_lighter_funding`. Each message has exactly the shape of its mainnet counterpart described above, `rh_lighter_orderbook` accepts `interval_ms` (100 to 5000, default one book per second), and the metering, limits, and lag notices are the same. Symbols are those returned by `client.rh_lighter.instruments.list()`: uppercase perps (`BTC`) and dashed spot markets (`AAPL-USDG`), case-insensitive on subscribe. These channels are served at `wss://api.0xarchive.io/ws`, the client default, and not at `wss://stream.0xarchive.io/ws`. + +Robinhood Chain symbols collide with mainnet ones, so these messages have their own handlers: `on_rh_lighter_orderbook`, `on_rh_lighter_trades`, and `on_rh_lighter_market_context`. They never reach the mainnet `on_lighter_*` handlers. Without a dedicated handler, books and trades fall back to `on_orderbook` and `on_trades`; `WsData.channel` on `on_message` always names the channel. + +```python +ws.on_rh_lighter_orderbook(lambda coin, book: print(f"Robinhood Chain {coin} mid {book.mid_price}")) +ws.on_rh_lighter_trades(lambda coin, legs: print(f"Robinhood Chain {coin}: {len({l.trade_id for l in legs})} trades")) +ws.on_rh_lighter_market_context(lambda channel, coin, ctx: print(f"{coin} OI {ctx.open_interest}")) + +ws.subscribe_rh_lighter_orderbook("AAPL-USDG") # one book per second (default) +ws.subscribe_rh_lighter_orderbook("BTC", interval_ms=500) +ws.subscribe_rh_lighter_trades("BTC") +ws.subscribe_rh_lighter_funding("BTC") # same message as rh_lighter_open_interest + +# Generic form +ws.subscribe("rh_lighter_orderbook", "BTC", interval_ms=250) +``` + +Live trades are preliminary; `client.rh_lighter.trades.list()` serves the finalized record. + ### Historical Replay Replay historical data with timing preserved. Perfect for backtesting. @@ -1602,6 +1811,18 @@ ws.subscribe_spot_trades("HYPE-USDC") Live Lighter subscriptions are served at `wss://api.0xarchive.io/ws`. Replay of all six channels keeps its historical row shapes, which differ from the live messages described under Live Lighter.xyz Data above. Current Lighter data is also available through the REST resources, and historical Lighter data through REST, WebSocket replay, or exports. +#### Lighter on Robinhood Chain Channels + +| Channel | Description | Requires Coin | Live Subscription | Historical Replay | +|---------|-------------|---------------|-------------------|-------------------| +| `rh_lighter_orderbook` | L2 order book (live: full top-20 book per side, 1 per second by default, `interval_ms` 100 to 5000) | Yes | Yes | Yes (from 2026-08-22) | +| `rh_lighter_trades` | Trade/fill updates (live: two legs per trade) | Yes | Yes | Yes (from 2026-06-26) | +| `rh_lighter_candles` | OHLCV candle data (once candles are enabled for this deployment) | Yes | No | Yes | +| `rh_lighter_open_interest` | Open interest (live: market context, same message as `rh_lighter_funding`) | Yes | Yes | Yes (from 2026-08-22) | +| `rh_lighter_funding` | Funding rates (live: market context, same message as `rh_lighter_open_interest`) | Yes | Yes | Yes (from 2026-08-22) | + +There is no L3 channel for this deployment. Live Robinhood Chain subscriptions are served at `wss://api.0xarchive.io/ws` only. Replay rows keep their historical shapes, like mainnet Lighter replay, and a multi-channel replay must stay within the `rh_lighter_*` family. + #### Candle Replay ```python @@ -1647,7 +1868,7 @@ await ws.replay( #### Open Interest / Funding Replay -The Hyperliquid `open_interest` and `funding` channels support both replay and live subscriptions (see Real-time Streaming above). The `hip3_open_interest` and `hip3_funding` channels are **historical only** (replay). They do not support real-time subscriptions. `lighter_open_interest` and `lighter_funding` support both replay and live subscriptions (see Live Lighter.xyz Data above). +The Hyperliquid `open_interest` and `funding` channels support both replay and live subscriptions (see Real-time Streaming above). The `hip3_open_interest` and `hip3_funding` channels are **historical only** (replay). They do not support real-time subscriptions. `lighter_open_interest` and `lighter_funding` support both replay and live subscriptions (see Live Lighter.xyz Data above), as do `rh_lighter_open_interest` and `rh_lighter_funding`. ```python # Replay open interest at 50x speed @@ -1758,6 +1979,15 @@ await ws.multi_replay( speed=10, ) +# Lighter on Robinhood Chain: orderbook + trades + OI + funding +await ws.multi_replay( + ["rh_lighter_orderbook", "rh_lighter_trades", "rh_lighter_open_interest", "rh_lighter_funding"], + "BTC", + 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"], @@ -1814,6 +2044,9 @@ from oxarchive.types import ( OrderBook, Trade, Instrument, LighterInstrument, FundingRate, OpenInterest, Candle, Liquidation, LiquidationVolume, CoinFreshness, CoinSummary, PriceSnapshot, WsReplaySnapshot, LighterLiveTrade, LighterMarketContext, LighterMarketContextUpdate, + LighterLiquidation, LighterLiquidationVolume, ResponseMeta, + Position, PositionChange, MarketPosition, MarketPositionsSummary, AccountSummary, + WalletPositions, LighterL1Accounts, ) from oxarchive.resources.trades import CursorResponse @@ -1834,6 +2067,11 @@ result: CursorResponse = client.hyperliquid.trades.list("BTC", start=..., end=.. # Lighter current data is available, so recent() is available recent: list[Trade] = client.lighter.trades.recent("BTC") +# Account positions: typed rows plus the response meta +now: CursorResponse[WalletPositions] = client.hyperliquid.positions.get("0xabc...") +meta: ResponseMeta = now.meta +legs: CursorResponse[list[PositionChange]] = client.rh_lighter.positions.changes(4521, start=..., end=...) + # Lighter granularity type hint granularity: LighterGranularity = "10s" diff --git a/oxarchive/__init__.py b/oxarchive/__init__.py index 66f50f6..bdc1b3c 100644 --- a/oxarchive/__init__.py +++ b/oxarchive/__init__.py @@ -1,13 +1,19 @@ """ oxarchive - Official Python SDK for 0xarchive -Historical Market Data API for the following venue APIs: +Historical Market Data API for two venues, Hyperliquid and Lighter. Lighter has +two deployments: mainnet and Robinhood Chain. - Hyperliquid (perpetuals data from April 2023) - Hyperliquid HIP-3 builder perps under the Hyperliquid namespace at /v1/hyperliquid/hip3 and client.hyperliquid.hip3 - Hyperliquid HIP-4 outcome markets under the Hyperliquid namespace at /v1/hyperliquid/hip4 and client.hyperliquid.hip4 - Hyperliquid spot pairs under /v1/hyperliquid/spot and client.spot (trades and candles from 2025-03-22, candle floor 2025-03-22T10:50:22Z, rest live from 2026-05-05) -- Lighter.xyz (perpetuals data) +- Lighter mainnet at /v1/lighter and client.lighter +- Lighter on Robinhood Chain at /v1/rh-lighter and client.rh_lighter (USDG-quoted; + trades and liquidations from 2026-06-26; order book, open interest and + funding from 2026-08-22) +- Account positions on client.hyperliquid.positions, client.hyperliquid.hip3.positions, + client.lighter.positions and client.rh_lighter.positions Example: >>> from oxarchive import Client @@ -21,6 +27,9 @@ >>> # Lighter.xyz data >>> lighter_orderbook = client.lighter.orderbook.get("BTC") >>> + >>> # Lighter on Robinhood Chain + >>> rh_orderbook = client.rh_lighter.orderbook.get("AAPL-USDG") + >>> >>> # Hyperliquid HIP-3 data >>> hip3_orderbook = client.hyperliquid.hip3.orderbook.get("km:US500") >>> @@ -32,7 +41,14 @@ """ from .client import Client -from .exchanges import HyperliquidClient, Hip3Client, Hip4Client, LighterClient, SpotClient +from .exchanges import ( + HyperliquidClient, + Hip3Client, + Hip4Client, + LighterClient, + RhLighterClient, + SpotClient, +) from .resources.orderbook import LighterGranularity from .orderbook_reconstructor import ( OrderBookReconstructor, @@ -65,6 +81,8 @@ OpenInterest, Liquidation, LiquidationVolume, + LighterLiquidation, + LighterLiquidationVolume, LiquidationLevelBucket, LiquidationLevels, LiquidationLevelsHistoryItem, @@ -78,6 +96,19 @@ Candle, CandleInterval, OxArchiveError, + CursorResponse, + ResponseMeta, + # Account positions types + Position, + PositionLeverage, + PositionCumFunding, + PositionChange, + MarketPosition, + MarketPositionsSummary, + AccountSummary, + WalletPositions, + LighterL1Account, + LighterL1Accounts, # Web3 Auth types SiweChallenge, Web3SignupResult, @@ -128,7 +159,7 @@ OxArchiveWs = None # type: ignore WsOptions = None # type: ignore -__version__ = "1.11.0" +__version__ = "1.12.0" __all__ = [ # Client @@ -138,6 +169,7 @@ "Hip3Client", "Hip4Client", "LighterClient", + "RhLighterClient", "SpotClient", # WebSocket Client "OxArchiveWs", @@ -176,6 +208,8 @@ "OpenInterest", "Liquidation", "LiquidationVolume", + "LighterLiquidation", + "LighterLiquidationVolume", "LiquidationLevelBucket", "LiquidationLevels", "LiquidationLevelsHistoryItem", @@ -189,6 +223,19 @@ "Candle", "CandleInterval", "OxArchiveError", + "CursorResponse", + "ResponseMeta", + # Account Positions Types + "Position", + "PositionLeverage", + "PositionCumFunding", + "PositionChange", + "MarketPosition", + "MarketPositionsSummary", + "AccountSummary", + "WalletPositions", + "LighterL1Account", + "LighterL1Accounts", # Web3 Auth Types "SiweChallenge", "Web3SignupResult", diff --git a/oxarchive/client.py b/oxarchive/client.py index f373fdf..c7d199a 100644 --- a/oxarchive/client.py +++ b/oxarchive/client.py @@ -6,7 +6,7 @@ from typing import Optional from .http import HttpClient -from .exchanges import HyperliquidClient, LighterClient, SpotClient +from .exchanges import HyperliquidClient, LighterClient, RhLighterClient, SpotClient from .resources import ( OrderBookResource, TradesResource, @@ -26,13 +26,22 @@ class Client: """ 0xarchive API client. - Supports the following venue APIs: + Two venues: Hyperliquid and Lighter. Lighter has two deployments: mainnet + and Robinhood Chain. + - `client.hyperliquid` - Hyperliquid perpetuals (April 2023+) - `client.hyperliquid.hip3` - Hyperliquid HIP-3 builder perps under the Hyperliquid namespace - `client.hyperliquid.hip4` - Hyperliquid HIP-4 outcome markets under the Hyperliquid namespace - `client.spot` - Hyperliquid spot pairs (trades and candles from 2025-03-22, orderbook/L4/TWAP live from 2026-05-05; no funding, OI, or liquidations) - - `client.lighter` - Lighter.xyz perpetuals + - `client.lighter` - Lighter mainnet + - `client.rh_lighter` - Lighter on Robinhood Chain (USDG-quoted perps and + spot; trades and liquidations from 2026-06-26; order book, OI and + funding from 2026-08-22) + + Account positions: `client.hyperliquid.positions`, + `client.hyperliquid.hip3.positions`, `client.lighter.positions` and + `client.rh_lighter.positions`. Example: >>> from oxarchive import Client @@ -46,6 +55,12 @@ class Client: >>> # Lighter.xyz data >>> lighter_orderbook = client.lighter.orderbook.get("BTC") >>> + >>> # Lighter on Robinhood Chain + >>> rh_orderbook = client.rh_lighter.orderbook.get("AAPL-USDG") + >>> + >>> # Account positions + >>> positions = client.hyperliquid.positions.get("0x...") + >>> >>> # Hyperliquid HIP-3 data >>> hip3_orderbook = client.hyperliquid.hip3.orderbook.get("km:US500") >>> @@ -111,9 +126,15 @@ def __init__( TWAP, and orders live from 2026-05-05. No funding, OI, or liquidations.""" self.lighter = LighterClient(self._http) - """Lighter.xyz exchange data. Trade history begins January 17, 2025; + """Lighter mainnet data. Trade history begins January 17, 2025; exact starts vary by market and data type.""" + self.rh_lighter = RhLighterClient(self._http) + """Lighter on Robinhood Chain data (``/v1/rh-lighter``). Trades and + liquidations from 2026-06-26 20:10:26 UTC; order book, open interest + and funding from 2026-08-22 18:43 UTC. Same resources as ``client.lighter`` + except the L3 order book and the L1 account resolver.""" + # Data quality monitoring (cross-exchange) self.data_quality = DataQualityResource(self._http) """Data quality metrics: status, coverage, incidents, latency, SLA""" diff --git a/oxarchive/exchanges.py b/oxarchive/exchanges.py index e880f17..5a6f3f5 100644 --- a/oxarchive/exchanges.py +++ b/oxarchive/exchanges.py @@ -18,11 +18,15 @@ Hip4InstrumentsResource, Hip4OpenInterestResource, Hip4OutcomesResource, + HyperliquidPositionsResource, InstrumentsResource, L2OrderBookResource, L3OrderBookResource, L4OrderBookResource, + LighterAccountsResource, LighterInstrumentsResource, + LighterLiquidationsResource, + LighterPositionsResource, LiquidationsResource, OpenInterestResource, OrderBookResource, @@ -110,6 +114,11 @@ def __init__(self, http: HttpClient): self.l2_orderbook = L2OrderBookResource(http, base_path) """L2 full-depth orderbook (derived from L4)""" + self.positions = HyperliquidPositionsResource(http, base_path) + """Account positions by wallet address: current, as-of, hourly history, + 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.hip3 = Hip3Client(http) """HIP-3 builder-deployed perpetuals (February 2026+)""" @@ -376,6 +385,11 @@ def __init__(self, http: HttpClient): self.l2_orderbook = L2OrderBookResource(http, base_path, coin_transform=coin_transform) """L2 full-depth orderbook (derived from L4)""" + self.positions = HyperliquidPositionsResource(http, base_path, hip3=True) + """HIP-3 account positions by wallet address (optional ``dex`` filter). + Change log from 2025-10-13, hourly history from 2026-06-07, live every + 5 minutes.""" + def _convert_timestamp(self, ts: Optional[Timestamp]) -> Optional[int]: """Convert timestamp to Unix milliseconds.""" if ts is None: @@ -872,29 +886,23 @@ async def aget_l4_history(self, symbol: str, **kwargs): return await self.l4_orderbook.ahistory(symbol, **kwargs) -class LighterClient: - """ - Lighter.xyz exchange client. - - Access Lighter.xyz market data through the 0xarchive API. +class _LighterDeploymentClient: + """Resources shared by both Lighter deployments (mainnet and Robinhood Chain).""" - Example: - >>> client = oxarchive.Client(api_key="...") - >>> orderbook = client.lighter.orderbook.get("BTC") - >>> trades = client.lighter.trades.list("ETH", start=..., end=...) - >>> instruments = client.lighter.instruments.list() - >>> print(f"ETH taker fee: {instruments[0].taker_fee}") - """ + _BASE_PATH = "/v1/lighter" def __init__(self, http: HttpClient): self._http = http - base_path = "/v1/lighter" + base_path = self._BASE_PATH + self._base_path = base_path self.orderbook = OrderBookResource(http, base_path, coin_transform=_lighter_encode) """Order book data (L2 snapshots)""" self.trades = TradesResource(http, base_path, coin_transform=_lighter_encode) - """Trade/fill history""" + """Trade/fill history. ``list()`` serves canonical trades up to the + finalization watermark (``meta.finalized_through``); ``recent()`` serves + the preliminary tier.""" self.instruments = LighterInstrumentsResource( http, base_path, coin_transform=_lighter_encode @@ -910,8 +918,10 @@ def __init__(self, http: HttpClient): self.candles = CandlesResource(http, base_path, coin_transform=_lighter_encode) """OHLCV candle data""" - self.l3_orderbook = L3OrderBookResource(http, base_path, coin_transform=_lighter_encode) - """L3 individual order-level orderbook data""" + self.liquidations = LighterLiquidationsResource( + http, base_path, coin_transform=_lighter_encode + ) + """Liquidation events (``history()``) and aggregated volume (``volume()``)""" def _convert_timestamp(self, ts: Optional[Timestamp]) -> Optional[int]: """Convert timestamp to Unix milliseconds.""" @@ -940,13 +950,13 @@ def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness: CoinFreshness with per-data-type lag information """ symbol = _resolve_symbol(symbol, kwargs) - data = self._http.get(f"/v1/lighter/freshness/{_lighter_encode(symbol)}") + data = self._http.get(f"{self._base_path}/freshness/{_lighter_encode(symbol)}") return CoinFreshness.model_validate(data["data"]) async def aget_freshness(self, symbol: str, **kwargs) -> CoinFreshness: """Async version of get_freshness().""" symbol = _resolve_symbol(symbol, kwargs) - data = await self._http.aget(f"/v1/lighter/freshness/{_lighter_encode(symbol)}") + data = await self._http.aget(f"{self._base_path}/freshness/{_lighter_encode(symbol)}") return CoinFreshness.model_validate(data["data"]) def get_summary(self, symbol: str, **kwargs) -> CoinSummary: @@ -960,13 +970,13 @@ def get_summary(self, symbol: str, **kwargs) -> CoinSummary: CoinSummary with all market metrics """ symbol = _resolve_symbol(symbol, kwargs) - data = self._http.get(f"/v1/lighter/summary/{_lighter_encode(symbol)}") + data = self._http.get(f"{self._base_path}/summary/{_lighter_encode(symbol)}") return CoinSummary.model_validate(data["data"]) async def aget_summary(self, symbol: str, **kwargs) -> CoinSummary: """Async version of get_summary().""" symbol = _resolve_symbol(symbol, kwargs) - data = await self._http.aget(f"/v1/lighter/summary/{_lighter_encode(symbol)}") + data = await self._http.aget(f"{self._base_path}/summary/{_lighter_encode(symbol)}") return CoinSummary.model_validate(data["data"]) def get_price_history( @@ -1003,7 +1013,7 @@ def get_price_history( "cursor": cursor, } data = self._http.get( - f"/v1/lighter/prices/{_lighter_encode(symbol)}", + f"{self._base_path}/prices/{_lighter_encode(symbol)}", params=params, ) return CursorResponse( @@ -1032,7 +1042,7 @@ async def aget_price_history( "cursor": cursor, } data = await self._http.aget( - f"/v1/lighter/prices/{_lighter_encode(symbol)}", + f"{self._base_path}/prices/{_lighter_encode(symbol)}", params=params, ) return CursorResponse( @@ -1041,6 +1051,78 @@ async def aget_price_history( ) +class LighterClient(_LighterDeploymentClient): + """ + Lighter.xyz exchange client (Lighter mainnet). + + Access Lighter.xyz market data through the 0xarchive API. Lighter has two + deployments: mainnet (this client, ``client.lighter``) and Robinhood Chain + (``client.rh_lighter``, see :class:`RhLighterClient`). + + Example: + >>> client = oxarchive.Client(api_key="...") + >>> orderbook = client.lighter.orderbook.get("BTC") + >>> trades = client.lighter.trades.list("ETH", start=..., end=...) + >>> instruments = client.lighter.instruments.list() + >>> print(f"ETH taker fee: {instruments[0].taker_fee}") + >>> positions = client.lighter.positions.get(281474976623827) + """ + + _BASE_PATH = "/v1/lighter" + + def __init__(self, http: HttpClient): + super().__init__(http) + base_path = self._base_path + + self.l3_orderbook = L3OrderBookResource(http, base_path, coin_transform=_lighter_encode) + """L3 individual order-level orderbook data""" + + self.positions = LighterPositionsResource(http, base_path) + """Account positions by Lighter account index: current, as-of, hourly + history, change log, and market-wide listings. From 2025-01-17; live + every 2 minutes.""" + + self.accounts = LighterAccountsResource(http, base_path) + """Resolve an L1 address to the Lighter account indices it owns.""" + + +class RhLighterClient(_LighterDeploymentClient): + """ + Lighter on Robinhood Chain client (``client.rh_lighter``). + + The Robinhood Chain deployment of Lighter, served under ``/v1/rh-lighter``. + It has the same resources as ``client.lighter`` except the L3 order book + (not captured on this deployment) and the L1 account resolver. Markets are + quoted in USDG: perps use uppercase symbols (``BTC``), spot markets use + dashed symbols (``AAPL-USDG``). Symbols and market ids are this + deployment's own; ``BTC`` here is not the mainnet ``BTC`` market. + + Coverage: trades and liquidations from the venue launch, + 2026-06-26 20:10:26 UTC; order book, open interest and funding from + 2026-08-22 18:43 UTC; account positions from 2026-06-26. Candles are + served once enabled for this deployment (from 2026-06-26); until then the + candles route answers with an error. Trades follow the same finalization + contract as mainnet Lighter: ``trades.list()`` is canonical up to + ``meta.finalized_through`` and ``trades.recent()`` is the preliminary tier. + + Example: + >>> client = oxarchive.Client(api_key="...") + >>> markets = client.rh_lighter.instruments.list() + >>> book = client.rh_lighter.orderbook.get("AAPL-USDG") + >>> page = client.rh_lighter.trades.list("BTC", start=..., end=...) + >>> print(page.meta.finalized_through) + """ + + _BASE_PATH = "/v1/rh-lighter" + + def __init__(self, http: HttpClient): + super().__init__(http) + + self.positions = LighterPositionsResource(http, self._base_path, l1_resolver=False) + """Account positions by Lighter account index (perp markets). From + 2026-06-26; live every 2 minutes.""" + + class SpotClient: """ Hyperliquid spot client. diff --git a/oxarchive/resources/__init__.py b/oxarchive/resources/__init__.py index c4b34f9..3023554 100644 --- a/oxarchive/resources/__init__.py +++ b/oxarchive/resources/__init__.py @@ -13,11 +13,17 @@ from .l2_orderbook import L2OrderBookResource from .l3_orderbook import L3OrderBookResource from .l4_orderbook import L4OrderBookResource +from .lighter_liquidations import LighterLiquidationsResource from .liquidations import LiquidationsResource from .openinterest import Hip4OpenInterestResource, OpenInterestResource from .orderbook import OrderBookResource from .orders import OrdersResource from .outcomes import Hip4OutcomesResource +from .positions import ( + HyperliquidPositionsResource, + LighterAccountsResource, + LighterPositionsResource, +) from .spot import SpotPairsResource, SpotTwapResource from .trades import TradesResource from .web3 import Web3Resource @@ -38,6 +44,7 @@ "Hip4CandlesResource", "SpotCandlesResource", "LiquidationsResource", + "LighterLiquidationsResource", "DataQualityResource", "Web3Resource", "OrdersResource", @@ -47,4 +54,7 @@ "L3OrderBookResource", "SpotPairsResource", "SpotTwapResource", + "HyperliquidPositionsResource", + "LighterPositionsResource", + "LighterAccountsResource", ] diff --git a/oxarchive/resources/data_quality.py b/oxarchive/resources/data_quality.py index aca0887..0cf4028 100644 --- a/oxarchive/resources/data_quality.py +++ b/oxarchive/resources/data_quality.py @@ -25,6 +25,10 @@ class DataQualityResource: Provides endpoints for monitoring data quality, coverage, incidents, and SLA metrics. + Venue scopes: ``hyperliquid``, ``hip3``, ``hip4``, ``spot`` (Hyperliquid + spot), ``lighter`` (Lighter mainnet) and ``rh-lighter`` (Lighter on + Robinhood Chain). + Example: >>> # Get system status >>> status = client.data_quality.status() @@ -117,7 +121,8 @@ def exchange_coverage(self, exchange: str) -> ExchangeCoverage: Get data coverage for a specific venue scope. Args: - exchange: Venue scope ('hyperliquid', 'lighter', 'hip3', or 'hip4') + exchange: Venue scope: 'hyperliquid', 'hip3', 'hip4', 'spot', + 'lighter' or 'rh-lighter' (Lighter on Robinhood Chain) Returns: ExchangeCoverage with coverage info for all data types on this venue scope. @@ -149,7 +154,8 @@ def symbol_coverage( historical coverage. Args: - exchange: Venue scope ('hyperliquid', 'lighter', 'hip3', or 'hip4') + 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') from_time: Start of gap detection window (default: now - 30 days). Accepts Unix ms, datetime, or ISO string. @@ -222,7 +228,8 @@ def list_incidents( Args: status: Filter by incident status - exchange: Filter by exchange + exchange: Filter by venue scope ('hyperliquid', 'hip3', 'hip4', + 'spot', 'lighter' or 'rh-lighter') since: Only show incidents starting after this timestamp limit: Maximum results per page (default: 20, max: 100) offset: Pagination offset diff --git a/oxarchive/resources/lighter_liquidations.py b/oxarchive/resources/lighter_liquidations.py new file mode 100644 index 0000000..f8a89e5 --- /dev/null +++ b/oxarchive/resources/lighter_liquidations.py @@ -0,0 +1,187 @@ +"""Lighter liquidations API resource (Lighter mainnet and Robinhood Chain).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable, Optional + +from ..http import HttpClient +from ..types import ( + CursorResponse, + LighterLiquidation, + LighterLiquidationVolume, + ResponseMeta, + Timestamp, +) + + +def _to_ms(ts: Optional[Timestamp]) -> Optional[int]: + """Convert a timestamp (Unix ms, ISO string 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) + try: + return int(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + return int(ts) + + +class LighterLiquidationsResource: + """ + Liquidation events for a Lighter deployment. + + Available as ``client.lighter.liquidations`` (Lighter mainnet) and + ``client.rh_lighter.liquidations`` (Lighter on Robinhood Chain). Lighter + liquidations are the liquidation trades of the venue's trade stream, so a + row keeps both sides' account fields; see :class:`LighterLiquidation`. + + Coverage: Robinhood Chain liquidations start at the venue launch, + 2026-06-26 20:10:26 UTC, the same floor as its trades; a request that + starts earlier is refused with the API's coverage error. Rows from before + live capture were backfilled from the venue's finalized export and have + ``source == "bucket"`` and an empty ``raw_json``; rows captured live have + ``source == "ws"`` and the venue's raw JSON in ``raw_json``. + + Example: + >>> page = client.lighter.liquidations.history("BTC", start="2026-09-01", end="2026-09-02") + >>> for liq in page.data: + ... print(liq.timestamp, liq.price, liq.size, liq.usd_amount) + >>> + >>> volume = client.rh_lighter.liquidations.volume("BTC", interval="1d") + """ + + def __init__( + self, + http: HttpClient, + base_path: str = "/v1/lighter", + coin_transform: Callable[[str], str] = str.upper, + ): + self._http = http + self._base_path = base_path + self._coin_transform = coin_transform + + @staticmethod + def _page(payload: dict[str, Any], model: Any) -> CursorResponse[Any]: + meta = ResponseMeta.model_validate(payload.get("meta") or {}) + return CursorResponse( + data=[model.model_validate(item) for item in payload["data"]], + next_cursor=meta.next_cursor, + meta=meta, + ) + + def _history_args( + self, + symbol: str, + start: Timestamp, + end: Timestamp, + cursor: Optional[str], + limit: Optional[int], + ) -> tuple[str, dict[str, Any]]: + return ( + f"{self._base_path}/liquidations/{self._coin_transform(symbol)}", + {"start": _to_ms(start), "end": _to_ms(end), "cursor": cursor, "limit": limit}, + ) + + def _volume_args( + self, + symbol: str, + start: Optional[Timestamp], + end: Optional[Timestamp], + interval: Optional[str], + cursor: Optional[str], + limit: Optional[int], + ) -> tuple[str, dict[str, Any]]: + return ( + f"{self._base_path}/liquidations/{self._coin_transform(symbol)}/volume", + { + "start": _to_ms(start), + "end": _to_ms(end), + "interval": interval, + "cursor": cursor, + "limit": limit, + }, + ) + + def history( + self, + symbol: str, + *, + start: Timestamp, + end: Timestamp, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[LighterLiquidation]]: + """ + Get liquidation events for a market with cursor-based pagination. + + Args: + symbol: Market symbol (e.g. ``BTC``; case-insensitive) + start: Start timestamp (required) + end: End timestamp (required) + cursor: ``next_cursor`` of the previous page (``"{timestamp_ms}_{trade_id}"``) + limit: Maximum number of results (default: 100, max: 1000) + + Returns: + CursorResponse with :class:`LighterLiquidation` rows and ``next_cursor`` + """ + path, params = self._history_args(symbol, start, end, cursor, limit) + return self._page(self._http.get(path, params=params), LighterLiquidation) + + async def ahistory( + self, + symbol: str, + *, + start: Timestamp, + end: Timestamp, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[LighterLiquidation]]: + """Async version of history(). start and end are required.""" + path, params = self._history_args(symbol, start, end, cursor, limit) + return self._page(await self._http.aget(path, params=params), LighterLiquidation) + + def volume( + self, + symbol: str, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + interval: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[LighterLiquidationVolume]]: + """ + Get aggregated liquidation volume for a market in time buckets. + + Each bucket carries ``total_usd`` and ``count`` (no long/short split). + + Args: + symbol: Market symbol (e.g. ``BTC``; case-insensitive) + start: Start timestamp (default: 24 hours before ``end``) + end: End timestamp (default: now) + interval: Bucket size: ``5m``, ``15m``, ``30m``, ``1h`` (default), ``4h`` or ``1d`` + cursor: ``next_cursor`` of the previous page + limit: Maximum number of buckets (default: 100, max: 1000) + + Returns: + CursorResponse with :class:`LighterLiquidationVolume` buckets and ``next_cursor`` + """ + path, params = self._volume_args(symbol, start, end, interval, cursor, limit) + return self._page(self._http.get(path, params=params), LighterLiquidationVolume) + + async def avolume( + self, + symbol: str, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + interval: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[LighterLiquidationVolume]]: + """Async version of volume().""" + path, params = self._volume_args(symbol, start, end, interval, cursor, limit) + return self._page(await self._http.aget(path, params=params), LighterLiquidationVolume) diff --git a/oxarchive/resources/positions.py b/oxarchive/resources/positions.py new file mode 100644 index 0000000..8bfe870 --- /dev/null +++ b/oxarchive/resources/positions.py @@ -0,0 +1,1435 @@ +"""Account positions API resources. + +Account positions are served for Hyperliquid core, HIP-3, Lighter and Lighter +on Robinhood Chain: + +- ``client.hyperliquid.positions`` and ``client.hyperliquid.hip3.positions`` + key accounts by 0x wallet address. +- ``client.lighter.positions`` and ``client.rh_lighter.positions`` key + accounts by integer Lighter account index. ``client.lighter.accounts`` + resolves an L1 address to its account indices (Lighter mainnet only). + +Every method returns a :class:`~oxarchive.types.CursorResponse` whose ``meta`` +carries the snapshot context (``as_of``, ``snapshot_ts``, ``source``, +``quality``, ``stale``, ``built_through``, ``finalized_through``, ``totals``). +Paginated routes return ``next_cursor``; pass it back as ``cursor`` with the +other arguments unchanged, or use the ``iterate_*`` helpers, which do that for +you. A cursor is bound to the request that produced it: changing any other +argument, or a snapshot that has since been replaced, is refused (HTTP 400 or +409). Restart without a cursor in that case. + +Position rows are billed like trades, 1,000 rows per credit. The account +summary routes and the Lighter L1 resolver are billed at the per-request +minimum. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import ( + Any, + AsyncIterator, + Callable, + Iterator, + Literal, + Optional, + TypeVar, + Union, +) +from urllib.parse import quote + +from ..http import HttpClient +from ..types import ( + AccountSummary, + CursorResponse, + LighterL1Account, + LighterL1Accounts, + MarketPosition, + MarketPositionsSummary, + Position, + PositionChange, + ResponseMeta, + Timestamp, + WalletPositions, +) + +D = TypeVar("D") +R = TypeVar("R") + +PositionSide = Literal["long", "short"] +"""Side filter for the market routes.""" + +HOUR_MS = 3_600_000 + +Params = dict[str, Any] + + +def _to_ms(ts: Optional[Timestamp]) -> Optional[int]: + """Convert a timestamp (Unix ms, ISO string or datetime) to Unix milliseconds.""" + if ts is None: + return None + if isinstance(ts, bool): + raise ValueError("timestamps must be Unix milliseconds, ISO strings or datetimes") + 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) + raise ValueError("timestamps must be Unix milliseconds, ISO strings or datetimes") + + +def _hour_ms(hour: Timestamp) -> int: + """An exact UTC hour in Unix milliseconds, or ``ValueError``.""" + ms = _to_ms(hour) + if ms is None or ms < 0 or ms % HOUR_MS != 0: + raise ValueError("hour must be an exact UTC hour (for example 2026-09-25T12:00:00Z)") + return ms + + +def _page(payload: dict[str, Any], parse: Callable[[Any], D]) -> CursorResponse[D]: + meta = ResponseMeta.model_validate(payload.get("meta") or {}) + return CursorResponse(data=parse(payload["data"]), next_cursor=meta.next_cursor, meta=meta) + + +def _list_of(model: Any) -> Callable[[Any], list[Any]]: + def parse(data: Any) -> list[Any]: + return [model.model_validate(item) for item in data] + + return parse + + +_positions = _list_of(Position) +_changes = _list_of(PositionChange) +_market_rows = _list_of(MarketPosition) +_summaries = _list_of(MarketPositionsSummary) +_accounts = _list_of(AccountSummary) + + +def _wallet(data: Any) -> WalletPositions: + return WalletPositions.model_validate(data) + + +def _l1_accounts(data: Any) -> LighterL1Accounts: + return LighterL1Accounts.model_validate(data) + + +class _Endpoint: + """One GET request: path, query and how to parse ``data``.""" + + __slots__ = ("path", "params", "parse") + + def __init__(self, path: str, params: Params, parse: Callable[[Any], Any]) -> None: + self.path = path + self.params = params + self.parse = parse + + +class _PositionsBase: + def __init__( + self, + http: HttpClient, + base_path: str, + coin_transform: Callable[[str], str], + ) -> None: + self._http = http + self._base_path = base_path + self._coin_transform = coin_transform + + def _symbol_path(self, symbol: str) -> str: + return quote(self._coin_transform(symbol), safe="") + + def _symbol_param(self, symbol: Optional[str]) -> Optional[str]: + return None if symbol is None else self._coin_transform(symbol) + + # ---- transport ------------------------------------------------------- + + def _fetch(self, ep: _Endpoint) -> CursorResponse[Any]: + return _page(self._http.get(ep.path, params=dict(ep.params)), ep.parse) + + async def _afetch(self, ep: _Endpoint) -> CursorResponse[Any]: + return _page(await self._http.aget(ep.path, params=dict(ep.params)), ep.parse) + + def _iterate(self, ep: _Endpoint, rows: Callable[[Any], list[R]]) -> Iterator[R]: + params = dict(ep.params) + while True: + page = self._fetch(_Endpoint(ep.path, params, ep.parse)) + yield from rows(page.data) + if not page.next_cursor: + return + params["cursor"] = page.next_cursor + + async def _aiterate(self, ep: _Endpoint, rows: Callable[[Any], list[R]]) -> AsyncIterator[R]: + params = dict(ep.params) + while True: + page = await self._afetch(_Endpoint(ep.path, params, ep.parse)) + for row in rows(page.data): + yield row + if not page.next_cursor: + return + params["cursor"] = page.next_cursor + + # ---- market routes (shared by every venue) --------------------------- + + def _market_ep( + self, + symbol: str, + hour: Optional[Timestamp], + side: Optional[PositionSide], + min_value: Optional[float], + include_system: Optional[bool], + cursor: Optional[str], + limit: Optional[int], + ) -> _Endpoint: + return _Endpoint( + f"{self._base_path}/positions/{self._symbol_path(symbol)}", + { + "hour": None if hour is None else _hour_ms(hour), + "side": side, + "min_value": min_value, + "include_system": include_system, + "cursor": cursor, + "limit": limit, + }, + _market_rows, + ) + + def _summary_ep( + self, + symbol: str, + start: Optional[Timestamp], + end: Optional[Timestamp], + include_system: Optional[bool], + cursor: Optional[str], + limit: Optional[int], + ) -> _Endpoint: + return _Endpoint( + f"{self._base_path}/positions/{self._symbol_path(symbol)}/summary", + { + "start": _to_ms(start), + "end": _to_ms(end), + "include_system": include_system, + "cursor": cursor, + "limit": limit, + }, + _summaries, + ) + + def _bulk_ep( + self, + hour: Timestamp, + include_system: Optional[bool], + cursor: Optional[str], + limit: Optional[int], + ) -> _Endpoint: + return _Endpoint( + f"{self._base_path}/positions", + { + "hour": _hour_ms(hour), + "include_system": include_system, + "cursor": cursor, + "limit": limit, + }, + _market_rows, + ) + + +def _identity(symbol: str) -> str: + return symbol + + +def _upper(symbol: str) -> str: + return symbol.upper() + + +class HyperliquidPositionsResource(_PositionsBase): + """Account positions for Hyperliquid core or HIP-3. + + Available as ``client.hyperliquid.positions`` (core) and + ``client.hyperliquid.hip3.positions`` (HIP-3, where ``dex`` narrows a wallet + to one dex). Accounts are 0x wallet addresses. + + Coverage: the change log starts 2025-05-25 on core and 2025-10-13 on HIP-3, + hourly history starts 2026-06-07, and the live snapshot refreshes every + 5 minutes. + + Example: + >>> now = client.hyperliquid.positions.get("0xabc...") + >>> for p in now.data.positions: + ... print(p.symbol, p.side, p.size, p.unrealized_pnl) + >>> print(now.meta.as_of, now.meta.quality) + >>> + >>> # State at an instant (an exact hour serves the hourly snapshot) + >>> then = client.hyperliquid.positions.get("0xabc...", timestamp="2026-09-01T12:00:00Z") + >>> + >>> # Every change in a window, following cursors + >>> for leg in client.hyperliquid.positions.iterate_changes( + ... "0xabc...", start="2026-09-01", end="2026-09-02" + ... ): + ... print(leg.timestamp, leg.event_type, leg.start_position, leg.end_position) + """ + + def __init__(self, http: HttpClient, base_path: str = "/v1/hyperliquid", *, hip3: bool = False): + super().__init__(http, base_path, _identity if hip3 else _upper) + self._hip3 = hip3 + + def _dex(self, dex: Optional[str]) -> Optional[str]: + if dex is not None and not self._hip3: + raise ValueError( + "dex applies to HIP-3 positions only (client.hyperliquid.hip3.positions)" + ) + return dex + + def _wallet_path(self, address: str, suffix: str) -> str: + return f"{self._base_path}/wallets/{quote(address, safe='')}/{suffix}" + + # ---- endpoint specs -------------------------------------------------- + + def _get_ep( + self, + address: str, + timestamp: Optional[Timestamp], + symbol: Optional[str], + dex: Optional[str], + cursor: Optional[str], + limit: Optional[int], + ) -> _Endpoint: + return _Endpoint( + self._wallet_path(address, "positions"), + { + "timestamp": _to_ms(timestamp), + "symbol": self._symbol_param(symbol), + "dex": self._dex(dex), + "cursor": cursor, + "limit": limit, + }, + _wallet, + ) + + def _range_ep( + self, + address: str, + suffix: str, + start: Timestamp, + end: Timestamp, + symbol: Optional[str], + dex: Optional[str], + cursor: Optional[str], + limit: Optional[int], + parse: Callable[[Any], Any], + ) -> _Endpoint: + return _Endpoint( + self._wallet_path(address, suffix), + { + "start": _to_ms(start), + "end": _to_ms(end), + "symbol": self._symbol_param(symbol), + "dex": self._dex(dex), + "cursor": cursor, + "limit": limit, + }, + parse, + ) + + # ---- wallet: current or as-of ---------------------------------------- + + def get( + self, + address: str, + *, + timestamp: Optional[Timestamp] = None, + symbol: Optional[str] = None, + dex: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[WalletPositions]: + """Open positions of a wallet, now or at an instant. + + Without ``timestamp``: the latest live snapshot (``meta.stale`` is + ``True`` with a ``meta.notice`` when it is older than 12 minutes). + + With ``timestamp``: the state after every event before that instant. + An exact UTC hour with a committed hourly snapshot serves that snapshot + (``meta.source == "snapshot"``); any other instant is reconstructed + (``meta.source == "reconstructed"``: size, entry and ``opened_at`` are + exact, mark fields are at the instant, snapshot-only fields are + ``None``). The instant is clamped to ``meta.built_through`` + (``meta.clamped_to`` says when it was). An instant before coverage + returns no positions with ``account_seen == "outside_coverage"``. + + Args: + address: 0x wallet address. + timestamp: As-of instant; omit for the latest live snapshot. + symbol: Only this market. + dex: HIP-3 only: only this dex. On HIP-3, the account summary is + included only when ``dex`` (or a ``symbol``) names one dex. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 500, max 5,000). + + Returns: + ``CursorResponse`` whose ``data`` is :class:`WalletPositions` + (``positions``, ``account``, ``account_seen``). + """ + return self._fetch(self._get_ep(address, timestamp, symbol, dex, cursor, limit)) + + async def aget( + self, + address: str, + *, + timestamp: Optional[Timestamp] = None, + symbol: Optional[str] = None, + dex: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[WalletPositions]: + """Async version of :meth:`get`.""" + return await self._afetch(self._get_ep(address, timestamp, symbol, dex, cursor, limit)) + + # ---- wallet: hourly history ------------------------------------------ + + def history( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + dex: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[Position]]: + """Hourly position rows of a wallet in ``[start, end)``. + + Each row carries ``snapshot_ts``, the hour it describes. Hourly + history starts 2026-06-07. + + Args: + address: 0x wallet address. + start: Inclusive start. + end: Exclusive end. + symbol: Only this market. + dex: HIP-3 only: only this dex. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 500, max 5,000). + """ + return self._fetch( + self._range_ep( + address, "positions/history", start, end, symbol, dex, cursor, limit, _positions + ) + ) + + async def ahistory( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + dex: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[Position]]: + """Async version of :meth:`history`.""" + return await self._afetch( + self._range_ep( + address, "positions/history", start, end, symbol, dex, cursor, limit, _positions + ) + ) + + def iterate_history( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + dex: Optional[str] = None, + limit: Optional[int] = None, + ) -> Iterator[Position]: + """Yield every hourly position row of :meth:`history`, following cursors.""" + return self._iterate( + self._range_ep( + address, "positions/history", start, end, symbol, dex, None, limit, _positions + ), + _positions_rows, + ) + + def aiterate_history( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + dex: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[Position]: + """Async version of :meth:`iterate_history`.""" + return self._aiterate( + self._range_ep( + address, "positions/history", start, end, symbol, dex, None, limit, _positions + ), + _positions_rows, + ) + + # ---- wallet: change log ---------------------------------------------- + + def changes( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + dex: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[PositionChange]]: + """Position change-log legs of a wallet in ``[start, end)``. + + ``end`` is clamped to ``meta.built_through``. Legs before + ``meta.finalized_through`` are final; later legs carry + ``finalized == False``. The change log starts 2025-05-25 on core and + 2025-10-13 on HIP-3. + + Args: + address: 0x wallet address. + start: Inclusive start. + end: Exclusive end. + symbol: Only this market. + dex: HIP-3 only: only this dex. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 500, max 5,000). + """ + return self._fetch( + self._range_ep( + address, "positions/changes", start, end, symbol, dex, cursor, limit, _changes + ) + ) + + async def achanges( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + dex: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[PositionChange]]: + """Async version of :meth:`changes`.""" + return await self._afetch( + self._range_ep( + address, "positions/changes", start, end, symbol, dex, cursor, limit, _changes + ) + ) + + def iterate_changes( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + dex: Optional[str] = None, + limit: Optional[int] = None, + ) -> Iterator[PositionChange]: + """Yield every change-log leg of :meth:`changes`, following cursors.""" + return self._iterate( + self._range_ep( + address, "positions/changes", start, end, symbol, dex, None, limit, _changes + ), + _change_rows, + ) + + def aiterate_changes( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + dex: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[PositionChange]: + """Async version of :meth:`iterate_changes`.""" + return self._aiterate( + self._range_ep( + address, "positions/changes", start, end, symbol, dex, None, limit, _changes + ), + _change_rows, + ) + + # ---- wallet: account summary ----------------------------------------- + + def account( + self, address: str, *, dex: Optional[str] = None + ) -> CursorResponse[list[AccountSummary]]: + """Account summary of a wallet at the latest live snapshot. + + One row on Hyperliquid core; one row per dex on HIP-3 (or just ``dex``). + Billed at the per-request minimum. + """ + return self._fetch( + _Endpoint(self._wallet_path(address, "account"), {"dex": self._dex(dex)}, _accounts) + ) + + async def aaccount( + self, address: str, *, dex: Optional[str] = None + ) -> CursorResponse[list[AccountSummary]]: + """Async version of :meth:`account`.""" + return await self._afetch( + _Endpoint(self._wallet_path(address, "account"), {"dex": self._dex(dex)}, _accounts) + ) + + def account_history( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + dex: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[AccountSummary]]: + """Hourly account summaries of a wallet in ``[start, end)``. + + Billed at the per-request minimum. + + Args: + address: 0x wallet address. + start: Inclusive start. + end: Exclusive end. + dex: HIP-3 only: only this dex. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 500, max 5,000). + """ + return self._fetch( + self._range_ep( + address, "account/history", start, end, None, dex, cursor, limit, _accounts + ) + ) + + async def aaccount_history( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + dex: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[AccountSummary]]: + """Async version of :meth:`account_history`.""" + return await self._afetch( + self._range_ep( + address, "account/history", start, end, None, dex, cursor, limit, _accounts + ) + ) + + def iterate_account_history( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + dex: Optional[str] = None, + limit: Optional[int] = None, + ) -> Iterator[AccountSummary]: + """Yield every hourly account summary of :meth:`account_history`, following cursors.""" + return self._iterate( + self._range_ep( + address, "account/history", start, end, None, dex, None, limit, _accounts + ), + _account_rows, + ) + + def aiterate_account_history( + self, + address: str, + *, + start: Timestamp, + end: Timestamp, + dex: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[AccountSummary]: + """Async version of :meth:`iterate_account_history`.""" + return self._aiterate( + self._range_ep( + address, "account/history", start, end, None, dex, None, limit, _accounts + ), + _account_rows, + ) + + # ---- market routes --------------------------------------------------- + + def market( + self, + symbol: str, + *, + hour: Optional[Timestamp] = None, + side: Optional[PositionSide] = None, + min_value: Optional[float] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPosition]]: + """Every open position in one market, largest ``position_value`` first. + + Reads the latest live snapshot, or the hourly snapshot at ``hour`` + (echoed as ``meta.snapshot_ts``). The first page carries + ``meta.totals`` (a :class:`MarketPositionsSummary` over the whole + filtered set). + + Args: + symbol: Market symbol (HIP-3 symbols are case-sensitive, e.g. ``xyz:TSLA``). + hour: An exact UTC hour with a committed snapshot; omit for live. + side: ``"long"`` or ``"short"``. + min_value: Minimum position value in USD. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 100, max 2,000). + """ + return self._fetch(self._market_ep(symbol, hour, side, min_value, None, cursor, limit)) + + async def amarket( + self, + symbol: str, + *, + hour: Optional[Timestamp] = None, + side: Optional[PositionSide] = None, + min_value: Optional[float] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPosition]]: + """Async version of :meth:`market`.""" + return await self._afetch( + self._market_ep(symbol, hour, side, min_value, None, cursor, limit) + ) + + def iterate_market( + self, + symbol: str, + *, + hour: Optional[Timestamp] = None, + side: Optional[PositionSide] = None, + min_value: Optional[float] = None, + limit: Optional[int] = None, + ) -> Iterator[MarketPosition]: + """Yield every position of :meth:`market`, following cursors (one snapshot).""" + return self._iterate( + self._market_ep(symbol, hour, side, min_value, None, None, limit), _market_position_rows + ) + + def aiterate_market( + self, + symbol: str, + *, + hour: Optional[Timestamp] = None, + side: Optional[PositionSide] = None, + min_value: Optional[float] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[MarketPosition]: + """Async version of :meth:`iterate_market`.""" + return self._aiterate( + self._market_ep(symbol, hour, side, min_value, None, None, limit), _market_position_rows + ) + + def market_summary( + self, + symbol: str, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPositionsSummary]]: + """Long/short aggregates of one market. + + Without ``start`` and ``end``: one summary at the latest live snapshot. + With them: one summary per hourly snapshot in ``[start, end)``, at most + 168 hours per page. + + Args: + symbol: Market symbol. + start: Inclusive start of the hourly series. + end: Exclusive end of the hourly series. + cursor: ``next_cursor`` of the previous page. + limit: Summaries per page (default 100, at most 168). + """ + return self._fetch(self._summary_ep(symbol, start, end, None, cursor, limit)) + + async def amarket_summary( + self, + symbol: str, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPositionsSummary]]: + """Async version of :meth:`market_summary`.""" + return await self._afetch(self._summary_ep(symbol, start, end, None, cursor, limit)) + + def iterate_market_summary( + self, + symbol: str, + *, + start: Timestamp, + end: Timestamp, + limit: Optional[int] = None, + ) -> Iterator[MarketPositionsSummary]: + """Yield every hourly summary of :meth:`market_summary` in ``[start, end)``.""" + return self._iterate(self._summary_ep(symbol, start, end, None, None, limit), _summary_rows) + + def aiterate_market_summary( + self, + symbol: str, + *, + start: Timestamp, + end: Timestamp, + limit: Optional[int] = None, + ) -> AsyncIterator[MarketPositionsSummary]: + """Async version of :meth:`iterate_market_summary`.""" + return self._aiterate( + self._summary_ep(symbol, start, end, None, None, limit), _summary_rows + ) + + def all( + self, + hour: Timestamp, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPosition]]: + """Every open position across every market at one hourly snapshot (bulk). + + Args: + hour: An exact UTC hour with a committed snapshot. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 1,000, max 2,000). + """ + return self._fetch(self._bulk_ep(hour, None, cursor, limit)) + + async def aall( + self, + hour: Timestamp, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPosition]]: + """Async version of :meth:`all`.""" + return await self._afetch(self._bulk_ep(hour, None, cursor, limit)) + + def iterate_all( + self, hour: Timestamp, *, limit: Optional[int] = None + ) -> Iterator[MarketPosition]: + """Yield every position of :meth:`all` at ``hour``, following cursors.""" + return self._iterate(self._bulk_ep(hour, None, None, limit), _market_position_rows) + + def aiterate_all( + self, hour: Timestamp, *, limit: Optional[int] = None + ) -> AsyncIterator[MarketPosition]: + """Async version of :meth:`iterate_all`.""" + return self._aiterate(self._bulk_ep(hour, None, None, limit), _market_position_rows) + + +AccountIndex = Union[int, str] +"""A Lighter account index: an integer, or a string of digits.""" + + +def _account_index(account_index: AccountIndex, l1_hint: bool) -> str: + """Validate a Lighter account index and render it for the path.""" + if isinstance(account_index, bool): + raise ValueError("account_index must be an integer Lighter account index") + if isinstance(account_index, int): + if account_index < 0: + raise ValueError("account_index must be a non-negative integer") + return str(account_index) + text = str(account_index).strip() + if text.isdigit(): + return text + hint = " Resolve an L1 address with client.lighter.accounts.by_l1(address)." if l1_hint else "" + raise ValueError(f"account_index must be an integer Lighter account index.{hint}") + + +class LighterPositionsResource(_PositionsBase): + """Account positions for Lighter (``client.lighter.positions``) and Lighter + on Robinhood Chain (``client.rh_lighter.positions``). + + Accounts are integer Lighter account indices (on mainnet, + ``client.lighter.accounts.by_l1()`` resolves an L1 address to them). + Perp markets only. System accounts (settlement, insurance) are left out + of the market routes unless ``include_system=True``, and are labelled by + ``account_kind`` everywhere. + + Coverage: from 2025-01-17 on Lighter mainnet and from 2026-06-26 on + Robinhood Chain, with hourly history over the same range and a live + snapshot every 2 minutes. + + Example: + >>> now = client.lighter.positions.get(281474976623827) + >>> for p in now.data.positions: + ... print(p.symbol, p.side, p.size, p.quality) + """ + + def __init__( + self, http: HttpClient, base_path: str = "/v1/lighter", *, l1_resolver: bool = True + ): + super().__init__(http, base_path, _upper) + self._l1_hint = l1_resolver + + def _account_path(self, account_index: AccountIndex, suffix: str) -> str: + index = _account_index(account_index, self._l1_hint) + return f"{self._base_path}/accounts/{index}/{suffix}" + + def _get_ep( + self, + account_index: AccountIndex, + timestamp: Optional[Timestamp], + symbol: Optional[str], + cursor: Optional[str], + limit: Optional[int], + ) -> _Endpoint: + return _Endpoint( + self._account_path(account_index, "positions"), + { + "timestamp": _to_ms(timestamp), + "symbol": self._symbol_param(symbol), + "cursor": cursor, + "limit": limit, + }, + _wallet, + ) + + def _range_ep( + self, + account_index: AccountIndex, + suffix: str, + start: Timestamp, + end: Timestamp, + symbol: Optional[str], + cursor: Optional[str], + limit: Optional[int], + parse: Callable[[Any], Any], + ) -> _Endpoint: + return _Endpoint( + self._account_path(account_index, suffix), + { + "start": _to_ms(start), + "end": _to_ms(end), + "symbol": self._symbol_param(symbol), + "cursor": cursor, + "limit": limit, + }, + parse, + ) + + # ---- account: current or as-of --------------------------------------- + + def get( + self, + account_index: AccountIndex, + *, + timestamp: Optional[Timestamp] = None, + symbol: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[WalletPositions]: + """Open positions of a Lighter account, now or at an instant. + + Same semantics as the Hyperliquid route: the latest live snapshot + without ``timestamp``; with it, the state after every event before + that instant (an exact hour serves the hourly snapshot, anything else + is reconstructed and clamped to ``meta.built_through``). Rows carry + ``finalized``: ``True`` once every event behind them is final. + + On the first page of a snapshot without a ``symbol`` filter, + ``data.account`` is an :class:`AccountSummary` of position aggregates + (``total_position_value``, ``total_unrealized_pnl``, ``long_value``, + ``short_value``, ``n_positions``, ``quality``); its margin fields are + ``None``. + + Args: + account_index: Integer Lighter account index. + timestamp: As-of instant; omit for the latest live snapshot. + symbol: Only this market. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 500, max 5,000). + + Returns: + ``CursorResponse`` whose ``data`` is :class:`WalletPositions` + (``positions``, ``account``, ``account_seen``). + """ + return self._fetch(self._get_ep(account_index, timestamp, symbol, cursor, limit)) + + async def aget( + self, + account_index: AccountIndex, + *, + timestamp: Optional[Timestamp] = None, + symbol: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[WalletPositions]: + """Async version of :meth:`get`.""" + return await self._afetch(self._get_ep(account_index, timestamp, symbol, cursor, limit)) + + # ---- account: hourly history ----------------------------------------- + + def history( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[Position]]: + """Hourly position rows of a Lighter account in ``[start, end)``. + + Args: + account_index: Integer Lighter account index. + start: Inclusive start. + end: Exclusive end. + symbol: Only this market. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 500, max 5,000). + """ + return self._fetch( + self._range_ep( + account_index, "positions/history", start, end, symbol, cursor, limit, _positions + ) + ) + + async def ahistory( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[Position]]: + """Async version of :meth:`history`.""" + return await self._afetch( + self._range_ep( + account_index, "positions/history", start, end, symbol, cursor, limit, _positions + ) + ) + + def iterate_history( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + limit: Optional[int] = None, + ) -> Iterator[Position]: + """Yield every hourly position row of :meth:`history`, following cursors.""" + return self._iterate( + self._range_ep( + account_index, "positions/history", start, end, symbol, None, limit, _positions + ), + _positions_rows, + ) + + def aiterate_history( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[Position]: + """Async version of :meth:`iterate_history`.""" + return self._aiterate( + self._range_ep( + account_index, "positions/history", start, end, symbol, None, limit, _positions + ), + _positions_rows, + ) + + # ---- account: change log --------------------------------------------- + + def changes( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[PositionChange]]: + """Position change-log legs of a Lighter account in ``[start, end)``. + + ``end`` is clamped to ``meta.built_through``. Legs before + ``meta.finalized_through`` are built from the canonical trade record + and are final, the same boundary as ``trades.list()``. + + Args: + account_index: Integer Lighter account index. + start: Inclusive start. + end: Exclusive end. + symbol: Only this market. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 500, max 5,000). + """ + return self._fetch( + self._range_ep( + account_index, "positions/changes", start, end, symbol, cursor, limit, _changes + ) + ) + + async def achanges( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[PositionChange]]: + """Async version of :meth:`changes`.""" + return await self._afetch( + self._range_ep( + account_index, "positions/changes", start, end, symbol, cursor, limit, _changes + ) + ) + + def iterate_changes( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + limit: Optional[int] = None, + ) -> Iterator[PositionChange]: + """Yield every change-log leg of :meth:`changes`, following cursors.""" + return self._iterate( + self._range_ep( + account_index, "positions/changes", start, end, symbol, None, limit, _changes + ), + _change_rows, + ) + + def aiterate_changes( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + symbol: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[PositionChange]: + """Async version of :meth:`iterate_changes`.""" + return self._aiterate( + self._range_ep( + account_index, "positions/changes", start, end, symbol, None, limit, _changes + ), + _change_rows, + ) + + # ---- market routes --------------------------------------------------- + + def market( + self, + symbol: str, + *, + hour: Optional[Timestamp] = None, + side: Optional[PositionSide] = None, + min_value: Optional[float] = None, + include_system: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPosition]]: + """Every open position in one Lighter perp market, largest value first. + + Reads the latest live snapshot, or the hourly snapshot at ``hour``. + The first page carries ``meta.totals``. + + Args: + symbol: Perp market symbol (e.g. ``BTC``). + hour: An exact UTC hour with a committed snapshot; omit for live. + side: ``"long"`` or ``"short"``. + min_value: Minimum position value. + include_system: Include settlement, insurance and other system accounts. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 100, max 2,000). + """ + return self._fetch( + self._market_ep(symbol, hour, side, min_value, include_system, cursor, limit) + ) + + async def amarket( + self, + symbol: str, + *, + hour: Optional[Timestamp] = None, + side: Optional[PositionSide] = None, + min_value: Optional[float] = None, + include_system: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPosition]]: + """Async version of :meth:`market`.""" + return await self._afetch( + self._market_ep(symbol, hour, side, min_value, include_system, cursor, limit) + ) + + def iterate_market( + self, + symbol: str, + *, + hour: Optional[Timestamp] = None, + side: Optional[PositionSide] = None, + min_value: Optional[float] = None, + include_system: Optional[bool] = None, + limit: Optional[int] = None, + ) -> Iterator[MarketPosition]: + """Yield every position of :meth:`market`, following cursors (one snapshot).""" + return self._iterate( + self._market_ep(symbol, hour, side, min_value, include_system, None, limit), + _market_position_rows, + ) + + def aiterate_market( + self, + symbol: str, + *, + hour: Optional[Timestamp] = None, + side: Optional[PositionSide] = None, + min_value: Optional[float] = None, + include_system: Optional[bool] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[MarketPosition]: + """Async version of :meth:`iterate_market`.""" + return self._aiterate( + self._market_ep(symbol, hour, side, min_value, include_system, None, limit), + _market_position_rows, + ) + + def market_summary( + self, + symbol: str, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + include_system: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPositionsSummary]]: + """Long/short aggregates of one Lighter perp market. + + Without ``start`` and ``end``: one summary at the latest live snapshot. + With them: one summary per hourly snapshot in ``[start, end)``, at most + 168 hours per page. + """ + return self._fetch(self._summary_ep(symbol, start, end, include_system, cursor, limit)) + + async def amarket_summary( + self, + symbol: str, + *, + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, + include_system: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPositionsSummary]]: + """Async version of :meth:`market_summary`.""" + return await self._afetch( + self._summary_ep(symbol, start, end, include_system, cursor, limit) + ) + + def iterate_market_summary( + self, + symbol: str, + *, + start: Timestamp, + end: Timestamp, + include_system: Optional[bool] = None, + limit: Optional[int] = None, + ) -> Iterator[MarketPositionsSummary]: + """Yield every hourly summary of :meth:`market_summary` in ``[start, end)``.""" + return self._iterate( + self._summary_ep(symbol, start, end, include_system, None, limit), _summary_rows + ) + + def aiterate_market_summary( + self, + symbol: str, + *, + start: Timestamp, + end: Timestamp, + include_system: Optional[bool] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[MarketPositionsSummary]: + """Async version of :meth:`iterate_market_summary`.""" + return self._aiterate( + self._summary_ep(symbol, start, end, include_system, None, limit), _summary_rows + ) + + def all( + self, + hour: Timestamp, + *, + include_system: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPosition]]: + """Every open position across every perp market at one hourly snapshot (bulk). + + Args: + hour: An exact UTC hour with a committed snapshot. + include_system: Include settlement, insurance and other system accounts. + cursor: ``next_cursor`` of the previous page. + limit: Rows per page (default 1,000, max 2,000). + """ + return self._fetch(self._bulk_ep(hour, include_system, cursor, limit)) + + async def aall( + self, + hour: Timestamp, + *, + include_system: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[MarketPosition]]: + """Async version of :meth:`all`.""" + return await self._afetch(self._bulk_ep(hour, include_system, cursor, limit)) + + def iterate_all( + self, + hour: Timestamp, + *, + include_system: Optional[bool] = None, + limit: Optional[int] = None, + ) -> Iterator[MarketPosition]: + """Yield every position of :meth:`all` at ``hour``, following cursors.""" + return self._iterate( + self._bulk_ep(hour, include_system, None, limit), _market_position_rows + ) + + def aiterate_all( + self, + hour: Timestamp, + *, + include_system: Optional[bool] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[MarketPosition]: + """Async version of :meth:`iterate_all`.""" + return self._aiterate( + self._bulk_ep(hour, include_system, None, limit), _market_position_rows + ) + + +class LighterAccountsResource(_PositionsBase): + """Lighter L1 address resolver (``client.lighter.accounts``, mainnet only). + + Example: + >>> owned = client.lighter.accounts.by_l1("0xabc...") + >>> print(owned.data.total_accounts) + >>> for account in owned.data.accounts: + ... print(account.account_index, account.account_type) + """ + + def __init__(self, http: HttpClient, base_path: str = "/v1/lighter"): + super().__init__(http, base_path, _upper) + + def _by_l1_ep(self, l1_address: str, cursor: Optional[str], limit: Optional[int]) -> _Endpoint: + return _Endpoint( + f"{self._base_path}/accounts", + {"l1_address": l1_address, "cursor": cursor, "limit": limit}, + _l1_accounts, + ) + + def by_l1( + self, + l1_address: str, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[LighterL1Accounts]: + """Account indices owned by an L1 (0x) address. + + Billed at the per-request minimum. + + Args: + l1_address: 0x L1 address. + cursor: ``next_cursor`` of the previous page. + limit: Accounts per page (default 500, max 5,000). + + Returns: + ``CursorResponse`` whose ``data`` is :class:`LighterL1Accounts` + (``l1_address``, ``total_accounts``, ``accounts``). + """ + return self._fetch(self._by_l1_ep(l1_address, cursor, limit)) + + async def aby_l1( + self, + l1_address: str, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[LighterL1Accounts]: + """Async version of :meth:`by_l1`.""" + return await self._afetch(self._by_l1_ep(l1_address, cursor, limit)) + + def iterate_by_l1( + self, l1_address: str, *, limit: Optional[int] = None + ) -> Iterator[LighterL1Account]: + """Yield every account owned by ``l1_address``, following cursors.""" + return self._iterate(self._by_l1_ep(l1_address, None, limit), _l1_account_rows) + + def aiterate_by_l1( + self, l1_address: str, *, limit: Optional[int] = None + ) -> AsyncIterator[LighterL1Account]: + """Async version of :meth:`iterate_by_l1`.""" + return self._aiterate(self._by_l1_ep(l1_address, None, limit), _l1_account_rows) + + +# ---- row extractors for the iterators -------------------------------------- + + +def _positions_rows(data: list[Position]) -> list[Position]: + return data + + +def _change_rows(data: list[PositionChange]) -> list[PositionChange]: + return data + + +def _account_rows(data: list[AccountSummary]) -> list[AccountSummary]: + return data + + +def _market_position_rows(data: list[MarketPosition]) -> list[MarketPosition]: + return data + + +def _summary_rows(data: list[MarketPositionsSummary]) -> list[MarketPositionsSummary]: + return data + + +def _l1_account_rows(data: LighterL1Accounts) -> list[LighterL1Account]: + return data.accounts + + +__all__ = [ + "AccountIndex", + "HyperliquidPositionsResource", + "LighterAccountsResource", + "LighterPositionsResource", + "PositionSide", +] diff --git a/oxarchive/resources/trades.py b/oxarchive/resources/trades.py index 4b7e480..cc13b63 100644 --- a/oxarchive/resources/trades.py +++ b/oxarchive/resources/trades.py @@ -6,7 +6,7 @@ from typing import Literal, Optional from ..http import HttpClient -from ..types import CursorResponse, OxArchiveError, Trade, Timestamp +from ..types import CursorResponse, OxArchiveError, ResponseMeta, Trade, Timestamp class TradesResource: @@ -86,7 +86,10 @@ def list( side: Filter by trade side Returns: - CursorResponse with trades and next_cursor for pagination + CursorResponse with trades and next_cursor for pagination. On + Lighter (mainnet and Robinhood Chain), ``meta.finalized_through`` is + the canonical boundary: ``end`` is clamped to it, and + ``meta.requested_end`` / ``meta.clamped_to`` are set when it was. Example: >>> # First page @@ -114,6 +117,7 @@ def list( return CursorResponse( data=[Trade.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.model_validate(data.get("meta") or {}), ) async def alist( @@ -146,13 +150,15 @@ async def alist( return CursorResponse( data=[Trade.model_validate(item) for item in data["data"]], next_cursor=data.get("meta", {}).get("next_cursor"), + meta=ResponseMeta.model_validate(data.get("meta") or {}), ) def recent(self, symbol: str, limit: Optional[int] = None, **kwargs) -> list[Trade]: """ Get most recent trades for a symbol. - Note: This method is available for Lighter (``client.lighter.trades.recent()``), + Note: This method is available for Lighter (``client.lighter.trades.recent()`` + and ``client.rh_lighter.trades.recent()``, the preliminary tier), HIP-3 (``client.hyperliquid.hip3.trades.recent()``), and HIP-4 (``client.hyperliquid.hip4.trades.recent()``), all of which have real-time data ingestion. Hyperliquid uses hourly S3 backfill so this @@ -164,7 +170,8 @@ def recent(self, symbol: str, limit: Optional[int] = None, **kwargs) -> list[Tra limit: Number of trades to return (default: 100) Returns: - List of recent trades + List of recent trades. The response meta is not returned: on + Lighter, use ``trades.list()`` for ``meta.finalized_through``. """ if not self._allow_recent: raise OxArchiveError( diff --git a/oxarchive/types.py b/oxarchive/types.py index c9b51ab..661f2a8 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -5,7 +5,7 @@ from datetime import date, datetime from typing import Any, Generic, Literal, Optional, TypeVar, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator # ============================================================================= @@ -1088,6 +1088,8 @@ class Candle(BaseModel): "open_interest", "funding", "lighter_orderbook", "lighter_trades", "lighter_candles", "lighter_open_interest", "lighter_funding", "lighter_l3_orderbook", + "rh_lighter_orderbook", "rh_lighter_trades", "rh_lighter_candles", + "rh_lighter_open_interest", "rh_lighter_funding", "hip3_orderbook", "hip3_trades", "hip3_candles", "hip3_open_interest", "hip3_funding", "hip3_liquidations", "hip4_orderbook", "hip4_trades", "hip4_open_interest", @@ -1110,6 +1112,12 @@ class Candle(BaseModel): Hyperliquid-style shapes described on :class:`LighterLiveTrade` and :class:`LighterMarketContext`; replay rows keep their historical shapes. - lighter_candles and lighter_l3_orderbook support historical replay only. +- rh_lighter_orderbook, rh_lighter_trades, rh_lighter_open_interest and + rh_lighter_funding (Lighter on Robinhood Chain) support live subscriptions + and historical replay, with the same live message shapes as the mainnet + ``lighter_*`` channels. rh_lighter_orderbook accepts ``interval_ms`` like + lighter_orderbook. rh_lighter_candles supports historical replay only. Live + Robinhood Chain channels are served on wss://api.0xarchive.io/ws only. - l4_diffs, l4_orders: Hyperliquid core L4 order-level data. Historical replay emits one ``l4_snapshot`` followed by ordered ``l4_batch`` messages. - hip3_l4_diffs, hip3_l4_orders: HIP-3 L4 order-level data (live-only). @@ -1167,8 +1175,8 @@ class WsData(BaseModel): Note: The `data` field can be either a dict (for orderbook) or a list (for trades). - Orderbook: dict with 'levels', 'time', etc. - Trades: list of trade objects with 'coin', 'side', 'px', 'sz', etc. - - lighter_open_interest / lighter_funding: dict with 'coin' and 'ctx' - (see :class:`LighterMarketContextUpdate`). + - lighter_open_interest / lighter_funding (and the rh_lighter_* equivalents): + dict with 'coin' and 'ctx' (see :class:`LighterMarketContextUpdate`). """ type: Literal["data"] @@ -1744,6 +1752,589 @@ def __str__(self) -> str: return f"[{self.code}] {self.message}" +# ============================================================================= +# Lighter Liquidation Types (Lighter mainnet and Lighter on Robinhood Chain) +# ============================================================================= + + +class LighterLiquidation(BaseModel): + """One Lighter liquidation event. + + Returned by ``client.lighter.liquidations.history()`` and + ``client.rh_lighter.liquidations.history()``. Lighter liquidations are the + liquidation trades of the venue's trade stream. The row keeps both sides' + raw account fields (``ask_account``, ``bid_account``, ``is_maker_ask`` and + the ``*_position_sign_changed`` flags) rather than a single liquidated + account, because the trade payload does not always say which side was + liquidated. Prices and sizes are in market units; the integer margin and + fee fields are in the venue's raw integer units. + """ + + symbol: str + """Market symbol.""" + + timestamp: int + """Trade time in Unix milliseconds.""" + + transaction_time_us: Optional[int] = None + """Transaction time in microseconds (orders events within a block).""" + + trade_id: int + """Lighter trade id.""" + + liquidation_type: Optional[str] = None + """Liquidation type as reported by Lighter.""" + + price: float + """Execution price.""" + + size: float + """Execution size in base units.""" + + usd_amount: Optional[float] = None + """Notional in the quote asset (USDC on mainnet, USDG on Robinhood Chain).""" + + ask_account: Optional[str] = None + """Account index of the ask side, as a string.""" + + bid_account: Optional[str] = None + """Account index of the bid side, as a string.""" + + ask_order_id: Optional[int] = None + """Order id of the ask side.""" + + bid_order_id: Optional[int] = None + """Order id of the bid side.""" + + is_maker_ask: Optional[bool] = None + """``True`` when the ask side was the maker.""" + + taker_position_size_before: Optional[float] = None + """Taker's signed position before the trade (positive long, negative short).""" + + maker_position_size_before: Optional[float] = None + """Maker's signed position before the trade.""" + + taker_entry_quote_before: Optional[float] = None + """Taker's entry quote before the trade.""" + + maker_entry_quote_before: Optional[float] = None + """Maker's entry quote before the trade.""" + + taker_initial_margin_fraction_before: Optional[int] = None + """Taker's initial margin fraction before the trade (raw integer units).""" + + maker_initial_margin_fraction_before: Optional[int] = None + """Maker's initial margin fraction before the trade (raw integer units).""" + + taker_allocated_margin_usdc_before: Optional[int] = None + """Taker's allocated margin before the trade (raw integer units).""" + + taker_allocated_margin_usdc_after: Optional[int] = None + """Taker's allocated margin after the trade (raw integer units).""" + + maker_allocated_margin_usdc_before: Optional[int] = None + """Maker's allocated margin before the trade (raw integer units).""" + + maker_allocated_margin_usdc_after: Optional[int] = None + """Maker's allocated margin after the trade (raw integer units).""" + + taker_fee: Optional[int] = None + """Taker fee (raw integer units).""" + + maker_fee: Optional[int] = None + """Maker fee (raw integer units).""" + + taker_position_sign_changed: Optional[bool] = None + """``True`` when the trade flipped the taker's position sign.""" + + maker_position_sign_changed: Optional[bool] = None + """``True`` when the trade flipped the maker's position sign.""" + + block_height: Optional[int] = None + """Block height of the trade.""" + + tx_hash: Optional[str] = None + """Transaction hash.""" + + raw_json: Optional[str] = None + """The trade object exactly as captured, as a JSON string. Empty for rows + backfilled from the venue's finalized export (``source == "bucket"``).""" + + source: Optional[str] = None + """Where the row came from. ``"ws"`` marks rows captured live, which keep + the venue's raw JSON in ``raw_json``. ``"bucket"`` marks rows backfilled + from the venue's finalized export, which carry an empty ``raw_json``; on + Robinhood Chain these cover the span before live capture.""" + + +class LighterLiquidationVolume(BaseModel): + """Aggregated Lighter liquidation volume for one time bucket. + + Lighter buckets carry the total only. There is no long/short split because + the trade payload does not reliably say which side was liquidated. + """ + + symbol: str + """Market symbol.""" + + timestamp: int + """Bucket start in Unix milliseconds.""" + + total_usd: float + """Total liquidated notional in the bucket, in the quote asset.""" + + count: int + """Number of liquidation trades in the bucket.""" + + +# ============================================================================= +# Account Positions Types +# ============================================================================= +# +# Numbers are decimal strings (sizes at the market's size precision, prices at +# its price precision, USD values to 6 decimals); a flat position is ``"0"``. +# A field is ``None`` when it is unknown, never a guess. Timestamps are UTC. + + +class PositionLeverage(BaseModel): + """Leverage of a position.""" + + type: str + """``"cross"``, ``"isolated"`` or ``"unknown"`` (always ``"unknown"`` on + reconstructed Hyperliquid rows). On Lighter, the margin mode.""" + + value: Optional[str] = None + """Leverage multiple, or ``None`` when not reported.""" + + +class PositionCumFunding(BaseModel): + """Cumulative funding of a position, in USD (Hyperliquid snapshot rows only).""" + + all_time: Optional[str] = None + since_open: Optional[str] = None + since_change: Optional[str] = None + + +class Position(BaseModel): + """One open position of an account. + + Returned by the wallet or account routes (``positions.get()``, + ``positions.history()``). Hyperliquid rows carry the full snapshot fields; + Lighter rows carry ``account_index``, ``account_kind`` and the Lighter + extras, and leave the Hyperliquid-only fields ``None``. + """ + + snapshot_ts: Optional[datetime] = None + """Hour the row describes (hourly history rows only).""" + + account_index: Optional[str] = None + """Lighter account index, as a string (Lighter only).""" + + account_kind: Optional[str] = None + """Lighter only: ``"user"``, ``"settlement"``, ``"insurance"`` or ``"system"``.""" + + symbol: str + """Market symbol.""" + + coin: str + """Alias of ``symbol``.""" + + dex: Optional[str] = None + """HIP-3 dex (HIP-3 only).""" + + size: str + """Signed position size; negative is short.""" + + side: Literal["long", "short"] + """Position side.""" + + entry_price: Optional[str] = None + mark_price: Optional[str] = None + + mark_time: Optional[datetime] = None + """Time of the mark used for ``mark_price``, ``position_value`` and ``unrealized_pnl``.""" + + position_value: Optional[str] = None + """Absolute size times mark, in USD.""" + + unrealized_pnl: Optional[str] = None + return_on_equity: Optional[str] = None + leverage: PositionLeverage + max_leverage: Optional[int] = None + margin_used: Optional[str] = None + liquidation_price: Optional[str] = None + + liquidation_price_status: str + """How ``liquidation_price`` was determined: ``"exact"`` (set), + ``"not_published_cross"`` (Hyperliquid does not publish cross-margin + liquidation prices), ``"changed_since_snapshot"`` or ``"unavailable"``.""" + + cum_funding: PositionCumFunding + + opened_at: Optional[datetime] = None + """When the current position was opened.""" + + snapshot_as_of: Optional[datetime] = None + """Instant the leverage, funding and account fields describe. Size, entry + and mark fields describe ``snapshot_ts`` (or the response's ``as_of``).""" + + quality: str + """Row quality: ``"complete"``, ``"partial"`` (for example no mark), + ``"degraded"``; Lighter also uses ``"preliminary"``, ``"unreconciled"`` and + ``"incomplete"``.""" + + initial_margin_fraction: Optional[str] = None + """Lighter only: initial margin fraction at the last trade, as a fraction.""" + + allocated_margin: Optional[str] = None + """Lighter only: allocated margin in the quote asset.""" + + margin_mode: Optional[str] = None + """Lighter only: margin mode.""" + + mark_source: Optional[str] = None + """Lighter only: where the mark came from (``"none"`` when there was no mark).""" + + finalized: Optional[bool] = None + """Lighter only: ``True`` when every event behind the row is final.""" + + +class MarketPosition(BaseModel): + """One open position in a market-wide listing (``positions.market()`` and + ``positions.all()``). A lean record: the account plus size, price and value.""" + + snapshot_ts: Optional[datetime] = None + """Hour the row describes (bulk ``positions.all()`` rows).""" + + user_address: Optional[str] = None + """Wallet address (Hyperliquid and HIP-3).""" + + account_index: Optional[str] = None + """Account index, as a string (Lighter).""" + + account_kind: Optional[str] = None + """Account kind (Lighter): ``"user"``, ``"settlement"``, ``"insurance"`` or ``"system"``.""" + + symbol: str + coin: str + dex: Optional[str] = None + size: str + side: Literal["long", "short"] + entry_price: Optional[str] = None + mark_price: Optional[str] = None + position_value: Optional[str] = None + unrealized_pnl: Optional[str] = None + + leverage_type: str + """``"cross"``, ``"isolated"`` or ``"unknown"``; on Lighter, the margin mode.""" + + liquidation_price: Optional[str] = None + quality: str + + +class PositionChange(BaseModel): + """One change-log leg: a fill or event that changed an account's position. + + ``side`` is ``"B"`` or ``"A"`` exactly as on trades. Hyperliquid rows carry + ``direction``, ``closed_pnl``, ``crossed`` and ``seq``; Lighter rows carry + ``realized_pnl``, ``is_maker`` and the Lighter extras. + """ + + timestamp: datetime + account_index: Optional[str] = None + account_kind: Optional[str] = None + symbol: str + coin: str + dex: Optional[str] = None + side: Literal["A", "B"] + price: Optional[str] = None + size: Optional[str] = None + + start_position: Optional[str] = None + """Signed position before the leg.""" + + end_position: Optional[str] = None + """Signed position after the leg.""" + + entry_price_after: Optional[str] = None + """Entry price after the leg; ``None`` when the position is flat.""" + + event_type: str + """What the leg did to the position: ``"open"``, ``"increase"``, + ``"reduce"``, ``"close"`` or ``"flip"``. On Lighter, a leg that leaves the + position unchanged is ``"settlement"`` (the settlement counterparty's side + of a market settlement) or otherwise ``"unchanged"``.""" + + cause: str + """What produced the leg: ``"trade"``, ``"liquidation"``, + ``"liquidation_counterparty"``, ``"adl"``, ``"settlement"`` or ``"unknown"``.""" + + direction: Optional[str] = None + """Hyperliquid only: direction as on trades (for example ``"Open Long"``).""" + + closed_pnl: Optional[str] = None + """Hyperliquid only: realized PnL of the leg.""" + + realized_pnl: Optional[str] = None + """Lighter only: realized PnL of the leg.""" + + fee: Optional[str] = None + fee_token: str + + crossed: Optional[bool] = None + """Hyperliquid only: ``True`` for the taker leg.""" + + is_maker: Optional[bool] = None + """Lighter only: ``True`` for the maker leg.""" + + trade_id: int + order_id: Optional[int] = None + opened_at: Optional[datetime] = None + + seq: Optional[int] = None + """Hyperliquid only: order of the leg among legs with the same timestamp.""" + + block_number: Optional[int] = None + """Hyperliquid only: block of the leg, when known.""" + + event_index: Optional[int] = None + """Hyperliquid only: index of the leg within its block, when known.""" + + continuity: str + """``"ok"``, ``"inferred"`` (the chain was re-seeded from the venue's + reported start position), ``"first_seen"`` or ``"quarantined"``.""" + + position_size_before: Optional[str] = None + """Lighter alias of ``start_position``.""" + + position_size_after: Optional[str] = None + """Lighter alias of ``end_position``.""" + + fee_rate: Optional[str] = None + """Lighter only: fee rate as a fraction.""" + + fee_usdc: Optional[str] = None + """Lighter only: fee in the quote asset.""" + + usdc_amount: Optional[str] = None + """Lighter only: notional of the leg in the quote asset.""" + + finalized: Optional[bool] = None + """``True`` when the leg is final and will not be re-derived.""" + + +class AccountSummary(BaseModel): + """Account summary at one snapshot. + + Hyperliquid and HIP-3: the full clearinghouse summary, one row per + clearinghouse (Hyperliquid core has one per address, HIP-3 one per dex), + returned by ``positions.account()`` / ``positions.account_history()`` and + as ``WalletPositions.account``. ``withdrawable`` is only available for + older hourly history. + + Lighter mainnet and Robinhood Chain: position aggregates only, as + ``WalletPositions.account`` on ``positions.get()``. ``account_index``, + ``total_position_value``, ``total_unrealized_pnl``, ``long_value``, + ``short_value``, ``n_positions`` and ``quality`` are set; ``dex``, the + margin fields (``account_value``, ``cross_account_value``, ``collateral``, + ``total_margin_used``, ``cross_maintenance_margin_used``, + ``withdrawable``), ``account_mode`` and ``snapshot_as_of`` are ``None``. + A Lighter total is ``None`` when any position in it has no mark, never a + partial sum. + """ + + snapshot_ts: Optional[datetime] = None + """Hour the row describes (hourly history rows only).""" + + account_index: Optional[str] = None + """Lighter account index, as a string (Lighter summaries only).""" + + dex: Optional[str] = None + account_value: Optional[str] = None + cross_account_value: Optional[str] = None + collateral: Optional[str] = None + total_margin_used: Optional[str] = None + cross_maintenance_margin_used: Optional[str] = None + withdrawable: Optional[str] = None + total_position_value: Optional[str] = None + total_unrealized_pnl: Optional[str] = None + long_value: Optional[str] = None + short_value: Optional[str] = None + n_positions: int + account_mode: Optional[str] = None + snapshot_as_of: Optional[datetime] = None + quality: str + + +class MarketPositionsSummary(BaseModel): + """Long/short aggregates of every open position in one market at one snapshot. + + Returned by ``positions.market_summary()`` and as ``meta.totals`` on the + first page of ``positions.market()``. Average entries cover only the + positions whose entry is known (``*_positions_with_entry``). A total or share + is ``None`` when any position in it has no mark, never a partial sum. + """ + + snapshot_ts: Optional[datetime] = None + symbol: str + coin: str + dex: Optional[str] = None + long_count: int + short_count: int + long_size: str + short_size: str + long_value: Optional[str] = None + short_value: Optional[str] = None + long_avg_entry_price: Optional[str] = None + short_avg_entry_price: Optional[str] = None + long_positions_with_entry: int + short_positions_with_entry: int + + long_top10_value_share: Optional[str] = None + """Share of long value held by the 10 largest longs, as a fraction.""" + + short_top10_value_share: Optional[str] = None + """Share of short value held by the 10 largest shorts, as a fraction.""" + + top10_value_share: Optional[str] = None + """Share of total value held by the 10 largest positions, as a fraction.""" + + quality: str + + +class WalletPositions(BaseModel): + """``data`` of ``positions.get()``: an account's open positions at one instant.""" + + positions: list[Position] = Field(default_factory=list) + """Open positions (flat markets are not listed).""" + + account: Optional[AccountSummary] = None + """Account summary on the first page of a snapshot (the live snapshot, or a + ``timestamp`` on an exact UTC hour). Hyperliquid core: when the wallet has + an account in that snapshot. HIP-3: when ``dex`` (or a ``symbol``) names + one dex. Lighter mainnet and + Robinhood Chain: when the request has no ``symbol`` filter; the summary + holds position aggregates only (see :class:`AccountSummary`). ``None`` on + later pages and on reconstructions (a ``timestamp`` between hours).""" + + account_seen: Optional[str] = None + """Set only when ``positions`` is empty: ``"flat"`` (activity recorded, no + open position), ``"never_seen"`` (no activity recorded in the covered + history; see ``meta.notice`` and ``meta.coverage_from``) or + ``"outside_coverage"`` (the instant is before coverage begins).""" + + +class LighterL1Account(BaseModel): + """One Lighter account owned by an L1 address.""" + + account_index: str + """Account index, as a string. Pass it to ``positions.get()``.""" + + account_type: int + """Lighter account type.""" + + first_seen: Optional[datetime] = None + """When the account was first seen.""" + + +class LighterL1Accounts(BaseModel): + """``data`` of ``client.lighter.accounts.by_l1()``.""" + + l1_address: str + """The L1 address, lowercased.""" + + total_accounts: int + """Number of accounts the address owns (across all pages).""" + + accounts: list[LighterL1Account] = Field(default_factory=list) + """Accounts on this page, ascending by account index.""" + + +class ResponseMeta(BaseModel): + """Response metadata (the ``meta`` object of an API response). + + Every field is optional; a route sets only the fields that apply to it. + Unknown fields are kept. Instants are UTC. + """ + + model_config = ConfigDict(extra="allow") + + count: Optional[int] = None + """Rows in this page.""" + + next_cursor: Optional[str] = None + """Cursor for the next page; ``None`` on the last page.""" + + request_id: Optional[str] = None + + finalized_through: Optional[datetime] = None + """Every event before this instant is final and will not be re-derived. + On Lighter trades, the canonical boundary; on account positions, how far the + position data is final.""" + + requested_end: Optional[datetime] = None + """Set with ``clamped_to`` when the request asked past the served boundary: + the end (or ``timestamp``) the request asked for.""" + + clamped_to: Optional[datetime] = None + """Set when the request was clamped: the boundary it was clamped to + (``finalized_through`` on Lighter trades, ``built_through`` on positions).""" + + preliminary_row_count: Optional[int] = None + """Rows in the page that are not final yet. The API sends it only on the + Lighter ``/trades/{symbol}/recent`` response, and ``trades.recent()`` + returns a plain list without the meta, so no SDK method returns it in this + release. It is typed so the meta parses wherever it appears.""" + + coverage_from: Optional[datetime] = None + """Where coverage begins, set with ``notice`` when a window is before coverage.""" + + notice: Optional[str] = None + """Human-readable advisory about this response.""" + + as_of: Optional[datetime] = None + """Instant the returned state describes, taken from the data.""" + + snapshot_ts: Optional[datetime] = None + """Snapshot the response was read from (market routes echo ``hour`` here).""" + + source: Optional[str] = None + """How the rows were produced: ``"snapshot"``, ``"reconstructed"`` or ``"changes"``.""" + + quality: Optional[str] = None + """Completeness of the snapshot the response was read from, for example + ``"complete"``, ``"partial"`` or ``"degraded"``.""" + + stale: Optional[bool] = None + """``True`` when the latest live snapshot is older than 12 minutes (with a ``notice``).""" + + totals: Optional[Union[MarketPositionsSummary, dict[str, Any]]] = Field( + default=None, union_mode="left_to_right" + ) + """Totals over the whole filtered result set, not just this page. Set on the + first page of ``positions.market()``.""" + + built_through: Optional[datetime] = None + """Account positions: every event before this instant is built into the + change log and the as-of state; reads are clamped to it. Data before it may + still be preliminary (see ``finalized_through``).""" + + @field_validator( + "finalized_through", + "requested_end", + "clamped_to", + "coverage_from", + "as_of", + "snapshot_ts", + "built_through", + mode="before", + ) + @classmethod + def _empty_instant_is_none(cls, value: Any) -> Any: + return None if value == "" else value + + # ============================================================================= # Pagination Types # ============================================================================= @@ -1758,6 +2349,10 @@ class CursorResponse(BaseModel, Generic[T]): next_cursor: Optional[str] = None """Cursor for the next page (use as cursor parameter).""" + meta: Optional[ResponseMeta] = None + """The response's metadata, where the method exposes it: account positions, + trades and Lighter liquidations. ``None`` elsewhere.""" + # Type alias for timestamp parameters Timestamp = Union[int, str, datetime] diff --git a/oxarchive/websocket.py b/oxarchive/websocket.py index 6aeac15..f83831d 100644 --- a/oxarchive/websocket.py +++ b/oxarchive/websocket.py @@ -18,6 +18,11 @@ >>> ws.subscribe_lighter_orderbook("BTC", interval_ms=250) >>> ws.subscribe_lighter_trades("BTC") + Live Lighter on Robinhood Chain data (same message shapes, own handlers): + >>> ws.on_rh_lighter_orderbook(lambda coin, ob: print(f"RH {coin}: {ob.mid_price}")) + >>> ws.subscribe_rh_lighter_orderbook("AAPL-USDG") + >>> ws.subscribe_rh_lighter_trades("BTC") + Historical replay (like Tardis.dev): >>> ws = OxArchiveWs(WsOptions(api_key="ox_...")) >>> await ws.connect() @@ -123,11 +128,41 @@ "Use REST for current data or a replay request for stored history." ) +RH_LIGHTER_LIVE_CHANNELS: frozenset[str] = frozenset( + { + "rh_lighter_orderbook", + "rh_lighter_trades", + "rh_lighter_open_interest", + "rh_lighter_funding", + } +) +"""Lighter on Robinhood Chain channels that support live subscriptions (and +historical replay). Live messages use the same shapes as the mainnet +``lighter_*`` live channels.""" + +RH_LIGHTER_REPLAY_ONLY_CHANNELS: frozenset[str] = frozenset({"rh_lighter_candles"}) +"""Lighter on Robinhood Chain channels available through historical replay only.""" + +RH_LIGHTER_REPLAY_CHANNELS: frozenset[str] = ( + RH_LIGHTER_LIVE_CHANNELS | RH_LIGHTER_REPLAY_ONLY_CHANNELS +) +"""All five Lighter on Robinhood Chain channels; each supports historical replay.""" + +RH_LIGHTER_SUBSCRIPTION_ERROR = ( + "rh_lighter_candles supports replay, not live subscriptions. " + "Use REST for current data or a replay request for stored history." +) + +LIGHTER_BOOK_CHANNELS: frozenset[str] = frozenset({"lighter_orderbook", "rh_lighter_orderbook"}) +"""Live book channels that accept ``interval_ms``.""" + LIGHTER_BOOK_INTERVAL_MIN_MS = 100 -"""Smallest ``interval_ms`` accepted on a ``lighter_orderbook`` subscription.""" +"""Smallest ``interval_ms`` accepted on a ``lighter_orderbook`` or +``rh_lighter_orderbook`` subscription.""" LIGHTER_BOOK_INTERVAL_MAX_MS = 5000 -"""Largest ``interval_ms`` accepted on a ``lighter_orderbook`` subscription.""" +"""Largest ``interval_ms`` accepted on a ``lighter_orderbook`` or +``rh_lighter_orderbook`` subscription.""" LIGHTER_BOOK_INTERVAL_DEFAULT_MS = 1000 """Book interval the server uses when ``interval_ms`` is left out.""" @@ -175,16 +210,24 @@ def _validate_historical_l4_channel(channel: WsChannel) -> None: def _validate_live_subscription(channel: WsChannel, interval_ms: Optional[int] = None) -> None: """Reject live subscriptions the server would refuse, before any state changes. - ``lighter_candles`` and ``lighter_l3_orderbook`` are replay-only, and - ``interval_ms`` is accepted only on ``lighter_orderbook``, as an integer - between ``LIGHTER_BOOK_INTERVAL_MIN_MS`` and ``LIGHTER_BOOK_INTERVAL_MAX_MS``. + ``lighter_candles``, ``lighter_l3_orderbook`` and ``rh_lighter_candles`` + are replay-only, and ``interval_ms`` is accepted only on + ``lighter_orderbook`` and ``rh_lighter_orderbook``, as an integer between + ``LIGHTER_BOOK_INTERVAL_MIN_MS`` and ``LIGHTER_BOOK_INTERVAL_MAX_MS``. """ if channel in LIGHTER_REPLAY_ONLY_CHANNELS: raise ValueError(LIGHTER_SUBSCRIPTION_ERROR) + if channel in RH_LIGHTER_REPLAY_ONLY_CHANNELS: + raise ValueError(RH_LIGHTER_SUBSCRIPTION_ERROR) if interval_ms is None: return - if channel != "lighter_orderbook": - raise ValueError("interval_ms is only supported on lighter_orderbook.") + if channel not in LIGHTER_BOOK_CHANNELS: + book = ( + "rh_lighter_orderbook" + if channel in RH_LIGHTER_REPLAY_CHANNELS + else "lighter_orderbook" + ) + raise ValueError(f"interval_ms is only supported on {book}.") if isinstance(interval_ms, bool) or not isinstance(interval_ms, int): raise ValueError( f"interval_ms must be an integer number of milliseconds (got {interval_ms!r})." @@ -192,7 +235,7 @@ def _validate_live_subscription(channel: WsChannel, interval_ms: Optional[int] = if not LIGHTER_BOOK_INTERVAL_MIN_MS <= interval_ms <= LIGHTER_BOOK_INTERVAL_MAX_MS: raise ValueError( f"interval_ms must be between {LIGHTER_BOOK_INTERVAL_MIN_MS} and " - f"{LIGHTER_BOOK_INTERVAL_MAX_MS} for lighter_orderbook (got {interval_ms!r}). " + f"{LIGHTER_BOOK_INTERVAL_MAX_MS} for {channel} (got {interval_ms!r}). " "Leave it out for one book a second." ) @@ -534,6 +577,9 @@ def __init__(self, options: WsOptions): self._on_lighter_orderbook: Optional[OrderbookHandler] = None self._on_lighter_trades: Optional[TradesHandler] = None self._on_lighter_market_context: Optional[LighterMarketContextHandler] = None + self._on_rh_lighter_orderbook: Optional[OrderbookHandler] = None + self._on_rh_lighter_trades: Optional[TradesHandler] = None + self._on_rh_lighter_market_context: Optional[LighterMarketContextHandler] = None self._on_state_change: Optional[StateHandler] = None self._on_error: Optional[ErrorHandler] = None self._on_open: Optional[Callable[[], None]] = None @@ -631,16 +677,16 @@ def subscribe( Args: channel: Channel type coin: Coin symbol (required for coin-specific channels) - interval_ms: ``lighter_orderbook`` only. Send the newest book at - most once per this many milliseconds (100 to 5000). Leave it - out for one book a second. Each book sent is one metered - message. + interval_ms: ``lighter_orderbook`` and ``rh_lighter_orderbook`` + only. Send the newest book at most once per this many + milliseconds (100 to 5000). Leave it out for one book a + second. Each book sent is one metered message. Raises: - ValueError: If ``channel`` is ``lighter_candles`` or - ``lighter_l3_orderbook`` (replay-only), or if ``interval_ms`` is - passed for another channel, is not an integer, or is out of - range. + ValueError: If ``channel`` is ``lighter_candles``, + ``lighter_l3_orderbook`` or ``rh_lighter_candles`` + (replay-only), or if ``interval_ms`` is passed for another + channel, is not an integer, or is out of range. """ _validate_live_subscription(channel, interval_ms) self._remember_subscription(channel, coin, interval_ms) @@ -657,9 +703,9 @@ async def subscribe_async( ) -> None: """Subscribe asynchronously to a supported live channel. - Takes the same arguments as :meth:`subscribe`. ``lighter_candles`` and - ``lighter_l3_orderbook`` are replay-only; use REST for current data or - :meth:`replay` for stored history. + Takes the same arguments as :meth:`subscribe`. ``lighter_candles``, + ``lighter_l3_orderbook`` and ``rh_lighter_candles`` are replay-only; + use REST for current data or :meth:`replay` for stored history. """ _validate_live_subscription(channel, interval_ms) self._remember_subscription(channel, coin, interval_ms) @@ -911,6 +957,73 @@ def unsubscribe_lighter_funding(self, coin: str) -> None: """Unsubscribe from live Lighter ``lighter_funding`` updates.""" self.unsubscribe("lighter_funding", coin) + # -- Lighter on Robinhood Chain (live) ----------------------------------- + # + # The Robinhood Chain deployment of Lighter has its own channels, with the + # same live message shapes as the mainnet ``lighter_*`` channels. Its + # symbols are its own (USDG-quoted; perps like ``BTC``, spot like + # ``AAPL-USDG``), so these messages have their own handlers. Live + # Robinhood Chain channels are served at ``wss://api.0xarchive.io/ws`` + # (the client default), not at ``wss://stream.0xarchive.io/ws``. + + def subscribe_rh_lighter_orderbook(self, coin: str, interval_ms: Optional[int] = None) -> None: + """Subscribe to live Lighter on Robinhood Chain L2 books for a market. + + Same message as ``lighter_orderbook``: a full top-20 book per side, + the newest one at most once per interval (one second by default, or + ``interval_ms`` between 100 and 5000). Each book sent is one metered + message. + + Books arrive on :meth:`on_rh_lighter_orderbook` when it is set, + otherwise on :meth:`on_orderbook`. Symbols are case-insensitive (the + server echoes them uppercase). + """ + self.subscribe("rh_lighter_orderbook", coin, interval_ms=interval_ms) + + def unsubscribe_rh_lighter_orderbook(self, coin: str) -> None: + """Unsubscribe from live Lighter on Robinhood Chain L2 books for a market.""" + self.unsubscribe("rh_lighter_orderbook", coin) + + def subscribe_rh_lighter_trades(self, coin: str) -> None: + """Subscribe to live Lighter on Robinhood Chain trades for a market. + + Same message as ``lighter_trades``: two legs per trade sharing + ``trade_id``. Live trades are preliminary; the finalized record is + served by ``client.rh_lighter.trades.list()``. + + Trades arrive on :meth:`on_rh_lighter_trades` when it is set, otherwise + on :meth:`on_trades`. + """ + self.subscribe("rh_lighter_trades", coin) + + def unsubscribe_rh_lighter_trades(self, coin: str) -> None: + """Unsubscribe from live Lighter on Robinhood Chain trades for a market.""" + self.unsubscribe("rh_lighter_trades", coin) + + def subscribe_rh_lighter_open_interest(self, coin: str) -> None: + """Subscribe to live Robinhood Chain market context on ``rh_lighter_open_interest``. + + ``rh_lighter_open_interest`` and ``rh_lighter_funding`` carry the same + message. Updates arrive on :meth:`on_rh_lighter_market_context`. + """ + self.subscribe("rh_lighter_open_interest", coin) + + def unsubscribe_rh_lighter_open_interest(self, coin: str) -> None: + """Unsubscribe from live ``rh_lighter_open_interest`` updates.""" + self.unsubscribe("rh_lighter_open_interest", coin) + + def subscribe_rh_lighter_funding(self, coin: str) -> None: + """Subscribe to live Robinhood Chain market context on ``rh_lighter_funding``. + + Carries the same message as ``rh_lighter_open_interest``. Updates + arrive on :meth:`on_rh_lighter_market_context`. + """ + self.subscribe("rh_lighter_funding", coin) + + def unsubscribe_rh_lighter_funding(self, coin: str) -> None: + """Unsubscribe from live ``rh_lighter_funding`` updates.""" + self.unsubscribe("rh_lighter_funding", coin) + # ========================================================================= # Historical Replay (Option B) - Like Tardis.dev # ========================================================================= @@ -930,9 +1043,10 @@ 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 support bounded historical replay. - Replay rows keep their historical shapes, which differ from the live - Lighter messages. + 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 + historical shapes, which differ from the live Lighter messages. Args: channel: Data channel to replay @@ -1222,6 +1336,32 @@ def on_lighter_market_context(self, handler: LighterMarketContextHandler) -> Non """ self._on_lighter_market_context = handler + def on_rh_lighter_orderbook(self, handler: OrderbookHandler) -> None: + """Set handler for live ``rh_lighter_orderbook`` books (Lighter on Robinhood Chain). + + Handler receives ``(coin, OrderBook)``. When set, Robinhood Chain books + go here instead of :meth:`on_orderbook`. They never reach + :meth:`on_lighter_orderbook`, which is for Lighter mainnet only. + """ + self._on_rh_lighter_orderbook = handler + + def on_rh_lighter_trades(self, handler: TradesHandler) -> None: + """Set handler for live ``rh_lighter_trades`` legs (Lighter on Robinhood Chain). + + Handler receives ``(coin, [Trade, ...])``, two legs per trade sharing + ``trade_id``. When set, Robinhood Chain trades go here instead of + :meth:`on_trades`. They never reach :meth:`on_lighter_trades`. + """ + self._on_rh_lighter_trades = handler + + def on_rh_lighter_market_context(self, handler: LighterMarketContextHandler) -> None: + """Set handler for live ``rh_lighter_open_interest`` / ``rh_lighter_funding`` updates. + + Handler receives ``(channel, coin, LighterMarketContext)``, the same + message shape as the mainnet Lighter channels. + """ + self._on_rh_lighter_market_context = handler + def on_state_change(self, handler: StateHandler) -> None: """Set handler for state changes.""" self._on_state_change = handler @@ -1383,11 +1523,12 @@ def _set_state(self, state: WsConnectionState) -> None: def _subscription_key(self, channel: WsChannel, coin: Optional[str]) -> str: """Create subscription key. - Live Lighter symbols are case-insensitive on the server, which echoes - them uppercase, so their keys use the uppercase symbol. ``btc`` and - ``BTC`` then name one subscription here, as they do on the server. + Live Lighter symbols (mainnet and Robinhood Chain) are + case-insensitive on the server, which echoes them uppercase, so their + keys use the uppercase symbol. ``btc`` and ``BTC`` then name one + subscription here, as they do on the server. """ - if coin and channel in LIGHTER_LIVE_CHANNELS: + if coin and (channel in LIGHTER_LIVE_CHANNELS or channel in RH_LIGHTER_LIVE_CHANNELS): coin = coin.upper() return f"{channel}:{coin}" if coin else channel @@ -1518,6 +1659,26 @@ def _handle_message(self, raw: str) -> None: if channel == "lighter_orderbook" and self._on_lighter_orderbook: self._on_lighter_orderbook(coin, _transform_orderbook(coin, raw_data)) + # Lighter on Robinhood Chain: the same live shapes as mainnet + # Lighter, delivered to their own handlers (the deployments' + # symbols collide) and otherwise to the generic ones. + elif channel == "rh_lighter_orderbook": + book_handler = self._on_rh_lighter_orderbook or self._on_orderbook + if book_handler: + book_handler(coin, _transform_orderbook(coin, raw_data)) + + elif channel == "rh_lighter_trades": + rh_trades_handler = self._on_rh_lighter_trades or self._on_trades + if rh_trades_handler: + rh_trades_handler(coin, _transform_lighter_trades(coin, raw_data)) + + elif ( + channel in ("rh_lighter_open_interest", "rh_lighter_funding") + and self._on_rh_lighter_market_context + ): + rh_update = _transform_lighter_market_context(coin, raw_data) + self._on_rh_lighter_market_context(channel, coin, rh_update.ctx) + elif channel == "lighter_trades": trades_handler = self._on_lighter_trades or self._on_trades if trades_handler: diff --git a/pyproject.toml b/pyproject.toml index f4951e0..5edf697 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "oxarchive" -version = "1.11.0" +version = "1.12.0" description = "Python SDK for the 0xArchive market data API for Hyperliquid and Lighter" readme = "README.md" license = "MIT" diff --git a/tests/_mock_api.py b/tests/_mock_api.py new file mode 100644 index 0000000..f01033a --- /dev/null +++ b/tests/_mock_api.py @@ -0,0 +1,70 @@ +"""A mocked HTTP API for unit tests. + +Requests go through the real ``Client`` and ``HttpClient`` into an +``httpx.MockTransport``, so the tests see the exact path and query string the +SDK sends. +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional, Union + +import httpx + +from oxarchive import Client + +Responder = Callable[[str, dict[str, str]], Union[dict[str, Any], httpx.Response]] + + +class MockApi: + """Records every request and answers from a responder function.""" + + def __init__(self, responder: Responder) -> None: + self.responder = responder + self.requests: list[httpx.Request] = [] + + def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + answer = self.responder(request.url.path, dict(request.url.params)) + if isinstance(answer, httpx.Response): + return answer + return httpx.Response(200, json=answer) + + @property + def calls(self) -> list[tuple[str, dict[str, str]]]: + """``(path, query)`` of every request, in order.""" + return [(r.url.path, dict(r.url.params)) for r in self.requests] + + @property + def raw_paths(self) -> list[str]: + """Paths exactly as sent (percent-encoding preserved).""" + return [r.url.raw_path.decode().split("?", 1)[0] for r in self.requests] + + +def mock_client(responder: Responder) -> tuple[Client, MockApi]: + api = MockApi(responder) + client = Client(api_key="0xa_test", base_url="https://api.example.test") + transport = httpx.MockTransport(api.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, api + + +def envelope( + data: Any, + *, + next_cursor: Optional[str] = None, + **meta: Any, +) -> dict[str, Any]: + """An API response: ``{"success", "data", "meta"}`` like the server sends.""" + body: dict[str, Any] = {"count": len(data) if isinstance(data, list) else 1} + if next_cursor is not None: + body["next_cursor"] = next_cursor + body["request_id"] = "req-test" + body.update(meta) + return {"success": True, "data": data, "meta": body} diff --git a/tests/test_positions.py b/tests/test_positions.py new file mode 100644 index 0000000..cb1bf7c --- /dev/null +++ b/tests/test_positions.py @@ -0,0 +1,1002 @@ +"""Account positions: paths, query parameters, cursor following and models. + +Response bodies are shaped exactly like the API's JSON: optional fields the +server leaves out are left out here too. +""" + +from __future__ import annotations + +import asyncio +import copy +from datetime import datetime, timezone +from typing import Any + +import httpx +import pytest +from _mock_api import envelope, mock_client + +from oxarchive import ( + AccountSummary, + CursorResponse, + LighterL1Accounts, + MarketPosition, + MarketPositionsSummary, + OxArchiveError, + Position, + PositionChange, + ResponseMeta, + WalletPositions, +) + +ADDRESS = "0x1111111111111111111111111111111111111111" +LIGHTER_ACCOUNT = 281474976623827 +T_HOUR = 1790337600000 # 2026-09-25T12:00:00Z, an exact UTC hour +T_START = 1790251200000 # 2026-09-24T12:00:00Z +T_END = 1790337600000 + +# --------------------------------------------------------------------------- +# Fixtures shaped like the API's JSON +# --------------------------------------------------------------------------- + +HL_POSITION: dict[str, Any] = { + "symbol": "BTC", + "coin": "BTC", + "size": "-1.25", + "side": "short", + "entry_price": "64000.5", + "mark_price": "63500", + "mark_time": "2026-09-25T12:00:00Z", + "position_value": "79375", + "unrealized_pnl": "625.625", + "return_on_equity": "0.078203", + "leverage": {"type": "cross", "value": "10"}, + "max_leverage": 40, + "margin_used": "7937.5", + "liquidation_price": "70123.4", + "liquidation_price_status": "exact", + "cum_funding": {"all_time": "-12.5", "since_open": "-3.25", "since_change": "0"}, + "opened_at": "2026-09-20T08:15:30.123Z", + "snapshot_as_of": "2026-09-25T11:58:02.456Z", + "quality": "complete", +} + +HL_RECONSTRUCTED_POSITION: dict[str, Any] = { + "symbol": "ETH", + "coin": "ETH", + "size": "12.5", + "side": "long", + "entry_price": "2500.25", + "mark_price": None, + "mark_time": None, + "position_value": None, + "unrealized_pnl": None, + "return_on_equity": None, + "leverage": {"type": "unknown", "value": None}, + "max_leverage": None, + "margin_used": None, + "liquidation_price": None, + "liquidation_price_status": "unavailable", + "cum_funding": {"all_time": None, "since_open": None, "since_change": None}, + "opened_at": "2026-09-01T00:00:00Z", + "snapshot_as_of": None, + "quality": "partial", +} + +HL_ACCOUNT: dict[str, Any] = { + "account_value": "15000.123456", + "cross_account_value": "15000.123456", + "collateral": "14374.5", + "total_margin_used": "7937.5", + "cross_maintenance_margin_used": "1984.375", + "withdrawable": None, + "total_position_value": "79375", + "total_unrealized_pnl": "625.625", + "long_value": "0", + "short_value": "79375", + "n_positions": 1, + "account_mode": "standard", + "snapshot_as_of": "2026-09-25T11:58:02.456Z", + "quality": "complete", +} + +HL_CHANGE: dict[str, Any] = { + "timestamp": "2026-09-25T10:00:01.500Z", + "symbol": "BTC", + "coin": "BTC", + "side": "A", + "price": "64000", + "size": "0.5", + "start_position": "-0.75", + "end_position": "-1.25", + "entry_price_after": "64000.5", + "event_type": "increase", + "cause": "trade", + "direction": "Open Short", + "closed_pnl": "0", + "fee": "12.8", + "fee_token": "USDC", + "crossed": True, + "trade_id": 123456789, + "order_id": 987654321, + "opened_at": "2026-09-20T08:15:30.123Z", + "seq": 0, + "block_number": 712345678, + "event_index": 3, + "continuity": "ok", + "finalized": True, +} + +LIGHTER_POSITION: dict[str, Any] = { + "account_index": str(LIGHTER_ACCOUNT), + "account_kind": "user", + "symbol": "BTC", + "coin": "BTC", + "size": "109.79014", + "side": "long", + "entry_price": "84000.1", + "mark_price": "84363.5", + "mark_time": "2026-09-25T12:00:00Z", + "position_value": "9262181.698", + "unrealized_pnl": "39898.207", + "return_on_equity": None, + "leverage": {"type": "cross", "value": None}, + "max_leverage": None, + "margin_used": None, + "liquidation_price": None, + "liquidation_price_status": "unavailable", + "cum_funding": {"all_time": None, "since_open": None, "since_change": None}, + "opened_at": "2026-08-30T14:02:11Z", + "snapshot_as_of": None, + "quality": "complete", + "initial_margin_fraction": "0.05", + "allocated_margin": None, + "margin_mode": "cross", + "mark_source": "mark", + "finalized": True, +} + +# The Lighter account summary as the API sends it on the first page of a +# snapshot without a symbol filter: position aggregates only, no margin fields. +LIGHTER_ACCOUNT_SUMMARY: dict[str, Any] = { + "account_index": str(LIGHTER_ACCOUNT), + "total_position_value": "9262181.698", + "total_unrealized_pnl": "39898.207", + "long_value": "9262181.698", + "short_value": "0", + "n_positions": 1, + "quality": "complete", +} + +LIGHTER_CHANGE: dict[str, Any] = { + "timestamp": "2026-09-25T10:00:01.211Z", + "account_index": str(LIGHTER_ACCOUNT), + "account_kind": "user", + "symbol": "BTC", + "coin": "BTC", + "side": "B", + "price": "84367.9", + "size": "0.00003", + "start_position": "109.79011", + "end_position": "109.79014", + "entry_price_after": "84000.1", + "event_type": "increase", + "cause": "trade", + "realized_pnl": "0", + "fee": "0.000506", + "fee_token": "USDC", + "is_maker": False, + "trade_id": 31944180930, + "order_id": 562953419896990, + "opened_at": "2026-08-30T14:02:11Z", + "continuity": "ok", + "position_size_before": "109.79011", + "position_size_after": "109.79014", + "fee_rate": "0.0002", + "fee_usdc": "0.000506", + "usdc_amount": "2.531037", + "finalized": False, +} + +HL_MARKET_ROW: dict[str, Any] = { + "user_address": ADDRESS, + "symbol": "BTC", + "coin": "BTC", + "size": "-250", + "side": "short", + "entry_price": "63000", + "mark_price": "63500", + "position_value": "15875000", + "unrealized_pnl": "-125000", + "leverage_type": "cross", + "liquidation_price": "80000.1", + "quality": "complete", +} + +LIGHTER_MARKET_ROW: dict[str, Any] = { + "account_index": str(LIGHTER_ACCOUNT), + "account_kind": "user", + "symbol": "BTC", + "coin": "BTC", + "size": "109.79014", + "side": "long", + "entry_price": "84000.1", + "mark_price": "84363.5", + "position_value": "9262181.698", + "unrealized_pnl": "39898.207", + "leverage_type": "cross", + "liquidation_price": None, + "quality": "complete", +} + +SUMMARY: dict[str, Any] = { + "snapshot_ts": "2026-09-25T12:00:00Z", + "symbol": "BTC", + "coin": "BTC", + "long_count": 1200, + "short_count": 900, + "long_size": "1500.5", + "short_size": "1400.25", + "long_value": "95000000", + "short_value": "88000000", + "long_avg_entry_price": "63000.1", + "short_avg_entry_price": "64500.2", + "long_positions_with_entry": 1199, + "short_positions_with_entry": 900, + "long_top10_value_share": "0.412345", + "short_top10_value_share": "0.5", + "top10_value_share": "0.45", + "quality": "complete", +} + + +def _with(row: dict[str, Any], **changes: Any) -> dict[str, Any]: + out = copy.deepcopy(row) + out.update(changes) + return out + + +def _utc(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) + + +# --------------------------------------------------------------------------- +# Hyperliquid core: wallet routes +# --------------------------------------------------------------------------- + + +def test_hl_get_reads_the_live_snapshot_and_types_the_meta() -> None: + client, api = mock_client( + lambda path, q: envelope( + {"positions": [HL_POSITION], "account": HL_ACCOUNT}, + as_of="2026-09-25T12:00:00.000Z", + snapshot_ts="2026-09-25T12:00:00.000Z", + source="snapshot", + quality="complete", + stale=False, + ) + ) + + result = client.hyperliquid.positions.get(ADDRESS) + + assert api.calls == [(f"/v1/hyperliquid/wallets/{ADDRESS}/positions", {})] + assert isinstance(result, CursorResponse) + assert isinstance(result.data, WalletPositions) + position = result.data.positions[0] + assert isinstance(position, Position) + assert (position.symbol, position.side, position.size) == ("BTC", "short", "-1.25") + assert position.leverage.type == "cross" and position.leverage.value == "10" + assert position.cum_funding.all_time == "-12.5" + assert position.max_leverage == 40 + assert position.mark_time == _utc("2026-09-25T12:00:00Z") + assert position.dex is None and position.account_index is None + assert isinstance(result.data.account, AccountSummary) + assert result.data.account.withdrawable is None + assert result.data.account.n_positions == 1 + assert result.data.account_seen is None + assert isinstance(result.meta, ResponseMeta) + assert result.meta.as_of == _utc("2026-09-25T12:00:00Z") + assert result.meta.source == "snapshot" + assert result.meta.stale is False + assert result.next_cursor is None + + +def test_hl_get_as_of_sends_epoch_ms_and_exposes_the_clamp() -> None: + client, api = mock_client( + lambda path, q: envelope( + {"positions": [HL_RECONSTRUCTED_POSITION], "account": None}, + as_of="2026-09-25T11:00:00.000Z", + source="reconstructed", + quality="partial", + built_through="2026-09-25T11:00:00.000Z", + finalized_through="2026-09-24T23:59:59.832Z", + requested_end="2026-09-25T13:30:00.000Z", + clamped_to="2026-09-25T11:00:00.000Z", + ) + ) + + result = client.hyperliquid.positions.get( + ADDRESS, timestamp="2026-09-25T13:30:00Z", symbol="eth", limit=50 + ) + + assert api.calls == [ + ( + f"/v1/hyperliquid/wallets/{ADDRESS}/positions", + {"timestamp": "1790343000000", "symbol": "ETH", "limit": "50"}, + ) + ] + position = result.data.positions[0] + assert position.leverage.type == "unknown" and position.leverage.value is None + assert position.liquidation_price_status == "unavailable" + assert position.mark_price is None and position.quality == "partial" + assert result.data.account is None + meta = result.meta + assert meta is not None + assert meta.source == "reconstructed" + assert meta.built_through == _utc("2026-09-25T11:00:00Z") + assert meta.clamped_to == meta.built_through + assert meta.requested_end == _utc("2026-09-25T13:30:00Z") + assert meta.finalized_through == _utc("2026-09-24T23:59:59.832Z") + + +def test_hl_get_reports_never_seen_with_a_coverage_notice() -> None: + client, _ = mock_client( + lambda path, q: envelope( + {"positions": [], "account": None, "account_seen": "never_seen"}, + notice="No activity is recorded for this address since 2025-05-25 15:00 (UTC).", + coverage_from="2025-05-25T15:00:00.000Z", + source="snapshot", + ) + ) + + result = client.hyperliquid.positions.get(ADDRESS) + + assert result.data.positions == [] + assert result.data.account_seen == "never_seen" + assert result.meta is not None + assert result.meta.coverage_from == _utc("2025-05-25T15:00:00Z") + assert result.meta.notice is not None and "2025-05-25" in result.meta.notice + + +def test_core_positions_reject_dex_before_sending() -> None: + client, api = mock_client(lambda path, q: envelope([])) + + with pytest.raises(ValueError, match="dex applies to HIP-3"): + client.hyperliquid.positions.get(ADDRESS, dex="xyz") + with pytest.raises(ValueError, match="dex applies to HIP-3"): + client.hyperliquid.positions.account(ADDRESS, dex="xyz") + with pytest.raises(ValueError, match="dex applies to HIP-3"): + client.hyperliquid.positions.iterate_changes(ADDRESS, start=T_START, end=T_END, dex="xyz") + + assert api.requests == [] + + +def test_hl_history_sends_the_window_and_parses_hourly_rows() -> None: + row = _with(HL_POSITION, snapshot_ts="2026-09-25T11:00:00Z") + client, api = mock_client( + lambda path, q: envelope( + [row], + next_cursor="c-hist-2", + source="snapshot", + finalized_through="2026-09-24T23:59:59.832Z", + ) + ) + + result = client.hyperliquid.positions.history( + ADDRESS, + start=datetime(2026, 9, 24, 12, tzinfo=timezone.utc), + end=T_END, + symbol="btc", + cursor="c-hist-1", + limit=1000, + ) + + assert api.calls == [ + ( + f"/v1/hyperliquid/wallets/{ADDRESS}/positions/history", + { + "start": str(T_START), + "end": str(T_END), + "symbol": "BTC", + "cursor": "c-hist-1", + "limit": "1000", + }, + ) + ] + assert result.data[0].snapshot_ts == _utc("2026-09-25T11:00:00Z") + assert result.next_cursor == "c-hist-2" + assert result.meta is not None and result.meta.finalized_through is not None + + +def test_hl_iterate_changes_follows_cursors_with_unchanged_arguments() -> None: + pages = { + None: envelope( + [HL_CHANGE, _with(HL_CHANGE, trade_id=2)], next_cursor="c2", source="changes" + ), + "c2": envelope([_with(HL_CHANGE, trade_id=3)], next_cursor="c3", source="changes"), + "c3": envelope([], source="changes"), + } + client, api = mock_client(lambda path, q: pages[q.get("cursor")]) + + legs = list( + client.hyperliquid.positions.iterate_changes( + ADDRESS, start=T_START, end=T_END, symbol="BTC", limit=2 + ) + ) + + assert [leg.trade_id for leg in legs] == [123456789, 2, 3] + assert all(isinstance(leg, PositionChange) for leg in legs) + base = {"start": str(T_START), "end": str(T_END), "symbol": "BTC", "limit": "2"} + assert api.calls == [ + (f"/v1/hyperliquid/wallets/{ADDRESS}/positions/changes", base), + (f"/v1/hyperliquid/wallets/{ADDRESS}/positions/changes", {**base, "cursor": "c2"}), + (f"/v1/hyperliquid/wallets/{ADDRESS}/positions/changes", {**base, "cursor": "c3"}), + ] + first = legs[0] + assert first.side == "A" and first.direction == "Open Short" and first.closed_pnl == "0" + assert first.crossed is True and first.is_maker is None and first.realized_pnl is None + assert first.seq == 0 and first.block_number == 712345678 and first.event_index == 3 + assert first.continuity == "ok" and first.finalized is True + + +def test_hl_changes_page_exposes_built_and_finalized_boundaries() -> None: + client, _ = mock_client( + lambda path, q: envelope( + [HL_CHANGE], + source="changes", + built_through="2026-09-25T11:00:00.000Z", + finalized_through="2026-09-25T00:00:00.000Z", + ) + ) + + page = client.hyperliquid.positions.changes(ADDRESS, start=T_START, end=T_END) + + assert page.meta is not None + assert page.meta.built_through == _utc("2026-09-25T11:00:00Z") + assert page.meta.finalized_through == _utc("2026-09-25T00:00:00Z") + assert page.meta.clamped_to is None + + +def test_hl_account_and_account_history() -> None: + history_row = _with(HL_ACCOUNT, snapshot_ts="2026-09-25T11:00:00Z", withdrawable="1200.5") + pages = { + "/v1/hyperliquid/wallets/{a}/account".format(a=ADDRESS): envelope( + [HL_ACCOUNT], source="snapshot", stale=True, notice="stale" + ), + "/v1/hyperliquid/wallets/{a}/account/history".format(a=ADDRESS): envelope( + [history_row], source="snapshot" + ), + } + client, api = mock_client(lambda path, q: pages[path]) + + now = client.hyperliquid.positions.account(ADDRESS) + history = client.hyperliquid.positions.account_history( + ADDRESS, start=T_START, end=T_END, limit=24 + ) + + assert api.calls == [ + (f"/v1/hyperliquid/wallets/{ADDRESS}/account", {}), + ( + f"/v1/hyperliquid/wallets/{ADDRESS}/account/history", + {"start": str(T_START), "end": str(T_END), "limit": "24"}, + ), + ] + assert isinstance(now.data[0], AccountSummary) + assert now.meta is not None and now.meta.stale is True + assert history.data[0].snapshot_ts == _utc("2026-09-25T11:00:00Z") + assert history.data[0].withdrawable == "1200.5" + + +def test_hl_iterate_account_history_follows_cursors() -> None: + pages = { + None: envelope([HL_ACCOUNT], next_cursor="a2"), + "a2": envelope([_with(HL_ACCOUNT, n_positions=2)]), + } + client, api = mock_client(lambda path, q: pages[q.get("cursor")]) + + rows = list( + client.hyperliquid.positions.iterate_account_history(ADDRESS, start=T_START, end=T_END) + ) + + assert [r.n_positions for r in rows] == [1, 2] + assert [q.get("cursor") for _, q in api.calls] == [None, "a2"] + + +# --------------------------------------------------------------------------- +# HIP-3 +# --------------------------------------------------------------------------- + + +def test_hip3_wallet_routes_take_dex_and_keep_symbol_case() -> None: + hip3_position = _with(HL_POSITION, symbol="xyz:TSLA", coin="xyz:TSLA", dex="xyz") + hip3_account = _with(HL_ACCOUNT, dex="xyz") + client, api = mock_client( + lambda path, q: ( + envelope([hip3_account]) + if path.endswith("/account") + else envelope({"positions": [hip3_position], "account": hip3_account}) + ) + ) + + result = client.hyperliquid.hip3.positions.get(ADDRESS, symbol="xyz:TSLA", dex="xyz") + client.hyperliquid.hip3.positions.account(ADDRESS, dex="xyz") + + assert api.calls[0] == ( + f"/v1/hyperliquid/hip3/wallets/{ADDRESS}/positions", + {"symbol": "xyz:TSLA", "dex": "xyz"}, + ) + assert api.calls[1] == (f"/v1/hyperliquid/hip3/wallets/{ADDRESS}/account", {"dex": "xyz"}) + assert result.data.positions[0].dex == "xyz" + assert result.data.account is not None and result.data.account.dex == "xyz" + + +def test_hip3_market_and_summary_paths_keep_the_dex_prefix() -> None: + hip3_summary = _with(SUMMARY, symbol="xyz:TSLA", coin="xyz:TSLA", dex="xyz") + hip3_row = _with(HL_MARKET_ROW, symbol="xyz:TSLA", coin="xyz:TSLA", dex="xyz") + client, api = mock_client( + lambda path, q: ( + envelope([hip3_summary]) + if path.endswith("/summary") + else envelope([hip3_row], totals=hip3_summary) + ) + ) + + summary = client.hyperliquid.hip3.positions.market_summary("xyz:TSLA") + client.hyperliquid.hip3.positions.market("xyz:TSLA") + + assert api.raw_paths == [ + "/v1/hyperliquid/hip3/positions/xyz%3ATSLA/summary", + "/v1/hyperliquid/hip3/positions/xyz%3ATSLA", + ] + assert api.calls[0][0] == "/v1/hyperliquid/hip3/positions/xyz:TSLA/summary" + assert summary.data[0].dex == "xyz" + + +# --------------------------------------------------------------------------- +# Market routes +# --------------------------------------------------------------------------- + + +def test_hl_market_sends_filters_and_types_the_totals() -> None: + client, api = mock_client( + lambda path, q: envelope( + [HL_MARKET_ROW], + next_cursor="m2", + as_of="2026-09-25T12:00:00.000Z", + snapshot_ts="2026-09-25T12:00:00.000Z", + source="snapshot", + quality="complete", + totals=SUMMARY, + ) + ) + + page = client.hyperliquid.positions.market( + "btc", hour="2026-09-25T12:00:00Z", side="short", min_value=1_000_000, limit=100 + ) + + assert api.calls == [ + ( + "/v1/hyperliquid/positions/BTC", + {"hour": str(T_HOUR), "side": "short", "min_value": "1000000", "limit": "100"}, + ) + ] + row = page.data[0] + assert isinstance(row, MarketPosition) + assert row.user_address == ADDRESS and row.leverage_type == "cross" + assert page.meta is not None + assert isinstance(page.meta.totals, MarketPositionsSummary) + assert page.meta.totals.long_count == 1200 + assert page.meta.snapshot_ts == _utc("2026-09-25T12:00:00Z") + assert page.next_cursor == "m2" + + +def test_market_hour_must_be_an_exact_utc_hour() -> None: + client, api = mock_client(lambda path, q: envelope([])) + + with pytest.raises(ValueError, match="exact UTC hour"): + client.hyperliquid.positions.market("BTC", hour=T_HOUR + 1) + with pytest.raises(ValueError, match="exact UTC hour"): + client.hyperliquid.positions.all("2026-09-25T12:30:00Z") + with pytest.raises(ValueError, match="exact UTC hour"): + client.lighter.positions.iterate_all(T_HOUR - 60_000) + + assert api.requests == [] + + +def test_hl_iterate_market_follows_cursors_on_one_snapshot() -> None: + pages = { + None: envelope([HL_MARKET_ROW], next_cursor="m2", totals=SUMMARY), + "m2": envelope([_with(HL_MARKET_ROW, size="-10")]), + } + client, api = mock_client(lambda path, q: pages[q.get("cursor")]) + + rows = list(client.hyperliquid.positions.iterate_market("BTC", side="short")) + + assert [r.size for r in rows] == ["-250", "-10"] + assert api.calls == [ + ("/v1/hyperliquid/positions/BTC", {"side": "short"}), + ("/v1/hyperliquid/positions/BTC", {"side": "short", "cursor": "m2"}), + ] + + +def test_market_summary_now_and_hourly_series() -> None: + series = { + None: envelope([SUMMARY], next_cursor="s2", finalized_through="2026-09-25T00:00:00.000Z"), + "s2": envelope([_with(SUMMARY, snapshot_ts="2026-09-25T13:00:00Z")]), + } + + def respond(path: str, q: dict[str, str]) -> dict[str, Any]: + if "start" not in q: + return envelope([SUMMARY], source="snapshot", stale=False) + return series[q.get("cursor")] + + client, api = mock_client(respond) + + now = client.hyperliquid.positions.market_summary("BTC") + hours = list( + client.hyperliquid.positions.iterate_market_summary("BTC", start=T_START, end=T_END) + ) + + assert api.calls[0] == ("/v1/hyperliquid/positions/BTC/summary", {}) + assert api.calls[1:] == [ + ("/v1/hyperliquid/positions/BTC/summary", {"start": str(T_START), "end": str(T_END)}), + ( + "/v1/hyperliquid/positions/BTC/summary", + {"start": str(T_START), "end": str(T_END), "cursor": "s2"}, + ), + ] + assert isinstance(now.data[0], MarketPositionsSummary) + assert now.data[0].top10_value_share == "0.45" + assert [h.snapshot_ts for h in hours] == [ + _utc("2026-09-25T12:00:00Z"), + _utc("2026-09-25T13:00:00Z"), + ] + + +def test_bulk_all_requires_an_hour_and_iterates_every_page() -> None: + bulk_row = _with(HL_MARKET_ROW, snapshot_ts="2026-09-25T12:00:00Z") + pages = { + None: envelope([bulk_row, bulk_row], next_cursor="b2", source="snapshot"), + "b2": envelope([bulk_row]), + } + client, api = mock_client(lambda path, q: pages[q.get("cursor")]) + + first = client.hyperliquid.positions.all(T_HOUR, limit=2) + everything = list(client.hyperliquid.positions.iterate_all(T_HOUR, limit=2)) + + assert api.calls[0] == ("/v1/hyperliquid/positions", {"hour": str(T_HOUR), "limit": "2"}) + assert first.next_cursor == "b2" + assert first.data[0].snapshot_ts == _utc("2026-09-25T12:00:00Z") + assert len(everything) == 3 + assert api.calls[-1] == ( + "/v1/hyperliquid/positions", + {"hour": str(T_HOUR), "limit": "2", "cursor": "b2"}, + ) + + +def test_a_replaced_snapshot_surfaces_as_a_409() -> None: + client, _ = mock_client( + lambda path, q: httpx.Response( + 409, + json={ + "success": False, + "error": "The snapshot this cursor was paging has been replaced or has expired.", + "code": "snapshot_advanced", + }, + ) + ) + + with pytest.raises(OxArchiveError) as raised: + client.hyperliquid.positions.market("BTC", cursor="stale") + + assert raised.value.code == 409 + assert "replaced" in raised.value.message + + +# --------------------------------------------------------------------------- +# Lighter and Lighter on Robinhood Chain +# --------------------------------------------------------------------------- + + +def test_lighter_get_uses_the_integer_account_index() -> None: + def respond(path: str, q: dict[str, str]) -> dict[str, Any]: + # The summary rides only on an unfiltered first page, as in the API. + account = None if "symbol" in q else LIGHTER_ACCOUNT_SUMMARY + return envelope({"positions": [LIGHTER_POSITION], "account": account}) + + client, api = mock_client(respond) + + result = client.lighter.positions.get(LIGHTER_ACCOUNT, symbol="btc") + hourly = client.lighter.positions.get(str(LIGHTER_ACCOUNT), timestamp=T_HOUR) + + assert api.calls == [ + (f"/v1/lighter/accounts/{LIGHTER_ACCOUNT}/positions", {"symbol": "BTC"}), + (f"/v1/lighter/accounts/{LIGHTER_ACCOUNT}/positions", {"timestamp": str(T_HOUR)}), + ] + position = result.data.positions[0] + assert position.account_index == str(LIGHTER_ACCOUNT) + assert position.account_kind == "user" + assert position.initial_margin_fraction == "0.05" + assert position.allocated_margin is None + assert position.margin_mode == "cross" and position.mark_source == "mark" + assert position.finalized is True + + assert result.data.account is None + summary = hourly.data.account + assert isinstance(summary, AccountSummary) + assert summary.account_index == str(LIGHTER_ACCOUNT) + assert summary.total_position_value == "9262181.698" + assert summary.total_unrealized_pnl == "39898.207" + assert (summary.long_value, summary.short_value) == ("9262181.698", "0") + assert summary.n_positions == 1 and summary.quality == "complete" + assert summary.account_value is None and summary.total_margin_used is None + assert summary.dex is None and summary.account_mode is None + + +@pytest.mark.parametrize("bad", ["0x1111111111111111111111111111111111111111", True, -1, "12a"]) +def test_lighter_positions_reject_non_integer_account_indices(bad: Any) -> None: + client, api = mock_client(lambda path, q: envelope([])) + + with pytest.raises(ValueError, match="account_index") as mainnet: + client.lighter.positions.get(bad) + with pytest.raises(ValueError, match="account_index") as rh: + client.rh_lighter.positions.history(bad, start=T_START, end=T_END) + + if isinstance(bad, str) and not bad.lstrip("-").isdigit(): + assert "accounts.by_l1" in str(mainnet.value) + assert "by_l1" not in str(rh.value) + assert api.requests == [] + + +def test_lighter_history_and_changes_parse_lighter_fields() -> None: + pages = { + f"/v1/lighter/accounts/{LIGHTER_ACCOUNT}/positions/history": envelope( + [_with(LIGHTER_POSITION, snapshot_ts="2026-09-25T11:00:00Z")] + ), + f"/v1/lighter/accounts/{LIGHTER_ACCOUNT}/positions/changes": envelope( + [LIGHTER_CHANGE], + source="changes", + built_through="2026-09-25T11:58:00.000Z", + finalized_through="2026-09-24T21:00:00.000Z", + ), + } + client, api = mock_client(lambda path, q: pages[path]) + + history = client.lighter.positions.history(LIGHTER_ACCOUNT, start=T_START, end=T_END) + changes = client.lighter.positions.changes( + LIGHTER_ACCOUNT, start=T_START, end=T_END, symbol="btc", limit=10 + ) + + assert api.calls[1] == ( + f"/v1/lighter/accounts/{LIGHTER_ACCOUNT}/positions/changes", + {"start": str(T_START), "end": str(T_END), "symbol": "BTC", "limit": "10"}, + ) + assert history.data[0].snapshot_ts == _utc("2026-09-25T11:00:00Z") + leg = changes.data[0] + assert leg.side == "B" and leg.is_maker is False and leg.crossed is None + assert leg.realized_pnl == "0" and leg.closed_pnl is None and leg.direction is None + assert leg.position_size_before == "109.79011" and leg.position_size_after == "109.79014" + assert leg.fee_rate == "0.0002" and leg.fee_usdc == "0.000506" + assert leg.usdc_amount == "2.531037" and leg.finalized is False + assert leg.seq is None and leg.block_number is None + assert changes.meta is not None + assert changes.meta.finalized_through == _utc("2026-09-24T21:00:00Z") + + +def test_lighter_market_routes_send_include_system() -> None: + client, api = mock_client( + lambda path, q: ( + envelope([SUMMARY]) + if path.endswith("/summary") + else envelope( + [LIGHTER_MARKET_ROW, _with(LIGHTER_MARKET_ROW, account_kind="insurance")], + totals=SUMMARY, + ) + ) + ) + + page = client.lighter.positions.market( + "btc", side="long", min_value=10_000.5, include_system=True, limit=2 + ) + client.lighter.positions.market_summary("BTC", include_system=True) + client.lighter.positions.all(T_HOUR, include_system=True) + + assert api.calls == [ + ( + "/v1/lighter/positions/BTC", + {"side": "long", "min_value": "10000.5", "include_system": "true", "limit": "2"}, + ), + ("/v1/lighter/positions/BTC/summary", {"include_system": "true"}), + ("/v1/lighter/positions", {"hour": str(T_HOUR), "include_system": "true"}), + ] + assert [row.account_kind for row in page.data] == ["user", "insurance"] + assert page.data[0].account_index == str(LIGHTER_ACCOUNT) + assert page.data[0].user_address is None + + +def test_lighter_accounts_by_l1_and_its_iterator() -> None: + first = { + "l1_address": ADDRESS, + "total_accounts": 3, + "accounts": [ + {"account_index": "12", "account_type": 0, "first_seen": "2025-01-17T00:00:00Z"}, + {"account_index": "13", "account_type": 1, "first_seen": None}, + ], + } + second = { + "l1_address": ADDRESS, + "total_accounts": 3, + "accounts": [{"account_index": "99", "account_type": 0, "first_seen": None}], + } + pages = {None: envelope(first, next_cursor="l2"), "l2": envelope(second)} + client, api = mock_client(lambda path, q: pages[q.get("cursor")]) + + page = client.lighter.accounts.by_l1(ADDRESS, limit=2) + indices = [a.account_index for a in client.lighter.accounts.iterate_by_l1(ADDRESS, limit=2)] + + assert api.calls[0] == ("/v1/lighter/accounts", {"l1_address": ADDRESS, "limit": "2"}) + assert api.calls[-1] == ( + "/v1/lighter/accounts", + {"l1_address": ADDRESS, "limit": "2", "cursor": "l2"}, + ) + assert isinstance(page.data, LighterL1Accounts) + assert page.data.total_accounts == 3 + assert page.data.accounts[0].first_seen == _utc("2025-01-17T00:00:00Z") + assert indices == ["12", "13", "99"] + + +def test_rh_lighter_positions_use_the_robinhood_chain_root() -> None: + rh_position = _with(LIGHTER_POSITION, account_index="7") + rh_change = _with(LIGHTER_CHANGE, account_index="7", fee_token="USDG") + rh_account = _with(LIGHTER_ACCOUNT_SUMMARY, account_index="7") + + def respond(path: str, q: dict[str, str]) -> dict[str, Any]: + if path.endswith("/changes"): + return envelope([rh_change]) + if path.endswith("/history"): + return envelope([rh_position]) + if path.endswith("/summary"): + return envelope([SUMMARY]) + if path.startswith("/v1/rh-lighter/accounts/"): + return envelope({"positions": [rh_position], "account": rh_account}) + return envelope([LIGHTER_MARKET_ROW]) + + client, api = mock_client(respond) + rh = client.rh_lighter.positions + + now = rh.get(7) + rh.history(7, start=T_START, end=T_END) + changes = rh.changes(7, start=T_START, end=T_END) + rh.market("btc") + rh.market_summary("BTC", start=T_START, end=T_END) + rh.all(T_HOUR) + + assert [path for path, _ in api.calls] == [ + "/v1/rh-lighter/accounts/7/positions", + "/v1/rh-lighter/accounts/7/positions/history", + "/v1/rh-lighter/accounts/7/positions/changes", + "/v1/rh-lighter/positions/BTC", + "/v1/rh-lighter/positions/BTC/summary", + "/v1/rh-lighter/positions", + ] + assert changes.data[0].fee_token == "USDG" + assert isinstance(now.data.account, AccountSummary) + assert now.data.account.account_index == "7" + assert now.data.account.account_value is None + assert not hasattr(client.rh_lighter, "accounts") + assert not hasattr(rh, "account") and not hasattr(client.lighter.positions, "account") + + +# --------------------------------------------------------------------------- +# Async +# --------------------------------------------------------------------------- + + +def test_async_positions_methods_match_the_sync_requests() -> None: + def respond(path: str, q: dict[str, str]) -> dict[str, Any]: + if path.endswith("/wallets/" + ADDRESS + "/positions"): + return envelope({"positions": [HL_POSITION], "account": HL_ACCOUNT}) + if path.endswith("/changes"): + pages = {None: envelope([HL_CHANGE], next_cursor="c2"), "c2": envelope([HL_CHANGE])} + return pages[q.get("cursor")] + if path.endswith("/history") and "/account/" in path: + return envelope([HL_ACCOUNT]) + if path.endswith("/history"): + return envelope([HL_POSITION]) + if path.endswith("/summary"): + return envelope([SUMMARY]) + if path == "/v1/lighter/accounts": + return envelope({"l1_address": ADDRESS, "total_accounts": 0, "accounts": []}) + if path.startswith("/v1/lighter/accounts/"): + return envelope({"positions": [LIGHTER_POSITION], "account": LIGHTER_ACCOUNT_SUMMARY}) + if path.endswith("/account"): + return envelope([HL_ACCOUNT]) + return envelope([HL_MARKET_ROW]) + + client, api = mock_client(respond) + hl = client.hyperliquid.positions + + async def run() -> dict[str, Any]: + out: dict[str, Any] = {} + out["get"] = await hl.aget(ADDRESS) + out["history"] = await hl.ahistory(ADDRESS, start=T_START, end=T_END) + out["changes"] = [ + leg async for leg in hl.aiterate_changes(ADDRESS, start=T_START, end=T_END) + ] + out["account"] = await hl.aaccount(ADDRESS) + out["account_history"] = await hl.aaccount_history(ADDRESS, start=T_START, end=T_END) + out["market"] = await hl.amarket("BTC", hour=T_HOUR) + out["summary"] = await hl.amarket_summary("BTC") + out["all"] = await hl.aall(T_HOUR) + out["lighter"] = await client.lighter.positions.aget(LIGHTER_ACCOUNT) + out["l1"] = await client.lighter.accounts.aby_l1(ADDRESS) + await client.aclose() + return out + + out = asyncio.run(run()) + + assert isinstance(out["get"].data, WalletPositions) + assert len(out["changes"]) == 2 + assert isinstance(out["account"].data[0], AccountSummary) + assert isinstance(out["summary"].data[0], MarketPositionsSummary) + assert isinstance(out["lighter"].data.positions[0], Position) + assert isinstance(out["lighter"].data.account, AccountSummary) + assert out["lighter"].data.account.n_positions == 1 + assert out["l1"].data.total_accounts == 0 + assert [path for path, _ in api.calls] == [ + f"/v1/hyperliquid/wallets/{ADDRESS}/positions", + f"/v1/hyperliquid/wallets/{ADDRESS}/positions/history", + f"/v1/hyperliquid/wallets/{ADDRESS}/positions/changes", + f"/v1/hyperliquid/wallets/{ADDRESS}/positions/changes", + f"/v1/hyperliquid/wallets/{ADDRESS}/account", + f"/v1/hyperliquid/wallets/{ADDRESS}/account/history", + "/v1/hyperliquid/positions/BTC", + "/v1/hyperliquid/positions/BTC/summary", + "/v1/hyperliquid/positions", + f"/v1/lighter/accounts/{LIGHTER_ACCOUNT}/positions", + "/v1/lighter/accounts", + ] + + +def test_positions_resources_expose_the_same_method_names_on_every_venue() -> None: + client, _ = mock_client(lambda path, q: envelope([])) + shared = { + "get", + "history", + "changes", + "market", + "market_summary", + "all", + "iterate_history", + "iterate_changes", + "iterate_market", + "iterate_market_summary", + "iterate_all", + } + shared |= {"a" + name for name in shared} + hl_only = { + "account", + "account_history", + "aaccount", + "aaccount_history", + "iterate_account_history", + "aiterate_account_history", + } + + for resource in (client.hyperliquid.positions, client.hyperliquid.hip3.positions): + assert shared | hl_only <= set(dir(resource)) + for resource in (client.lighter.positions, client.rh_lighter.positions): + assert shared <= set(dir(resource)) + assert not hl_only & set(dir(resource)) + assert {"by_l1", "aby_l1", "iterate_by_l1", "aiterate_by_l1"} <= set( + dir(client.lighter.accounts) + ) + + +def test_response_meta_keeps_unknown_fields_and_empty_instants() -> None: + meta = ResponseMeta.model_validate( + {"count": 0, "request_id": "r", "as_of": "", "totals": {"future": 1}, "new_field": "x"} + ) + + assert meta.as_of is None + assert meta.totals == {"future": 1} + assert meta.model_extra == {"new_field": "x"} diff --git a/tests/test_rh_lighter.py b/tests/test_rh_lighter.py new file mode 100644 index 0000000..8ecdb67 --- /dev/null +++ b/tests/test_rh_lighter.py @@ -0,0 +1,661 @@ +"""Lighter on Robinhood Chain (REST and WebSocket) and Lighter liquidations.""" + +from __future__ import annotations + +import asyncio +import json +import re +from pathlib import Path +from typing import Any, Callable, cast + +import pytest +from _mock_api import envelope, mock_client +from test_websocket_capabilities import ( + LIGHTER_BOOK_FRAME, + LIGHTER_CTX_FRAME, + LIGHTER_TRADES_FRAME, + FakeSocket, +) + +from oxarchive import ( + LighterClient, + LighterLiquidation, + LighterLiquidationVolume, + ResponseMeta, + RhLighterClient, +) +from oxarchive.types import OrderBook, Trade, WsChannel, WsData, WsSubscribed +from oxarchive.websocket import ( + LIGHTER_LIVE_CHANNELS, + RH_LIGHTER_LIVE_CHANNELS, + RH_LIGHTER_REPLAY_CHANNELS, + RH_LIGHTER_REPLAY_ONLY_CHANNELS, + RH_LIGHTER_SUBSCRIPTION_ERROR, + OxArchiveWs, + WsOptions, +) + +T_START = 1790251200000 +T_END = 1790337600000 + +# A liquidation row shaped like the API's JSON: Robinhood Chain's first +# liquidation, from before live capture, so backfilled from the venue's +# finalized export (source "bucket", empty raw_json). +RH_LIQUIDATION: dict[str, Any] = { + "symbol": "BTC", + "timestamp": 1782602083534, + "transaction_time_us": 1782602083534123, + "trade_id": 5501, + "liquidation_type": "partial", + "price": 107250.5, + "size": 0.0125, + "usd_amount": 1340.63125, + "ask_account": "281474976710654", + "bid_account": "4521", + "ask_order_id": 7001, + "bid_order_id": 7002, + "is_maker_ask": False, + "taker_position_size_before": -0.0125, + "maker_position_size_before": 3.5, + "taker_entry_quote_before": 1331.25, + "maker_entry_quote_before": 371000.0, + "taker_initial_margin_fraction_before": 500, + "maker_initial_margin_fraction_before": 200, + "taker_allocated_margin_usdc_before": 0, + "taker_allocated_margin_usdc_after": 0, + "maker_allocated_margin_usdc_before": 0, + "maker_allocated_margin_usdc_after": 0, + "taker_fee": 0, + "maker_fee": 0, + "taker_position_sign_changed": True, + "maker_position_sign_changed": False, + "block_height": 912345, + "tx_hash": "0x0f", + "raw_json": "", + "source": "bucket", +} + +# The same shape captured live: source "ws" with the venue's raw JSON. +RH_LIQUIDATION_LIVE: dict[str, Any] = { + **RH_LIQUIDATION, + "timestamp": 1788240575923, + "transaction_time_us": 1788240575923456, + "trade_id": 324449287, + "raw_json": '{"trade_id":324449287,"market_id":1,"price":"78963.9"}', + "source": "ws", +} + +LIQUIDATION_VOLUME: dict[str, Any] = { + "symbol": "BTC", + "timestamp": 1788220800000, + "total_usd": 1340.63125, + "count": 1, +} + + +def _public(obj: object) -> set[str]: + return {name for name in vars(obj) if not name.startswith("_")} + + +# --------------------------------------------------------------------------- +# Client shape +# --------------------------------------------------------------------------- + + +def test_rh_lighter_has_the_lighter_resources_minus_l3_and_the_l1_resolver() -> None: + client, _ = mock_client(lambda path, q: envelope([])) + + assert isinstance(client.rh_lighter, RhLighterClient) + assert isinstance(client.lighter, LighterClient) + assert not isinstance(client.rh_lighter, LighterClient) + assert _public(client.lighter) - _public(client.rh_lighter) == {"l3_orderbook", "accounts"} + assert _public(client.rh_lighter) <= _public(client.lighter) + assert {"liquidations", "positions"} <= _public(client.lighter) + for method in ("get_freshness", "get_summary", "get_price_history"): + assert callable(getattr(client.rh_lighter, method)) + + +# --------------------------------------------------------------------------- +# REST routes under /v1/rh-lighter +# --------------------------------------------------------------------------- + +INSTRUMENT: dict[str, Any] = { + "symbol": "AAPL-USDG", + "market_id": 2049, + "market_type": "spot", + "status": "active", + "taker_fee": 0.0, + "maker_fee": 0.0, + "liquidation_fee": 0.0, + "min_base_amount": 0.01, + "min_quote_amount": 1.0, + "size_decimals": 2, + "price_decimals": 2, + "quote_decimals": 4, + "is_active": True, +} +BOOK: dict[str, Any] = { + "coin": "AAPL-USDG", + "timestamp": "2026-09-25T12:00:00Z", + "bids": [{"px": "254.1", "sz": "10", "n": 1}], + "asks": [{"px": "254.2", "sz": "5", "n": 1}], +} +TRADE: dict[str, Any] = { + "coin": "BTC", + "side": "B", + "price": "107250.5", + "size": "0.01", + "timestamp": "2026-09-25T11:59:59Z", + "trade_id": 5502, + "account_index": "4521", +} +FUNDING: dict[str, Any] = { + "coin": "BTC", + "timestamp": "2026-09-25T12:00:00Z", + "funding_rate": "0.00001", +} +OI: dict[str, Any] = {"coin": "BTC", "timestamp": "2026-09-25T12:00:00Z", "open_interest": "12.5"} +CANDLE: dict[str, Any] = { + "timestamp": "2026-09-25T12:00:00Z", + "open": 1.0, + "high": 2.0, + "low": 0.5, + "close": 1.5, + "volume": 3.0, +} +FRESHNESS: dict[str, Any] = { + "coin": "BTC", + "exchange": "rh-lighter", + "measured_at": "2026-09-25T12:00:00Z", + "orderbook": {"last_updated": "2026-09-25T11:59:59Z", "lag_ms": 1000}, + "trades": {"last_updated": "2026-09-25T11:59:59Z", "lag_ms": 1000}, + "funding": {"last_updated": None, "lag_ms": None}, + "open_interest": {"last_updated": None, "lag_ms": None}, +} +SUMMARY: dict[str, Any] = { + "coin": "BTC", + "timestamp": "2026-09-25T12:00:00Z", + "mark_price": "107250", +} +PRICE: dict[str, Any] = {"timestamp": "2026-09-25T12:00:00Z", "mark_price": "107250"} + + +def _rh_responder(path: str, q: dict[str, str]) -> dict[str, Any]: + rest = path.removeprefix("/v1/rh-lighter/") + head = rest.split("/", 1)[0] + if head == "instruments": + return envelope([INSTRUMENT] if rest == "instruments" else INSTRUMENT) + if head == "orderbook": + return envelope([BOOK] if rest.endswith("/history") else BOOK) + if head == "trades": + if rest.endswith("/recent"): + return envelope( + [TRADE], + finalized_through="2026-09-24T21:00:00.000Z", + preliminary_row_count=1, + ) + return envelope( + [TRADE], + next_cursor="1790337599000_5502", + finalized_through="2026-09-24T21:00:00.000Z", + requested_end="2026-09-25T12:00:00.000Z", + clamped_to="2026-09-24T21:00:00.000Z", + ) + if head == "funding": + return envelope(FUNDING if rest.endswith("/current") else [FUNDING]) + if head == "openinterest": + return envelope(OI if rest.endswith("/current") else [OI]) + if head == "candles": + return envelope([CANDLE]) + if head == "liquidations": + return envelope([LIQUIDATION_VOLUME] if rest.endswith("/volume") else [RH_LIQUIDATION]) + if head == "freshness": + return envelope(FRESHNESS) + if head == "summary": + return envelope(SUMMARY) + if head == "prices": + return envelope([PRICE]) + raise AssertionError(f"unexpected path {path}") + + +def test_every_rh_lighter_route_is_served_under_its_own_root() -> None: + client, api = mock_client(_rh_responder) + rh = client.rh_lighter + window = {"start": T_START, "end": T_END} + + rh.instruments.list() + rh.instruments.get("aapl-usdg") + rh.orderbook.get("aapl-usdg") + rh.orderbook.history("AAPL-USDG", **window) + rh.trades.list("btc", **window) + rh.trades.recent("BTC") + rh.candles.history("BTC", **window) + rh.open_interest.history("BTC", **window) + rh.open_interest.current("BTC") + rh.funding.history("BTC", **window) + rh.funding.current("BTC") + rh.liquidations.history("BTC", **window) + rh.liquidations.volume("BTC", **window) + rh.get_freshness("BTC") + rh.get_summary("BTC") + rh.get_price_history("BTC", **window) + + assert [path for path, _ in api.calls] == [ + "/v1/rh-lighter/instruments", + "/v1/rh-lighter/instruments/AAPL-USDG", + "/v1/rh-lighter/orderbook/AAPL-USDG", + "/v1/rh-lighter/orderbook/AAPL-USDG/history", + "/v1/rh-lighter/trades/BTC", + "/v1/rh-lighter/trades/BTC/recent", + "/v1/rh-lighter/candles/BTC", + "/v1/rh-lighter/openinterest/BTC", + "/v1/rh-lighter/openinterest/BTC/current", + "/v1/rh-lighter/funding/BTC", + "/v1/rh-lighter/funding/BTC/current", + "/v1/rh-lighter/liquidations/BTC", + "/v1/rh-lighter/liquidations/BTC/volume", + "/v1/rh-lighter/freshness/BTC", + "/v1/rh-lighter/summary/BTC", + "/v1/rh-lighter/prices/BTC", + ] + assert len({path for path, _ in api.calls}) == 16 + + +def test_lighter_mainnet_routes_are_unchanged_by_the_shared_base() -> None: + client, api = mock_client( + lambda path, q: envelope( + FRESHNESS if "/freshness/" in path else SUMMARY if "/summary/" in path else [PRICE] + ) + ) + + client.lighter.get_freshness("btc") + client.lighter.get_summary("BTC") + client.lighter.get_price_history("BTC", start=T_START, end=T_END, interval="1h") + + assert api.calls == [ + ("/v1/lighter/freshness/BTC", {}), + ("/v1/lighter/summary/BTC", {}), + ("/v1/lighter/prices/BTC", {"start": str(T_START), "end": str(T_END), "interval": "1h"}), + ] + + +def test_rh_trades_expose_the_finalization_meta() -> None: + client, api = mock_client(_rh_responder) + + page = client.rh_lighter.trades.list("BTC", start=T_START, end=T_END) + recent = client.rh_lighter.trades.recent("BTC", limit=5) + + assert api.calls[0] == ("/v1/rh-lighter/trades/BTC", {"start": str(T_START), "end": str(T_END)}) + assert api.calls[1] == ("/v1/rh-lighter/trades/BTC/recent", {"limit": "5"}) + assert isinstance(page.meta, ResponseMeta) + assert page.meta.finalized_through is not None + assert page.meta.clamped_to == page.meta.finalized_through + assert page.meta.requested_end is not None + assert page.next_cursor == "1790337599000_5502" + assert page.data[0].account_index == "4521" + assert isinstance(recent, list) and isinstance(recent[0], Trade) + + +def test_spot_symbols_are_uppercased_and_path_encoded() -> None: + client, api = mock_client(_rh_responder) + + client.rh_lighter.orderbook.get("aapl-usdg") + + assert api.raw_paths == ["/v1/rh-lighter/orderbook/AAPL-USDG"] + + +# --------------------------------------------------------------------------- +# Lighter liquidations (both deployments) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "venue, root", [("lighter", "/v1/lighter"), ("rh_lighter", "/v1/rh-lighter")] +) +def test_lighter_liquidations_history_and_volume(venue: str, root: str) -> None: + def respond(path: str, q: dict[str, str]) -> dict[str, Any]: + if path.endswith("/volume"): + return envelope([LIQUIDATION_VOLUME], next_cursor="1788220800000") + return envelope( + [RH_LIQUIDATION, RH_LIQUIDATION_LIVE], next_cursor="1788240575923_324449287" + ) + + client, api = mock_client(respond) + liquidations = getattr(client, venue).liquidations + + history = liquidations.history( + "btc", start="2026-06-26T20:10:26Z", end=T_END, cursor="1782602083534_5501", limit=500 + ) + volume = liquidations.volume("BTC", start=T_START, end=T_END, interval="4h", limit=10) + + assert api.calls == [ + ( + f"{root}/liquidations/BTC", + { + "start": "1782504626000", + "end": str(T_END), + "cursor": "1782602083534_5501", + "limit": "500", + }, + ), + ( + f"{root}/liquidations/BTC/volume", + {"start": str(T_START), "end": str(T_END), "interval": "4h", "limit": "10"}, + ), + ] + row = history.data[0] + assert isinstance(row, LighterLiquidation) + assert row.timestamp == 1782602083534 and row.trade_id == 5501 + assert row.source == "bucket" and row.raw_json == "" + live = history.data[1] + assert live.source == "ws" and live.raw_json and live.raw_json.startswith("{") + assert row.ask_account == "281474976710654" and row.is_maker_ask is False + assert row.taker_position_sign_changed is True + assert history.next_cursor == "1788240575923_324449287" + assert isinstance(history.meta, ResponseMeta) + bucket = volume.data[0] + assert isinstance(bucket, LighterLiquidationVolume) + assert bucket.total_usd == pytest.approx(1340.63125) and bucket.count == 1 + assert volume.next_cursor == "1788220800000" + + +def test_lighter_liquidations_async() -> None: + client, api = mock_client( + lambda path, q: envelope( + [LIQUIDATION_VOLUME] if path.endswith("/volume") else [RH_LIQUIDATION] + ) + ) + + async def run() -> tuple[Any, Any]: + history = await client.rh_lighter.liquidations.ahistory("BTC", start=T_START, end=T_END) + volume = await client.lighter.liquidations.avolume("BTC") + await client.aclose() + return history, volume + + history, volume = asyncio.run(run()) + + assert [path for path, _ in api.calls] == [ + "/v1/rh-lighter/liquidations/BTC", + "/v1/lighter/liquidations/BTC/volume", + ] + assert history.data[0].source == "bucket" + assert volume.data[0].count == 1 + + +def test_rh_liquidations_copy_uses_the_venue_launch_floor() -> None: + # Robinhood Chain liquidations share the trades floor (venue launch); only + # order book, open interest and funding start at live capture. + root = Path(__file__).resolve().parents[1] + copy = { + name: (root / name).read_text() + for name in ( + "README.md", + "CHANGELOG.md", + "oxarchive/__init__.py", + "oxarchive/client.py", + "oxarchive/exchanges.py", + "oxarchive/resources/lighter_liquidations.py", + ) + } + readme = " ".join(copy["README.md"].split()) + assert "Trades and liquidations from 2026-06-26 20:10:26 UTC (venue launch)" in readme + assert "trades and liquidations from the venue launch, 2026-06-26 20:10:26 UTC" in readme + for name, text in copy.items(): + flat = " ".join(text.split()) + assert "funding, and liquidations from 2026-08-22" not in flat, name + assert "funding and liquidations from 2026-08-22" not in flat, name + assert "liquidations start 2026-08-22" not in flat, name + + +# --------------------------------------------------------------------------- +# WebSocket +# --------------------------------------------------------------------------- + + +def _connected() -> tuple[OxArchiveWs, FakeSocket]: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + socket = FakeSocket() + setattr(ws, "_ws", socket) + return ws, socket + + +def _data(channel: str, payload: Any, coin: str = "BTC") -> str: + return json.dumps( + {"type": "data", "channel": channel, "coin": coin, "symbol": coin, "data": payload} + ) + + +def test_rh_channel_sets() -> None: + assert RH_LIGHTER_LIVE_CHANNELS == { + "rh_lighter_orderbook", + "rh_lighter_trades", + "rh_lighter_open_interest", + "rh_lighter_funding", + } + assert RH_LIGHTER_REPLAY_ONLY_CHANNELS == {"rh_lighter_candles"} + assert RH_LIGHTER_REPLAY_CHANNELS == RH_LIGHTER_LIVE_CHANNELS | {"rh_lighter_candles"} + assert not RH_LIGHTER_REPLAY_CHANNELS & LIGHTER_LIVE_CHANNELS + channels = set(WsChannel.__args__) # type: ignore[attr-defined] + assert RH_LIGHTER_REPLAY_CHANNELS <= channels + + +def test_rh_lighter_candles_is_replay_only() -> None: + ws, socket = _connected() + + with pytest.raises(ValueError, match=re.escape(RH_LIGHTER_SUBSCRIPTION_ERROR)): + ws.subscribe("rh_lighter_candles", "BTC") + + async def run() -> None: + with pytest.raises(ValueError, match=re.escape(RH_LIGHTER_SUBSCRIPTION_ERROR)): + await ws.subscribe_async("rh_lighter_candles", "BTC") + + asyncio.run(run()) + assert ws._subscriptions == set() + assert socket.sent == [] + + +@pytest.mark.parametrize("channel", sorted(RH_LIGHTER_LIVE_CHANNELS)) +def test_rh_live_channels_send_a_plain_subscribe(channel: str) -> None: + ws, socket = _connected() + + asyncio.run(ws.subscribe_async(cast(WsChannel, channel), "btc")) + + assert ws._subscriptions == {f"{channel}:BTC"} + assert socket.sent == [{"op": "subscribe", "channel": channel, "symbol": "btc"}] + + +def test_rh_convenience_helpers_send_subscribe_and_unsubscribe() -> None: + ws, socket = _connected() + + async def run() -> None: + ws.subscribe_rh_lighter_orderbook("AAPL-USDG") + ws.subscribe_rh_lighter_orderbook("BTC", interval_ms=500) + ws.subscribe_rh_lighter_trades("BTC") + ws.subscribe_rh_lighter_open_interest("BTC") + ws.subscribe_rh_lighter_funding("BTC") + await asyncio.sleep(0) + ws.unsubscribe_rh_lighter_orderbook("BTC") + ws.unsubscribe_rh_lighter_trades("BTC") + ws.unsubscribe_rh_lighter_open_interest("BTC") + ws.unsubscribe_rh_lighter_funding("BTC") + await asyncio.sleep(0) + + asyncio.run(run()) + + assert socket.sent == [ + {"op": "subscribe", "channel": "rh_lighter_orderbook", "symbol": "AAPL-USDG"}, + {"op": "subscribe", "channel": "rh_lighter_orderbook", "symbol": "BTC", "interval_ms": 500}, + {"op": "subscribe", "channel": "rh_lighter_trades", "symbol": "BTC"}, + {"op": "subscribe", "channel": "rh_lighter_open_interest", "symbol": "BTC"}, + {"op": "subscribe", "channel": "rh_lighter_funding", "symbol": "BTC"}, + {"op": "unsubscribe", "channel": "rh_lighter_orderbook", "symbol": "BTC"}, + {"op": "unsubscribe", "channel": "rh_lighter_trades", "symbol": "BTC"}, + {"op": "unsubscribe", "channel": "rh_lighter_open_interest", "symbol": "BTC"}, + {"op": "unsubscribe", "channel": "rh_lighter_funding", "symbol": "BTC"}, + ] + assert ws._subscriptions == {"rh_lighter_orderbook:AAPL-USDG"} + + +def test_rh_interval_ms_rules_match_the_server() -> None: + ws, socket = _connected() + + with pytest.raises( + ValueError, match=re.escape("interval_ms is only supported on rh_lighter_orderbook.") + ): + ws.subscribe("rh_lighter_trades", "BTC", interval_ms=250) + with pytest.raises( + ValueError, + match=re.escape( + "interval_ms must be between 100 and 5000 for rh_lighter_orderbook (got 99). " + "Leave it out for one book a second." + ), + ): + ws.subscribe_rh_lighter_orderbook("BTC", interval_ms=99) + with pytest.raises(ValueError, match="integer"): + ws.subscribe_rh_lighter_orderbook("BTC", interval_ms=cast(int, 250.0)) + # Mainnet wording is unchanged for mainnet and Hyperliquid channels. + with pytest.raises( + ValueError, match=re.escape("interval_ms is only supported on lighter_orderbook.") + ): + ws.subscribe("lighter_trades", "BTC", interval_ms=250) + + assert ws._subscriptions == set() + assert socket.sent == [] + + +def test_rh_subscriptions_are_case_insensitive_and_survive_reconnect() -> None: + ws, socket = _connected() + + async def run() -> None: + await ws.subscribe_async("rh_lighter_orderbook", "aapl-usdg", interval_ms=250) + await ws.subscribe_async("rh_lighter_trades", "btc") + await ws.unsubscribe_async("rh_lighter_trades", "BTC") + socket.sent.clear() + await ws._resubscribe() + + asyncio.run(run()) + assert ws._subscriptions == {"rh_lighter_orderbook:AAPL-USDG"} + assert socket.sent == [ + { + "op": "subscribe", + "channel": "rh_lighter_orderbook", + "symbol": "AAPL-USDG", + "interval_ms": 250, + } + ] + + +def test_rh_messages_reach_their_own_handlers_not_the_mainnet_ones() -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + got: dict[str, list[Any]] = {"rh_book": [], "rh_trades": [], "rh_ctx": [], "mainnet": []} + mainnet: Callable[..., None] = lambda *args: got["mainnet"].append(args) # noqa: E731 + ws.on_lighter_orderbook(mainnet) + ws.on_lighter_trades(mainnet) + ws.on_lighter_market_context(mainnet) + ws.on_orderbook(mainnet) + ws.on_trades(mainnet) + ws.on_rh_lighter_orderbook(lambda coin, book: got["rh_book"].append((coin, book))) + ws.on_rh_lighter_trades(lambda coin, legs: got["rh_trades"].append((coin, legs))) + ws.on_rh_lighter_market_context(lambda ch, coin, ctx: got["rh_ctx"].append((ch, coin, ctx))) + + ws._handle_message(_data("rh_lighter_orderbook", LIGHTER_BOOK_FRAME)) + ws._handle_message(_data("rh_lighter_trades", LIGHTER_TRADES_FRAME)) + ws._handle_message(_data("rh_lighter_open_interest", LIGHTER_CTX_FRAME)) + ws._handle_message(_data("rh_lighter_funding", LIGHTER_CTX_FRAME)) + + assert got["mainnet"] == [] + coin, book = got["rh_book"][0] + assert coin == "BTC" and isinstance(book, OrderBook) + assert book.bids[0].px == "84368.7" and book.asks[0].px == "84368.8" + legs = got["rh_trades"][0][1] + assert [leg.account_index for leg in legs] == ["281474976623827", "713845"] + assert {leg.trade_id for leg in legs} == {31944180930} + assert [entry[0] for entry in got["rh_ctx"]] == [ + "rh_lighter_open_interest", + "rh_lighter_funding", + ] + assert got["rh_ctx"][0][2].open_interest == "172706178.266310" + + +def test_rh_books_and_trades_fall_back_to_the_generic_handlers() -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + books: list[Any] = [] + trades: list[Any] = [] + lighter_only: list[Any] = [] + ws.on_orderbook(lambda coin, book: books.append(book)) + ws.on_trades(lambda coin, legs: trades.extend(legs)) + ws.on_lighter_orderbook(lambda coin, book: lighter_only.append(book)) + + ws._handle_message(_data("rh_lighter_orderbook", LIGHTER_BOOK_FRAME)) + ws._handle_message(_data("rh_lighter_trades", LIGHTER_TRADES_FRAME)) + + assert len(books) == 1 and isinstance(books[0], OrderBook) + assert len(trades) == 2 and trades[0].account_index == "281474976623827" + assert lighter_only == [] + + +def test_rh_subscribe_ack_and_data_envelope_are_typed() -> None: + ws = OxArchiveWs(WsOptions(api_key="test-key")) + messages: list[object] = [] + ws.on_message(messages.append) + + ws._handle_message( + json.dumps( + {"type": "subscribed", "channel": "rh_lighter_trades", "coin": "BTC", "symbol": "BTC"} + ) + ) + ws._handle_message(_data("rh_lighter_trades", LIGHTER_TRADES_FRAME)) + + assert isinstance(messages[0], WsSubscribed) and messages[0].channel == "rh_lighter_trades" + assert isinstance(messages[1], WsData) and messages[1].channel == "rh_lighter_trades" + + +@pytest.mark.parametrize("channel", sorted(RH_LIGHTER_REPLAY_CHANNELS)) +def test_every_rh_channel_replays(channel: str) -> None: + 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) + + asyncio.run( + ws.replay(cast(WsChannel, channel), "BTC", start=1_788_000_000_000, end=1_788_003_600_000) + ) + + assert sent == [ + { + "op": "replay", + "channel": channel, + "symbol": "BTC", + "start": 1_788_000_000_000, + "speed": 1.0, + "end": 1_788_003_600_000, + } + ] + + +def test_rh_multi_replay_sends_the_rh_channel_family() -> None: + 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) + channels = cast( + list[WsChannel], + [ + "rh_lighter_orderbook", + "rh_lighter_trades", + "rh_lighter_open_interest", + "rh_lighter_funding", + ], + ) + + asyncio.run( + ws.multi_replay(channels, "BTC", start=1_788_000_000_000, end=1_788_003_600_000, speed=10) + ) + + assert sent[0]["op"] == "replay" and sent[0]["channels"] == channels