Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,35 @@ All notable changes to the `oxarchive` Python SDK are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.9.1] - Unreleased

### Added
- HIP-3 breadth above current UTC-session VWAP via
`client.hyperliquid.hip3.breadth.current()` and cursor-paginated
`.history()`; collection begins on 2026-08-28 and `value_pct` remains null
when no instrument is eligible. History accepts `5m`, `15m`, `30m`, `1h`,
`4h`, and `1d` downsampling intervals.
- Typed Hyperliquid core L4 replay frames: `l4_snapshot` is followed by
ordered `l4_batch` events for `l4_diffs` and `l4_orders`. HIP-3, HIP-4, and
Hyperliquid Spot L4 remain live-only.

### Changed
- Lighter WebSocket channels now support bounded historical replay without
live subscriptions. Current Lighter data remains available through REST;
live subscription calls fail fast with guidance to REST or replay.
- Projected forced-liquidation price-level endpoints refresh about every five
minutes. This is a measured cadence, not an exact five-minute guarantee.

### Breaking
- Lighter `funding_rate` is now a fractional, non-annualized rate. Consumers
that compensated for the former percent units must remove that conversion;
do not apply a second percent conversion.

## [1.9.0] - 2026-08-22

### Added
- HIP-4 candle history at `client.hyperliquid.hip4.candles.history()` and its async equivalent.
- **Hyperliquid Spot candle history.** Added `client.spot.candles.history()` and `ahistory()` for `/v1/hyperliquid/spot/candles/{symbol}`. Coverage starts at `2025-03-22T10:50:22Z`; supported intervals are `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, with opaque cursor pagination and a 1,000-row page cap.
- **Hyperliquid Spot candle history.** Added `client.spot.candles.history()` and `ahistory()` for `/v1/hyperliquid/spot/candles/{symbol}`. Coverage starts at `2025-03-22T10:50:22Z`; supported intervals are `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, and `1w`, with numeric timestamp-string cursor pagination and a 1,000-row page cap.

### Changed
- Coverage copy now states HIP-4 outcome-side OI at roughly 10-second cadence, Lighter L3 at 250 orders per side from March 5, 2026, and Lighter per-fill trade history from August 27, 2025.
Expand All @@ -22,9 +46,9 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **Liquidation levels**: `liquidations.levels()` / `alevels()` and
`levels_history()` / `alevels_history()` on the Hyperliquid and HIP-3
clients. Projected forced-liquidation levels computed from clearinghouse
positions and margin state (~45-minute snapshots, `at=` point-in-time
reads, `side=` filter, cursor-paginated history with `summary=True`
discovery mode). History retained from 2026-07-27.
positions and margin state (snapshots approximately every five minutes,
`at=` point-in-time reads, `side=` filter, cursor-paginated history with
`summary=True` discovery mode). History retained from 2026-07-27.
- **Trigger levels**: `orders.trigger_levels()` and
`trigger_levels_history()` (+ async variants): the pending stop-loss /
take-profit map with 15-minute snapshot history.
Expand Down
185 changes: 126 additions & 59 deletions README.md

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion oxarchive/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
SpotPair,
SpotTwapStatus,
SpotTableFreshness,
BreadthNamespaceCounts,
BreadthCounts,
BreadthSnapshot,
FundingRate,
OpenInterest,
Liquidation,
Expand Down Expand Up @@ -91,6 +94,8 @@
WsPong,
WsError,
WsData,
WsL4Snapshot,
WsL4Batch,
# Replay types (Option B)
WsReplayStarted,
WsReplayPaused,
Expand Down Expand Up @@ -119,7 +124,7 @@
OxArchiveWs = None # type: ignore
WsOptions = None # type: ignore

__version__ = "1.9.0"
__version__ = "1.9.1"

__all__ = [
# Client
Expand Down Expand Up @@ -159,6 +164,9 @@
"SpotPair",
"SpotTwapStatus",
"SpotTableFreshness",
"BreadthNamespaceCounts",
"BreadthCounts",
"BreadthSnapshot",
"LighterGranularity",
"FundingRate",
"OpenInterest",
Expand Down Expand Up @@ -193,6 +201,8 @@
"WsPong",
"WsError",
"WsData",
"WsL4Snapshot",
"WsL4Batch",
# Replay Types (Option B)
"WsReplayStarted",
"WsReplayPaused",
Expand Down
6 changes: 5 additions & 1 deletion oxarchive/exchanges.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from .http import HttpClient
from .resources import (
BreadthResource,
CandlesResource,
FundingResource,
Hip3CandlesResource,
Expand Down Expand Up @@ -345,6 +346,9 @@ def __init__(self, http: HttpClient):
self.instruments = Hip3InstrumentsResource(http, base_path, coin_transform=coin_transform)
"""HIP-3 instruments with latest market data"""

self.breadth = BreadthResource(http, base_path)
"""Percent of eligible instruments above current UTC-session VWAP."""

self.orderbook = OrderBookResource(http, base_path, coin_transform=coin_transform)
"""Order book snapshots (February 2026+)"""

Expand All @@ -358,7 +362,7 @@ def __init__(self, http: HttpClient):
"""Open interest"""

self.candles = Hip3CandlesResource(http, base_path, coin_transform=coin_transform)
"""OHLCV candle data (max 1,000 rows per page)"""
"""OHLCV candle data (max 10,000 rows per page)"""

self.liquidations = LiquidationsResource(http, base_path, coin_transform=coin_transform)
"""Liquidation events"""
Expand Down
2 changes: 2 additions & 0 deletions oxarchive/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Resource modules."""

from .breadth import BreadthResource
from .candles import CandlesResource, Hip3CandlesResource, Hip4CandlesResource, SpotCandlesResource
from .data_quality import DataQualityResource
from .funding import FundingResource
Expand Down Expand Up @@ -29,6 +30,7 @@
"Hip3InstrumentsResource",
"Hip4InstrumentsResource",
"FundingResource",
"BreadthResource",
"OpenInterestResource",
"Hip4OpenInterestResource",
"CandlesResource",
Expand Down
129 changes: 129 additions & 0 deletions oxarchive/resources/breadth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""HIP-3 market breadth API resource."""

from __future__ import annotations

from datetime import datetime
from typing import Literal, Optional

from ..http import HttpClient
from ..types import BreadthSnapshot, CursorResponse, Timestamp

BreadthInterval = Literal["5m", "15m", "30m", "1h", "4h", "1d"]
BREADTH_INTERVALS = frozenset({"5m", "15m", "30m", "1h", "4h", "1d"})


class BreadthResource:
"""HIP-3 percent-above-session-VWAP market breadth.

The current route returns one validated snapshot. History defaults to the
last 24 hours of raw one-minute snapshots; the server applies the
last-snapshot-per-bucket rule when ``interval`` is supplied. Collection
began on 2026-08-28, so callers must not infer synthetic pre-launch history.
"""

def __init__(
self,
http: HttpClient,
base_path: str = "/v1/hyperliquid/hip3",
) -> None:
self._http = http
self._base_path = base_path
self._max_limit = 1000

def _validate_limit(self, limit: Optional[int]) -> None:
if limit is not None and not 1 <= limit <= self._max_limit:
raise ValueError(f"limit must be between 1 and {self._max_limit} for HIP-3 breadth")

@staticmethod
def _validate_interval(interval: Optional[BreadthInterval]) -> None:
if interval is not None and interval not in BREADTH_INTERVALS:
choices = ", ".join(sorted(BREADTH_INTERVALS))
raise ValueError(f"interval must be one of {choices} for HIP-3 breadth")

@staticmethod
def _convert_timestamp(ts: Optional[Timestamp]) -> Optional[int]:
"""Convert an ISO timestamp or datetime to Unix milliseconds."""
if ts is None:
return None
if isinstance(ts, int):
return ts
if isinstance(ts, datetime):
return int(ts.timestamp() * 1000)
if isinstance(ts, str):
try:
parsed = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return int(parsed.timestamp() * 1000)
except ValueError:
return int(ts)
return None

@staticmethod
def _history_response(payload: dict) -> CursorResponse[list[BreadthSnapshot]]:
return CursorResponse(
data=[BreadthSnapshot.model_validate(item) for item in payload["data"]],
next_cursor=payload.get("meta", {}).get("next_cursor"),
)

def current(self) -> BreadthSnapshot:
"""Return the latest validated HIP-3 breadth snapshot."""
payload = self._http.get(f"{self._base_path}/breadth/above-vwap/current")
return BreadthSnapshot.model_validate(payload["data"])

async def acurrent(self) -> BreadthSnapshot:
"""Async version of :meth:`current`."""
payload = await self._http.aget(f"{self._base_path}/breadth/above-vwap/current")
return BreadthSnapshot.model_validate(payload["data"])

def history(
self,
*,
start: Optional[Timestamp] = None,
end: Optional[Timestamp] = None,
interval: Optional[BreadthInterval] = None,
cursor: Optional[str] = None,
limit: Optional[int] = None,
) -> CursorResponse[list[BreadthSnapshot]]:
"""Return ascending HIP-3 breadth history with cursor pagination.

``start`` defaults to 24 hours before ``end`` and ``end`` defaults to
now. ``cursor`` is the epoch-millisecond string returned by
``meta.next_cursor`` and is passed back unchanged. History begins on
2026-08-28; a pre-launch window may be empty with coverage metadata.
"""
self._validate_interval(interval)
self._validate_limit(limit)
payload = self._http.get(
f"{self._base_path}/breadth/above-vwap",
params={
"start": self._convert_timestamp(start),
"end": self._convert_timestamp(end),
"interval": interval,
"cursor": cursor,
"limit": limit,
},
)
return self._history_response(payload)

async def ahistory(
self,
*,
start: Optional[Timestamp] = None,
end: Optional[Timestamp] = None,
interval: Optional[BreadthInterval] = None,
cursor: Optional[str] = None,
limit: Optional[int] = None,
) -> CursorResponse[list[BreadthSnapshot]]:
"""Async version of :meth:`history`."""
self._validate_interval(interval)
self._validate_limit(limit)
payload = await self._http.aget(
f"{self._base_path}/breadth/above-vwap",
params={
"start": self._convert_timestamp(start),
"end": self._convert_timestamp(end),
"interval": interval,
"cursor": cursor,
"limit": limit,
},
)
return self._history_response(payload)
9 changes: 5 additions & 4 deletions oxarchive/resources/candles.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ def history(
start: Start timestamp (required)
end: End timestamp (required)
interval: Candle interval (1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w). Default: 1h
cursor: Opaque cursor string from the previous response's next_cursor
cursor: Numeric timestamp string returned as the previous response's
next_cursor; pass it back unchanged
limit: Maximum number of results (default: 100, max: 10000 for
Hyperliquid core and Lighter candles; HIP-3, HIP-4, and Spot
Hyperliquid core, HIP-3, and Lighter candles; HIP-4 and Spot
have a max of 1000)

Returns:
Expand Down Expand Up @@ -164,7 +165,7 @@ def _resolve_symbol(symbol, kwargs):


class Hip3CandlesResource(CandlesResource):
"""HIP-3 OHLCV candles with a 1,000-row page cap."""
"""HIP-3 OHLCV candles with a 10,000-row page cap."""

def __init__(
self,
Expand All @@ -173,7 +174,7 @@ def __init__(
coin_transform=lambda coin: coin,
):
super().__init__(http, base_path, coin_transform)
self._max_limit = 1000
self._max_limit = 10000
self._limit_label = "HIP-3 candles"


Expand Down
6 changes: 3 additions & 3 deletions oxarchive/resources/liquidations.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,8 @@ def levels(
Get projected forced-liquidation levels for a symbol.

Computed from clearinghouse positions and margin state, bucketed
around the snapshot mark price. Snapshots refresh roughly every 45
minutes; pass ``at`` (epoch ms) for a point-in-time read. History
around the snapshot mark price. Snapshots refresh approximately every
five minutes; pass ``at`` (epoch ms) for a point-in-time read. History
begins 2026-07-27.

These are projected forced liquidations, not the pending
Expand Down Expand Up @@ -384,7 +384,7 @@ def levels_history(
"""
Get historical liquidation-levels snapshots with cursor pagination.

Ascending by snapshot time (about every 45 minutes, retained from
Ascending by snapshot time (approximately every five minutes, retained from
2026-07-27). Pass ``summary=True`` to list snapshots without
histograms. Follow ``next_cursor`` as ``cursor`` for the next page.

Expand Down
3 changes: 2 additions & 1 deletion oxarchive/resources/openinterest.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ def history(
symbol: The symbol (e.g., 'BTC', 'ETH')
start: Start timestamp (required)
end: End timestamp (required)
cursor: Opaque cursor string from the previous response's next_cursor
cursor: Numeric timestamp string returned as the previous response's
next_cursor; pass it back unchanged
limit: Maximum number of results (default: 100, max: 1000)
interval: Aggregation interval (e.g., '5m', '15m', '30m', '1h', '4h', '1d').
Raw cadence is route-specific. HIP-3, HIP-4 outcome-side OI,
Expand Down
Loading