Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ tests/*
!tests/test_positions.py
!tests/test_rh_lighter.py
!tests/_mock_api.py
!tests/test_timestamps.py

# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
24 changes: 21 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain
`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`.
- `account(key)` and `account_history(key, start, end)`: the account
summary now and hourly. On Hyperliquid and HIP-3 it is the clearinghouse
summary (HIP-3 takes an optional `dex`); on Lighter and Robinhood Chain it
holds position aggregates (totals, long/short value, `n_positions`).
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).
Expand All @@ -60,6 +62,10 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain
`PositionChange`, `MarketPosition`, `MarketPositionsSummary`,
`AccountSummary`, `WalletPositions`, `LighterL1Account`,
`LighterL1Accounts`.
- `client.data_quality.positions_freshness()` (and
`apositions_freshness()`): one `PositionsFreshness` row per venue with
the latest live and hourly snapshots, the live snapshot's age and
quality, `stale`, `built_through` and `finalized_through`.
- 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
Expand Down Expand Up @@ -116,6 +122,18 @@ deployments in the SDK, mainnet (`client.lighter`) and Robinhood Chain
(the default), `5m`, `15m` and `1h`. The docstring used to suggest `4h` and
`1d`, which the API refuses.

### Fixed
- Times without a time zone are UTC on every method. Before, a naive
`datetime` and an ISO string without an offset (`"2026-09-01"`,
`"2026-09-01T00:00:00"`) were read as the machine's local time, so the
same call asked for a different window on machines in different time
zones. A date alone is now midnight UTC and an offset-less date-time is
UTC. A naive `datetime.now()` is local wall-clock time; for the current
time pass an aware datetime such as `datetime.now(timezone.utc)`. Every
resource now converts times with one shared helper, and a value that is
not a timestamp raises `ValueError` instead of being dropped from the
request.

## [1.11.0] - 2026-09-25

Versions 1.7.1, 1.8.0, 1.9.0, 1.9.1 and 1.10.0 were not published to PyPI.
Expand Down
42 changes: 25 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,14 @@ history = client.lighter.orderbook.history(
For tick-level data, the SDK provides client-side orderbook reconstruction. This efficiently reconstructs full orderbook state from a checkpoint and incremental deltas.

```python
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from oxarchive import OrderBookReconstructor

# Option 1: Get fully reconstructed snapshots (simplest)
snapshots = client.lighter.orderbook.history_reconstructed(
"BTC",
start=datetime.now() - timedelta(hours=1),
end=datetime.now()
start=datetime.now(timezone.utc) - timedelta(hours=1),
end=datetime.now(timezone.utc)
)

for ob in snapshots:
Expand All @@ -242,8 +242,8 @@ for ob in snapshots:
# Option 2: Get raw tick data for custom reconstruction
tick_data = client.lighter.orderbook.history_tick(
"BTC",
start=datetime.now() - timedelta(hours=1),
end=datetime.now()
start=datetime.now(timezone.utc) - timedelta(hours=1),
end=datetime.now(timezone.utc)
)

print(f"Checkpoint: {len(tick_data.checkpoint.bids)} bids")
Expand All @@ -253,8 +253,8 @@ print(f"Deltas: {len(tick_data.deltas)} updates")
# Automatically handles pagination, fetching up to 1,000 deltas per request
for snapshot in client.lighter.orderbook.iterate_tick_history(
"BTC",
start=datetime.now() - timedelta(days=1), # 24 hours of data
end=datetime.now()
start=datetime.now(timezone.utc) - timedelta(days=1), # 24 hours of data
end=datetime.now(timezone.utc)
):
print(snapshot.timestamp, "Mid:", snapshot.mid_price)
if some_condition:
Expand Down Expand Up @@ -1167,9 +1167,12 @@ for row in client.hyperliquid.positions.iterate_history("0xabc...", start="2026-
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)
# Account summaries: the clearinghouse summary on Hyperliquid and HIP-3,
# position aggregates on Lighter and Robinhood Chain
summary = client.hyperliquid.positions.account("0xabc...")
hourly = client.hyperliquid.positions.account_history("0xabc...", start="2026-09-01", end="2026-09-02")
lighter_summary = client.lighter.positions.account(4521)
lighter_hourly = client.rh_lighter.positions.account_history(4521, 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")
Expand Down Expand Up @@ -1216,12 +1219,12 @@ async for leg in client.lighter.positions.aiterate_changes(4521, start=..., end=
| `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) |
| `account(key, *, dex)` | `AccountSummary` rows at the latest live snapshot: the clearinghouse summary on Hyperliquid and HIP-3, position aggregates on Lighter |
| `account_history(key, *, start, end, dex, cursor, limit)` | Hourly `AccountSummary` rows (on Lighter, one per hour, at most 744 per page) |
| `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.
`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, and a time without a time zone is UTC; `hour` must be an exact UTC hour.

Things to know:

Expand Down Expand Up @@ -1279,6 +1282,10 @@ latency = client.data_quality.latency()
for exchange, metrics in latency.exchanges.items():
print(f"{exchange}: OB lag {metrics.data_freshness.orderbook_lag_ms}ms")

# Account positions freshness, one row per venue
for venue in client.data_quality.positions_freshness():
print(venue.venue, venue.product, venue.live_age_seconds, venue.stale, venue.finalized_through)

# Get SLA compliance metrics for a specific month
sla = client.data_quality.sla(year=2026, month=1)
print(f"Period: {sla.period}")
Expand All @@ -1302,6 +1309,7 @@ coverage = await client.data_quality.acoverage()
| `get_incident(incident_id)` | Get specific incident details |
| `latency()` | Current latency metrics (WebSocket, REST, data freshness) |
| `sla(year, month)` | SLA compliance metrics for a specific month |
| `positions_freshness()` | Account positions freshness per venue: latest live and hourly snapshots, staleness, `built_through`, `finalized_through` |

**Note:** Data Quality endpoints (`coverage()`, `exchange_coverage()`, `symbol_coverage()`) perform complex aggregation queries and may take 30-60 seconds on first request (results are cached server-side for 5 minutes). If you encounter timeout errors, create a client with a longer timeout:

Expand Down Expand Up @@ -2000,22 +2008,22 @@ await ws.multi_replay(

## Timestamp Formats

The SDK accepts timestamps in multiple formats:
The SDK accepts timestamps in multiple formats and sends them as Unix milliseconds. A time without a time zone is UTC: a naive `datetime`, an ISO string without an offset (`"2024-01-01T12:00:00"`), and a date alone (`"2024-01-01"`, midnight UTC) mean the same instant on every machine, whatever its local time zone. For the current time, use an aware datetime such as `datetime.now(timezone.utc)`; a naive `datetime.now()` is local wall-clock time and would be read as UTC.

```python
from datetime import datetime
from datetime import datetime, timezone

# Unix milliseconds (int)
client.hyperliquid.orderbook.get("BTC", timestamp=1704067200000)

# ISO string
client.hyperliquid.orderbook.history("BTC", start="2024-01-01", end="2024-01-02")
# ISO string: a date alone is midnight UTC; a time without an offset is UTC
client.hyperliquid.orderbook.history("BTC", start="2024-01-01", end="2024-01-01T12:00:00")

# datetime object
# datetime object: naive datetimes are UTC, aware ones keep their offset
client.hyperliquid.orderbook.history(
"BTC",
start=datetime(2024, 1, 1),
end=datetime(2024, 1, 2)
end=datetime(2024, 1, 2, tzinfo=timezone.utc)
)
```

Expand Down
2 changes: 2 additions & 0 deletions oxarchive/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
WalletPositions,
LighterL1Account,
LighterL1Accounts,
PositionsFreshness,
# Web3 Auth types
SiweChallenge,
Web3SignupResult,
Expand Down Expand Up @@ -236,6 +237,7 @@
"WalletPositions",
"LighterL1Account",
"LighterL1Accounts",
"PositionsFreshness",
# Web3 Auth Types
"SiweChallenge",
"Web3SignupResult",
Expand Down
66 changes: 66 additions & 0 deletions oxarchive/_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Timestamp conversion shared by every resource.

The API takes times as Unix milliseconds. Every method that accepts a
:data:`~oxarchive.types.Timestamp` converts it here, so a time means the same
instant everywhere in the SDK, whatever the local time zone of the machine:

- ``int``: Unix milliseconds, sent unchanged.
- ``datetime`` with a time zone: that instant.
- ``datetime`` without a time zone: read as UTC.
- ``date``: midnight UTC of that day.
- ``str`` of digits: Unix milliseconds.
- any other ``str``: ISO 8601 (a trailing ``Z`` is accepted). Without an
offset it is read as UTC; a date alone (``"2026-09-01"``) is midnight UTC.
"""

from __future__ import annotations

import numbers
import re
from datetime import date, datetime, timedelta, timezone
from typing import Any, Optional

_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
_ONE_MS = timedelta(milliseconds=1)
_DIGITS = re.compile(r"-?\d+")
_ERROR = "must be Unix milliseconds, an ISO 8601 string, a datetime or a date"


def _datetime_ms(value: datetime) -> int:
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
value = value.replace(tzinfo=timezone.utc)
return (value - _EPOCH) // _ONE_MS


def to_unix_ms(ts: Any, name: str = "timestamp") -> Optional[int]:
"""Convert a timestamp to Unix milliseconds, reading times without a zone as UTC.

``None`` passes through as ``None``. Anything that is not a timestamp
raises ``ValueError``.
"""
if ts is None:
return None
if isinstance(ts, bool):
raise ValueError(f"{name} {_ERROR}")
if isinstance(ts, int):
return ts
if isinstance(ts, numbers.Integral):
return int(ts)
if isinstance(ts, datetime):
return _datetime_ms(ts)
if isinstance(ts, date):
return _datetime_ms(datetime(ts.year, ts.month, ts.day))
if isinstance(ts, str):
text = ts.strip()
if _DIGITS.fullmatch(text):
return int(text)
if text.endswith(("Z", "z")):
text = text[:-1] + "+00:00"
try:
return _datetime_ms(datetime.fromisoformat(text))
except ValueError:
raise ValueError(f"{name} {_ERROR} (got {ts!r})") from None
raise ValueError(f"{name} {_ERROR} (got {type(ts).__name__})")


__all__ = ["to_unix_ms"]
86 changes: 8 additions & 78 deletions oxarchive/exchanges.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
from __future__ import annotations

import warnings
from datetime import datetime
from typing import Optional
from urllib.parse import quote

from ._time import to_unix_ms
from .http import HttpClient
from .resources import (
BreadthResource,
Expand Down Expand Up @@ -125,21 +125,7 @@ def __init__(self, http: HttpClient):
self.hip4 = Hip4Client(http)
"""HIP-4 outcome markets (May 2026+)"""

def _convert_timestamp(self, ts: Optional[Timestamp]) -> Optional[int]:
"""Convert timestamp 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:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return int(dt.timestamp() * 1000)
except ValueError:
return int(ts)
return None
_convert_timestamp = staticmethod(to_unix_ms)

# -----------------------------------------------------------------
# Convenience methods (not tied to a specific resource)
Expand Down Expand Up @@ -390,21 +376,7 @@ def __init__(self, http: HttpClient):
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:
return None
if isinstance(ts, int):
return ts
if isinstance(ts, datetime):
return int(ts.timestamp() * 1000)
if isinstance(ts, str):
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return int(dt.timestamp() * 1000)
except ValueError:
return int(ts)
return None
_convert_timestamp = staticmethod(to_unix_ms)

def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness:
"""
Expand Down Expand Up @@ -604,21 +576,7 @@ def __init__(self, http: HttpClient):
self.l2_orderbook = L2OrderBookResource(http, base_path, coin_transform=_hip4_encode)
"""L2 full-depth orderbook (derived from L4)."""

def _convert_timestamp(self, ts: Optional[Timestamp]) -> Optional[int]:
"""Convert timestamp 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:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return int(dt.timestamp() * 1000)
except ValueError:
return int(ts)
return None
_convert_timestamp = staticmethod(to_unix_ms)

# -----------------------------------------------------------------
# Discovery / metadata flat helpers
Expand Down Expand Up @@ -923,21 +881,7 @@ def __init__(self, http: HttpClient):
)
"""Liquidation events (``history()``) and aggregated volume (``volume()``)"""

def _convert_timestamp(self, ts: Optional[Timestamp]) -> Optional[int]:
"""Convert timestamp 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:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return int(dt.timestamp() * 1000)
except ValueError:
return int(ts)
return None
_convert_timestamp = staticmethod(to_unix_ms)

def get_freshness(self, symbol: str, **kwargs) -> CoinFreshness:
"""
Expand Down Expand Up @@ -1079,8 +1023,8 @@ def __init__(self, http: HttpClient):

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."""
history, change log, account summaries (position aggregates), 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."""
Expand Down Expand Up @@ -1173,21 +1117,7 @@ def __init__(self, http: HttpClient):
self.twap = SpotTwapResource(http, base_path)
"""TWAP status records by pair or by user wallet."""

def _convert_timestamp(self, ts: Optional[Timestamp]) -> Optional[int]:
"""Convert timestamp 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:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return int(dt.timestamp() * 1000)
except ValueError:
return int(ts)
return None
_convert_timestamp = staticmethod(to_unix_ms)

def get_freshness(self, symbol: str, **kwargs) -> SpotTableFreshness:
"""
Expand Down
Loading
Loading