diff --git a/.gitignore b/.gitignore index 9859488..fab4502 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d648441..d39a664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). @@ -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 @@ -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. diff --git a/README.md b/README.md index 9c9a5fb..743be8d 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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") @@ -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: @@ -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") @@ -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: @@ -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}") @@ -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: @@ -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) ) ``` diff --git a/oxarchive/__init__.py b/oxarchive/__init__.py index bdc1b3c..0030bbf 100644 --- a/oxarchive/__init__.py +++ b/oxarchive/__init__.py @@ -109,6 +109,7 @@ WalletPositions, LighterL1Account, LighterL1Accounts, + PositionsFreshness, # Web3 Auth types SiweChallenge, Web3SignupResult, @@ -236,6 +237,7 @@ "WalletPositions", "LighterL1Account", "LighterL1Accounts", + "PositionsFreshness", # Web3 Auth Types "SiweChallenge", "Web3SignupResult", diff --git a/oxarchive/_time.py b/oxarchive/_time.py new file mode 100644 index 0000000..2ee526c --- /dev/null +++ b/oxarchive/_time.py @@ -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"] diff --git a/oxarchive/exchanges.py b/oxarchive/exchanges.py index 5a6f3f5..a1230f0 100644 --- a/oxarchive/exchanges.py +++ b/oxarchive/exchanges.py @@ -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, @@ -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) @@ -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: """ @@ -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 @@ -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: """ @@ -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.""" @@ -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: """ diff --git a/oxarchive/resources/breadth.py b/oxarchive/resources/breadth.py index be97554..defb477 100644 --- a/oxarchive/resources/breadth.py +++ b/oxarchive/resources/breadth.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Any, Literal, Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import BreadthSnapshot, CursorResponse, Timestamp @@ -40,22 +40,7 @@ def _validate_interval(interval: Optional[BreadthInterval]) -> None: 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 + _convert_timestamp = staticmethod(to_unix_ms) @staticmethod def _history_response(payload: dict[str, Any]) -> CursorResponse[list[BreadthSnapshot]]: diff --git a/oxarchive/resources/candles.py b/oxarchive/resources/candles.py index 5b01a74..b0fa00e 100644 --- a/oxarchive/resources/candles.py +++ b/oxarchive/resources/candles.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import Candle, CandleInterval, CursorResponse, Timestamp @@ -45,21 +45,7 @@ def _validate_limit(self, limit: Optional[int]) -> None: f"limit must be between 1 and {self._max_limit} for {self._limit_label}" ) - 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 history( self, diff --git a/oxarchive/resources/data_quality.py b/oxarchive/resources/data_quality.py index 0cf4028..326da7e 100644 --- a/oxarchive/resources/data_quality.py +++ b/oxarchive/resources/data_quality.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Literal, Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import ( CoverageResponse, @@ -12,6 +12,7 @@ Incident, IncidentsResponse, LatencyResponse, + PositionsFreshness, SlaResponse, StatusResponse, SymbolCoverageResponse, @@ -48,21 +49,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1/data-quality"): self._http = http self._base_path = base_path - 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) # ========================================================================= # Status Endpoints @@ -378,3 +365,29 @@ async def asla( }, ) return SlaResponse.model_validate(data) + + # ========================================================================= + # Account Positions Freshness + # ========================================================================= + + def positions_freshness(self) -> list[PositionsFreshness]: + """ + Get the freshness of the account positions data, one row per venue. + + Returns: + One :class:`PositionsFreshness` per venue (Hyperliquid core, HIP-3, + Lighter and Lighter on Robinhood Chain): the latest live snapshot + and its age, whether it is stale, the latest hourly snapshot, and + the ``built_through`` and ``finalized_through`` boundaries. + + Example: + >>> for venue in client.data_quality.positions_freshness(): + ... print(venue.venue, venue.product, venue.live_age_seconds, venue.stale) + """ + data = self._http.get(f"{self._base_path}/positions") + return [PositionsFreshness.model_validate(item) for item in data["data"]] + + async def apositions_freshness(self) -> list[PositionsFreshness]: + """Async version of positions_freshness().""" + data = await self._http.aget(f"{self._base_path}/positions") + return [PositionsFreshness.model_validate(item) for item in data["data"]] diff --git a/oxarchive/resources/funding.py b/oxarchive/resources/funding.py index a1b9b76..309d6d4 100644 --- a/oxarchive/resources/funding.py +++ b/oxarchive/resources/funding.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, FundingRate, Timestamp @@ -26,21 +26,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str. self._base_path = base_path self._coin_transform = coin_transform - 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 history( self, diff --git a/oxarchive/resources/l2_orderbook.py b/oxarchive/resources/l2_orderbook.py index 1799706..c959bfc 100644 --- a/oxarchive/resources/l2_orderbook.py +++ b/oxarchive/resources/l2_orderbook.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, Timestamp @@ -29,21 +29,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str. self._base_path = base_path self._coin_transform = coin_transform - 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( self, diff --git a/oxarchive/resources/l3_orderbook.py b/oxarchive/resources/l3_orderbook.py index 937e01d..c51408c 100644 --- a/oxarchive/resources/l3_orderbook.py +++ b/oxarchive/resources/l3_orderbook.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, Timestamp @@ -30,21 +30,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str. self._base_path = base_path self._coin_transform = coin_transform - 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) @staticmethod def _validate_depth(depth: Optional[int]) -> None: diff --git a/oxarchive/resources/l4_orderbook.py b/oxarchive/resources/l4_orderbook.py index 21f712a..8572bde 100644 --- a/oxarchive/resources/l4_orderbook.py +++ b/oxarchive/resources/l4_orderbook.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, Timestamp @@ -29,21 +29,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str. self._base_path = base_path self._coin_transform = coin_transform - 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( self, diff --git a/oxarchive/resources/lighter_liquidations.py b/oxarchive/resources/lighter_liquidations.py index f8a89e5..6db7650 100644 --- a/oxarchive/resources/lighter_liquidations.py +++ b/oxarchive/resources/lighter_liquidations.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Any, Callable, Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import ( CursorResponse, @@ -14,19 +14,7 @@ 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) +_to_ms = to_unix_ms class LighterLiquidationsResource: diff --git a/oxarchive/resources/liquidations.py b/oxarchive/resources/liquidations.py index dc3461c..d1aec49 100644 --- a/oxarchive/resources/liquidations.py +++ b/oxarchive/resources/liquidations.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import ( CursorResponse, @@ -45,21 +45,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str. self._base_path = base_path self._coin_transform = coin_transform - 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 history( self, diff --git a/oxarchive/resources/openinterest.py b/oxarchive/resources/openinterest.py index e798483..a039b25 100644 --- a/oxarchive/resources/openinterest.py +++ b/oxarchive/resources/openinterest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from datetime import datetime from typing import Generic, Optional, TypeVar from pydantic import BaseModel +from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, Hip4OpenInterestRecord, OpenInterest, Timestamp @@ -37,21 +37,7 @@ def __init__( self._coin_transform = coin_transform self._record_model = record_model - 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 history( self, diff --git a/oxarchive/resources/orderbook.py b/oxarchive/resources/orderbook.py index 152076d..21f6681 100644 --- a/oxarchive/resources/orderbook.py +++ b/oxarchive/resources/orderbook.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import AsyncIterator, Iterator, Literal, Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, OrderBook, Timestamp from ..orderbook_reconstructor import ( @@ -42,22 +42,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str. self._base_path = base_path self._coin_transform = coin_transform - 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 parsing ISO format - 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( self, @@ -227,8 +212,8 @@ def history_tick( Example: >>> 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: {tick_data.checkpoint}") >>> print(f"Deltas: {len(tick_data.deltas)}") @@ -350,8 +335,8 @@ def history_reconstructed( >>> # Get all snapshots >>> 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: ... print(ob.timestamp, "Best bid:", ob.bids[0].px, "Best ask:", ob.asks[0].px) @@ -477,8 +462,8 @@ def iterate_tick_history( >>> # Process 24 hours of tick data with automatic pagination >>> for snapshot in client.lighter.orderbook.iterate_tick_history( ... "BTC", - ... start=datetime.now() - timedelta(days=1), - ... end=datetime.now() + ... start=datetime.now(timezone.utc) - timedelta(days=1), + ... end=datetime.now(timezone.utc) ... ): ... print(snapshot.timestamp, "Mid:", snapshot.mid_price) ... if some_condition: @@ -558,8 +543,8 @@ async def aiterate_tick_history( >>> # Process 24 hours of tick data with automatic pagination >>> async for snapshot in client.lighter.orderbook.aiterate_tick_history( ... "BTC", - ... start=datetime.now() - timedelta(days=1), - ... end=datetime.now() + ... start=datetime.now(timezone.utc) - timedelta(days=1), + ... end=datetime.now(timezone.utc) ... ): ... print(snapshot.timestamp, "Mid:", snapshot.mid_price) ... if some_condition: diff --git a/oxarchive/resources/orders.py b/oxarchive/resources/orders.py index 671dd2c..18db9ab 100644 --- a/oxarchive/resources/orders.py +++ b/oxarchive/resources/orders.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import ( CursorResponse, @@ -35,21 +35,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1", coin_transform=str. self._base_path = base_path self._coin_transform = coin_transform - 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 history( self, diff --git a/oxarchive/resources/positions.py b/oxarchive/resources/positions.py index 8bfe870..8efbcfb 100644 --- a/oxarchive/resources/positions.py +++ b/oxarchive/resources/positions.py @@ -25,7 +25,6 @@ from __future__ import annotations -from datetime import datetime from typing import ( Any, AsyncIterator, @@ -38,6 +37,7 @@ ) from urllib.parse import quote +from .._time import to_unix_ms from ..http import HttpClient from ..types import ( AccountSummary, @@ -64,23 +64,7 @@ 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") +_to_ms = to_unix_ms def _hour_ms(hour: Timestamp) -> int: @@ -1140,6 +1124,107 @@ def aiterate_changes( _change_rows, ) + # ---- account: position aggregates ----------------------------------- + + def account(self, account_index: AccountIndex) -> CursorResponse[list[AccountSummary]]: + """Position aggregates of a Lighter account at the latest live snapshot. + + One :class:`AccountSummary` with ``account_index``, + ``total_position_value``, ``total_unrealized_pnl``, ``long_value``, + ``short_value``, ``n_positions`` and ``quality``. Lighter does not + report margin or account value here, so those fields are ``None``. An + account with no open position has zero totals. Billed at the + per-request minimum. + + Args: + account_index: Integer Lighter account index. + """ + return self._fetch( + _Endpoint(self._account_path(account_index, "account"), {}, _accounts) + ) + + async def aaccount(self, account_index: AccountIndex) -> CursorResponse[list[AccountSummary]]: + """Async version of :meth:`account`.""" + return await self._afetch( + _Endpoint(self._account_path(account_index, "account"), {}, _accounts) + ) + + def account_history( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[AccountSummary]]: + """Hourly position aggregates of a Lighter account in ``[start, end)``. + + One row per hourly snapshot, stamped with ``snapshot_ts``; an hour + with no open position has zero totals. Billed at the per-request + minimum. + + Args: + account_index: Integer Lighter account index. + start: Inclusive start. + end: Exclusive end. + cursor: ``next_cursor`` of the previous page. + limit: Hours per page (default 500, at most 744). + """ + return self._fetch( + self._range_ep( + account_index, "account/history", start, end, None, cursor, limit, _accounts + ) + ) + + async def aaccount_history( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> CursorResponse[list[AccountSummary]]: + """Async version of :meth:`account_history`.""" + return await self._afetch( + self._range_ep( + account_index, "account/history", start, end, None, cursor, limit, _accounts + ) + ) + + def iterate_account_history( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + limit: Optional[int] = None, + ) -> Iterator[AccountSummary]: + """Yield every hourly row of :meth:`account_history`, following cursors.""" + return self._iterate( + self._range_ep( + account_index, "account/history", start, end, None, None, limit, _accounts + ), + _account_rows, + ) + + def aiterate_account_history( + self, + account_index: AccountIndex, + *, + start: Timestamp, + end: Timestamp, + limit: Optional[int] = None, + ) -> AsyncIterator[AccountSummary]: + """Async version of :meth:`iterate_account_history`.""" + return self._aiterate( + self._range_ep( + account_index, "account/history", start, end, None, None, limit, _accounts + ), + _account_rows, + ) + # ---- market routes --------------------------------------------------- def market( diff --git a/oxarchive/resources/spot.py b/oxarchive/resources/spot.py index 4a03189..4c8a20a 100644 --- a/oxarchive/resources/spot.py +++ b/oxarchive/resources/spot.py @@ -12,9 +12,9 @@ from __future__ import annotations -from datetime import datetime from typing import Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, SpotPair, SpotTwapStatus, Timestamp @@ -82,21 +82,7 @@ def __init__(self, http: HttpClient, base_path: str = "/v1/hyperliquid/spot"): self._http = http self._base_path = base_path - 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 by_symbol( self, diff --git a/oxarchive/resources/trades.py b/oxarchive/resources/trades.py index cc13b63..e803ac7 100644 --- a/oxarchive/resources/trades.py +++ b/oxarchive/resources/trades.py @@ -2,9 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Literal, Optional +from .._time import to_unix_ms from ..http import HttpClient from ..types import CursorResponse, OxArchiveError, ResponseMeta, Trade, Timestamp @@ -44,21 +44,7 @@ def __init__( # with a clear pointer instead of letting the user 404 against the API. self._allow_recent = allow_recent - 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 list( self, diff --git a/oxarchive/types.py b/oxarchive/types.py index f1fe391..3a7ba39 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -2133,7 +2133,8 @@ class AccountSummary(BaseModel): as ``WalletPositions.account``. ``withdrawable`` is only available for older hourly history. - Lighter mainnet and Robinhood Chain: position aggregates only, as + Lighter mainnet and Robinhood Chain: position aggregates only, returned by + ``positions.account()`` / ``positions.account_history()`` and 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 @@ -2225,6 +2226,44 @@ class WalletPositions(BaseModel): ``"outside_coverage"`` (the instant is before coverage begins).""" +class PositionsFreshness(BaseModel): + """Freshness of the account positions data of one venue. + + Returned by ``client.data_quality.positions_freshness()``, one row per + venue: Hyperliquid core (``venue="hyperliquid"``, ``product="core"``), + HIP-3 (``"hyperliquid"``, ``"hip3"``), Lighter (``"lighter"``, + ``"lighter"``) and Lighter on Robinhood Chain (``"rh_lighter"``, + ``"rh_lighter"``). + """ + + venue: str + """Venue: ``"hyperliquid"``, ``"lighter"`` or ``"rh_lighter"``.""" + + product: str + """Product within the venue: ``"core"``, ``"hip3"``, ``"lighter"`` or ``"rh_lighter"``.""" + + live_snapshot_ts: Optional[datetime] = None + """Time of the latest live snapshot.""" + + live_age_seconds: Optional[int] = None + """Age of the latest live snapshot, in seconds.""" + + stale: bool + """``True`` when the latest live snapshot is older than 12 minutes (or there is none).""" + + live_quality: Optional[str] = None + """Quality of the latest live snapshot: ``"complete"``, ``"partial"`` or ``"degraded"``.""" + + hourly_snapshot_ts: Optional[datetime] = None + """Hour of the latest hourly snapshot.""" + + built_through: Optional[datetime] = None + """Every event before this instant is built into the change log and the as-of state.""" + + finalized_through: Optional[datetime] = None + """Every event before this instant is final and will not be re-derived.""" + + class LighterL1Account(BaseModel): """One Lighter account owned by an L1 address.""" @@ -2356,7 +2395,11 @@ class CursorResponse(BaseModel, Generic[T]): # Type alias for timestamp parameters Timestamp = Union[int, str, datetime] -"""Timestamp can be Unix ms (int), ISO string, or datetime object.""" +"""Timestamp can be Unix ms (int), an ISO 8601 string, or a datetime. + +A time without a time zone is UTC: a naive datetime, an ISO string without an +offset (``"2026-09-01T12:00:00"``) and a date alone (``"2026-09-01"``, midnight +UTC) mean the same instant on every machine.""" # ============================================================================= diff --git a/tests/test_positions.py b/tests/test_positions.py index cb1bf7c..29f326c 100644 --- a/tests/test_positions.py +++ b/tests/test_positions.py @@ -24,6 +24,7 @@ OxArchiveError, Position, PositionChange, + PositionsFreshness, ResponseMeta, WalletPositions, ) @@ -883,7 +884,7 @@ def respond(path: str, q: dict[str, str]) -> dict[str, Any]: 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") + assert callable(rh.account) and callable(client.lighter.positions.account) # --------------------------------------------------------------------------- @@ -957,6 +958,105 @@ async def run() -> dict[str, Any]: ] +def test_lighter_account_summary_routes_on_both_deployments() -> None: + summary = _with(LIGHTER_ACCOUNT_SUMMARY, account_index="7") + hourly = [ + _with(summary, snapshot_ts="2026-09-25T10:00:00Z"), + _with( + summary, + snapshot_ts="2026-09-25T11:00:00Z", + total_position_value="0", + total_unrealized_pnl="0", + long_value="0", + short_value="0", + n_positions=0, + ), + ] + + def responder(path: str, q: dict[str, str]) -> dict[str, Any]: + if path.endswith("/account"): + return envelope([summary], source="snapshot", quality="complete", stale=False) + if q.get("cursor") is None: + return envelope(hourly[:1], next_cursor="h2", source="snapshot") + return envelope(hourly[1:], source="snapshot") + + client, api = mock_client(responder) + for root, positions in ( + ("/v1/lighter", client.lighter.positions), + ("/v1/rh-lighter", client.rh_lighter.positions), + ): + api.requests.clear() + now = positions.account(7) + page = positions.account_history("7", start=T_START, end=T_END, limit=1) + rows = list(positions.iterate_account_history(7, start=T_START, end=T_END, limit=1)) + async_now = asyncio.run(positions.aaccount(7)) + async_page = asyncio.run(positions.aaccount_history(7, start=T_START, end=T_END)) + + window = {"start": str(T_START), "end": str(T_END)} + assert api.calls == [ + (f"{root}/accounts/7/account", {}), + (f"{root}/accounts/7/account/history", {**window, "limit": "1"}), + (f"{root}/accounts/7/account/history", {**window, "limit": "1"}), + (f"{root}/accounts/7/account/history", {**window, "limit": "1", "cursor": "h2"}), + (f"{root}/accounts/7/account", {}), + (f"{root}/accounts/7/account/history", window), + ] + assert isinstance(now.data[0], AccountSummary) + assert now.data[0].account_index == "7" + assert now.data[0].account_value is None + assert now.meta is not None and now.meta.source == "snapshot" + assert page.next_cursor == "h2" + assert [r.n_positions for r in rows] == [summary["n_positions"], 0] + assert rows[1].snapshot_ts == _utc("2026-09-25T11:00:00Z") + assert async_now.data == now.data + assert async_page.data[0].snapshot_ts == _utc("2026-09-25T10:00:00Z") + + with pytest.raises(ValueError, match="account_index"): + client.lighter.positions.account("0xabc") + + +def test_data_quality_positions_freshness() -> None: + rows = [ + { + "venue": "hyperliquid", + "product": "core", + "live_snapshot_ts": "2026-09-28T22:54:27.000Z", + "live_age_seconds": 119, + "stale": False, + "live_quality": "complete", + "hourly_snapshot_ts": "2026-09-28T22:00:00.000Z", + "built_through": "2026-09-28T22:00:00.000Z", + "finalized_through": "2026-09-28T19:00:00.000Z", + }, + { + "venue": "rh_lighter", + "product": "rh_lighter", + "live_snapshot_ts": None, + "live_age_seconds": None, + "stale": True, + "live_quality": None, + "hourly_snapshot_ts": None, + "built_through": None, + "finalized_through": None, + }, + ] + client, api = mock_client(lambda path, q: envelope(rows)) + + venues = client.data_quality.positions_freshness() + async_venues = asyncio.run(client.data_quality.apositions_freshness()) + + assert api.calls == [("/v1/data-quality/positions", {})] * 2 + assert [(v.venue, v.product) for v in venues] == [ + ("hyperliquid", "core"), + ("rh_lighter", "rh_lighter"), + ] + assert isinstance(venues[0], PositionsFreshness) + assert venues[0].live_age_seconds == 119 + assert venues[0].finalized_through == _utc("2026-09-28T19:00:00Z") + assert venues[1].stale is True and venues[1].live_snapshot_ts is None + assert async_venues == venues + + def test_positions_resources_expose_the_same_method_names_on_every_venue() -> None: client, _ = mock_client(lambda path, q: envelope([])) shared = { @@ -972,21 +1072,20 @@ def test_positions_resources_expose_the_same_method_names_on_every_venue() -> No "iterate_market_summary", "iterate_all", } - shared |= {"a" + name for name in shared} - hl_only = { + shared |= { "account", "account_history", - "aaccount", - "aaccount_history", "iterate_account_history", - "aiterate_account_history", } + shared |= {"a" + name for name in shared} - 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): + for resource in ( + client.hyperliquid.positions, + client.hyperliquid.hip3.positions, + 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) ) diff --git a/tests/test_timestamps.py b/tests/test_timestamps.py new file mode 100644 index 0000000..6a81050 --- /dev/null +++ b/tests/test_timestamps.py @@ -0,0 +1,102 @@ +"""Times without a time zone are UTC, whatever the machine's local zone. + +Every test runs with the process time zone set to America/New_York (UTC-4 in +September), where reading a naive time as local time would shift it by four +hours. +""" + +from __future__ import annotations + +import os +import time +from datetime import date, datetime, timedelta, timezone +from typing import Any, Iterator + +import pytest +from _mock_api import envelope, mock_client + +from oxarchive._time import to_unix_ms + +SEPT_1_UTC = 1788220800000 # 2026-09-01T00:00:00Z +SEPT_1_NOON_UTC = 1788264000000 # 2026-09-01T12:00:00Z + + +@pytest.fixture(autouse=True) +def new_york_time_zone() -> Iterator[None]: + if not hasattr(time, "tzset"): + pytest.skip("time.tzset is not available on this platform") + previous = os.environ.get("TZ") + os.environ["TZ"] = "America/New_York" + time.tzset() + try: + # The zone really is in effect: local midnight is 04:00 UTC. + assert datetime(2026, 9, 1).timestamp() * 1000 == SEPT_1_UTC + 4 * 3_600_000 + yield + finally: + if previous is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = previous + time.tzset() + + +@pytest.mark.parametrize( + "value", + [ + "2026-09-01", + "2026-09-01T00:00:00", + "2026-09-01T00:00:00.000", + "2026-09-01 00:00:00", + "2026-09-01T00:00:00Z", + "2026-09-01T00:00:00z", + "2026-09-01T00:00:00+00:00", + "2026-08-31T20:00:00-04:00", + "1788220800000", + " 1788220800000 ", + SEPT_1_UTC, + datetime(2026, 9, 1), + datetime(2026, 9, 1, tzinfo=timezone.utc), + datetime(2026, 9, 1, 2, tzinfo=timezone(timedelta(hours=2))), + date(2026, 9, 1), + ], +) +def test_every_form_of_september_first_is_the_same_instant(value: Any) -> None: + assert to_unix_ms(value) == SEPT_1_UTC + + +def test_milliseconds_are_exact() -> None: + assert to_unix_ms("2026-09-01T12:00:00.123") == SEPT_1_NOON_UTC + 123 + assert to_unix_ms(datetime(2026, 9, 1, 12, 0, 0, 123999)) == SEPT_1_NOON_UTC + 123 + + +def test_none_passes_through_and_bad_values_raise() -> None: + assert to_unix_ms(None) is None + for bad in ("yesterday", "", True, 1.5, [1]): + with pytest.raises(ValueError, match="start"): + to_unix_ms(bad, "start") + + +def test_resources_send_naive_times_as_utc() -> None: + def responder(path: str, q: dict[str, str]) -> dict[str, Any]: + if path.endswith("/positions") and "/wallets/" in path: + return envelope({"positions": [], "account": None}) + return envelope([]) + + client, api = mock_client(responder) + + client.hyperliquid.trades.list("BTC", start="2026-09-01", end="2026-09-01T12:00:00") + client.lighter.positions.changes( + 7, start=datetime(2026, 9, 1), end=datetime(2026, 9, 1, 12) + ) + client.hyperliquid.positions.get( + "0x1111111111111111111111111111111111111111", timestamp="2026-09-01T12:00:00" + ) + client.hyperliquid.positions.market("BTC", hour="2026-09-01T12:00:00") + client.rh_lighter.liquidations.history("BTC", start="2026-09-01", end="2026-09-01T12:00") + + sent = [q for _, q in api.calls] + assert sent[0]["start"] == str(SEPT_1_UTC) and sent[0]["end"] == str(SEPT_1_NOON_UTC) + assert sent[1]["start"] == str(SEPT_1_UTC) and sent[1]["end"] == str(SEPT_1_NOON_UTC) + assert sent[2]["timestamp"] == str(SEPT_1_NOON_UTC) + assert sent[3]["hour"] == str(SEPT_1_NOON_UTC) + assert sent[4]["start"] == str(SEPT_1_UTC) and sent[4]["end"] == str(SEPT_1_NOON_UTC)