diff --git a/CHANGELOG.md b/CHANGELOG.md index 2985b3f..999bca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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] + +### Added +- `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`. + +### Changed +- `client.hyperliquid.hip3.breadth.history()` and `ahistory()` accept + `interval="1m"`. The API now 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` + (the default), `5m`, `15m` and `1h`. The docstring used to suggest `4h` and + `1d`, which the API refuses. + ## [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 2e679ba..ab5442b 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,7 @@ print(f"{current.value_pct}% above VWAP ({current.counts.eligible} eligible)") history = client.hyperliquid.hip3.breadth.history( start="2026-08-28T00:00:00Z", end="2026-08-29T00:00:00Z", - interval="5m", # 5m, 15m, 30m, 1h, 4h, 1d + interval="5m", # 1m, 5m, 15m, 30m, 1h, 4h, 1d limit=1000, ) while history.next_cursor: @@ -569,7 +569,7 @@ hip3_current = await client.hyperliquid.hip3.funding.acurrent("km:US500") | `end` | `Timestamp` | Yes | End timestamp | | `cursor` | `Timestamp` | No | Cursor from previous response for pagination | | `limit` | `int` | No | Max results (default: 100, max: 1000) | -| `interval` | `str` | No | Aggregation interval: `'5m'`, `'15m'`, `'30m'`, `'1h'`, `'4h'`, `'1d'`. Omit for raw rows: Hyperliquid core funding is ~1 min; HIP-3 and Lighter funding are ~10s. HIP-4 has no funding. | +| `interval` | `str` | No | Aggregation interval: `'1m'`, `'5m'`, `'15m'`, `'30m'`, `'1h'`, `'4h'`, `'1d'`. Omit for raw rows: Hyperliquid core funding is ~1 min; HIP-3 and Lighter funding are ~10s. HIP-4 has no funding. | ### Open Interest @@ -611,7 +611,7 @@ hip3_current = await client.hyperliquid.hip3.open_interest.acurrent("km:US500") | `end` | `Timestamp` | Yes | End timestamp | | `cursor` | `str` | No | Numeric timestamp string returned as `next_cursor`; pass it back unchanged | | `limit` | `int` | No | Max results (default: 100, max: 1000) | -| `interval` | `str` | No | Aggregation interval: `'5m'`, `'15m'`, `'30m'`, `'1h'`, `'4h'`, `'1d'`. Omit for raw rows: HIP-3, HIP-4 outcome-side OI, and Lighter update at ~10s. | +| `interval` | `str` | No | Aggregation interval: `'1m'`, `'5m'`, `'15m'`, `'30m'`, `'1h'`, `'4h'`, `'1d'`. Omit for raw rows: HIP-3, HIP-4 outcome-side OI, and Lighter update at ~10s. | ### Liquidations @@ -679,7 +679,7 @@ volume = client.hyperliquid.liquidations.volume( "BTC", start="2026-01-01", end="2026-01-08", - interval="1h" # 5m, 15m, 30m, 1h, 4h, 1d + interval="1h" # 1m, 5m, 15m, 30m, 1h, 4h, 1d ) for bucket in volume.data: @@ -764,7 +764,7 @@ prices = client.hyperliquid.get_price_history( "BTC", start="2026-01-01", end="2026-01-02", - interval="1h" # 5m, 15m, 30m, 1h, 4h, 1d + interval="1h" # 1m, 5m, 15m, 30m, 1h, 4h, 1d ) for snapshot in prices.data: @@ -1009,12 +1009,13 @@ result = client.hyperliquid.orders.history( order_type="limit" ) -# Get order flow aggregation +# Get order flow aggregation: time buckets, oldest first flow = client.hyperliquid.orders.flow( "BTC", - start="2024-01-01", - end="2024-01-02", - interval="1h" + start="2026-07-13T00:00:00Z", + end="2026-07-14T00:00:00Z", + interval="15m", # 1m (default), 5m, 15m, 1h + limit=1000, # buckets (default 1000, max 10000) ) # Get TP/SL history diff --git a/oxarchive/resources/breadth.py b/oxarchive/resources/breadth.py index 7e6c903..be97554 100644 --- a/oxarchive/resources/breadth.py +++ b/oxarchive/resources/breadth.py @@ -8,8 +8,8 @@ from ..http import HttpClient from ..types import BreadthSnapshot, CursorResponse, Timestamp -BreadthInterval = Literal["5m", "15m", "30m", "1h", "4h", "1d"] -BREADTH_INTERVALS = frozenset({"5m", "15m", "30m", "1h", "4h", "1d"}) +BreadthInterval = Literal["1m", "5m", "15m", "30m", "1h", "4h", "1d"] +BREADTH_INTERVALS = frozenset({"1m", "5m", "15m", "30m", "1h", "4h", "1d"}) class BreadthResource: diff --git a/oxarchive/resources/funding.py b/oxarchive/resources/funding.py index 0660077..a1b9b76 100644 --- a/oxarchive/resources/funding.py +++ b/oxarchive/resources/funding.py @@ -62,7 +62,7 @@ def history( end: End timestamp (required) cursor: Cursor from previous response's next_cursor (timestamp) limit: Maximum number of results (default: 100, max: 1000) - interval: Aggregation interval (e.g., '5m', '15m', '30m', '1h', '4h', '1d'). + interval: Aggregation interval (e.g., '1m', '5m', '15m', '30m', '1h', '4h', '1d'). Raw cadence is route-specific: Hyperliquid core is roughly one minute; HIP-3 and Lighter are roughly 10 seconds. HIP-4 has no funding. diff --git a/oxarchive/resources/openinterest.py b/oxarchive/resources/openinterest.py index bc53e3e..e798483 100644 --- a/oxarchive/resources/openinterest.py +++ b/oxarchive/resources/openinterest.py @@ -74,7 +74,7 @@ def history( 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'). + interval: Aggregation interval (e.g., '1m', '5m', '15m', '30m', '1h', '4h', '1d'). Raw cadence is route-specific. HIP-3, HIP-4 outcome-side OI, and Lighter are roughly 10 seconds. diff --git a/oxarchive/resources/orders.py b/oxarchive/resources/orders.py index c68edc4..671dd2c 100644 --- a/oxarchive/resources/orders.py +++ b/oxarchive/resources/orders.py @@ -137,21 +137,28 @@ def flow( start: Timestamp, end: Timestamp, interval: Optional[str] = None, + cursor: Optional[str] = None, limit: Optional[int] = None, **kwargs, ) -> CursorResponse: """ - Get order flow aggregation. + Get order flow aggregation in time buckets. + + Buckets are labelled by their open time in UTC, and buckets with no + events are omitted. A response holds the oldest ``limit`` buckets of + the window. Args: symbol: The symbol (e.g., 'BTC', 'ETH') - start: Start timestamp (required) - end: End timestamp (required) - interval: Aggregation interval (e.g., '1h', '4h', '1d') - limit: Maximum number of results + start: Start timestamp, inclusive (required) + end: End timestamp, exclusive (required) + interval: Bucket width: '1m' (default), '5m', '15m' or '1h' + cursor: Optional resume point, a Unix millisecond timestamp; the + response starts at the first bucket that opens after it + limit: Maximum number of buckets (default: 1000, max: 10000) Returns: - CursorResponse with order flow data and next_cursor for pagination + CursorResponse with order flow buckets """ symbol = self._resolve_symbol(symbol, kwargs) data = self._http.get( @@ -160,6 +167,7 @@ def flow( "start": self._convert_timestamp(start), "end": self._convert_timestamp(end), "interval": interval, + "cursor": cursor, "limit": limit, }, ) @@ -175,6 +183,7 @@ async def aflow( start: Timestamp, end: Timestamp, interval: Optional[str] = None, + cursor: Optional[str] = None, limit: Optional[int] = None, **kwargs, ) -> CursorResponse: @@ -186,6 +195,7 @@ async def aflow( "start": self._convert_timestamp(start), "end": self._convert_timestamp(end), "interval": interval, + "cursor": cursor, "limit": limit, }, ) diff --git a/tests/test_coverage_contract.py b/tests/test_coverage_contract.py index 47bae78..694d26d 100644 --- a/tests/test_coverage_contract.py +++ b/tests/test_coverage_contract.py @@ -4,7 +4,13 @@ import pytest -from oxarchive.exchanges import Hip3Client, Hip4Client, LighterClient, SpotClient +from oxarchive.exchanges import ( + Hip3Client, + Hip4Client, + HyperliquidClient, + LighterClient, + SpotClient, +) from oxarchive.http import HttpClient from oxarchive.resources.l3_orderbook import L3OrderBookResource from oxarchive.types import ( @@ -580,11 +586,11 @@ def test_hip3_breadth_exposes_typed_current_and_history_contract() -> None: with pytest.raises(ValueError, match="1000"): client.breadth.history(limit=1001) with pytest.raises(ValueError, match="interval"): - client.breadth.history(interval=cast(Any, "1m")) + client.breadth.history(interval=cast(Any, "2h")) assert len(http.calls) == 2 -@pytest.mark.parametrize("interval", ["5m", "15m", "30m", "1h", "4h", "1d"]) +@pytest.mark.parametrize("interval", ["1m", "5m", "15m", "30m", "1h", "4h", "1d"]) def test_hip3_breadth_accepts_every_served_history_interval(interval: str) -> None: response = { "data": [_breadth_snapshot()], @@ -640,3 +646,51 @@ def test_hip3_breadth_supports_async_methods_and_null_is_not_zero() -> None: assert history.data[0].value_pct is None assert http.calls[-1][1] is not None assert http.calls[-1][1]["cursor"] == "1788036840000" + + +def _flow_page(next_cursor: str | None) -> dict[str, Any]: + return { + "data": [{"timestamp": "2026-07-13T16:39:00Z", "limit_orders_placed": 3}], + "meta": {"count": 1, "request_id": "flow", "next_cursor": next_cursor}, + } + + +def test_order_flow_forwards_its_cursor_and_returns_next_cursor() -> None: + http = FakeHttp(_flow_page("1783960740000")) + client = HyperliquidClient(cast(HttpClient, http)) + window = {"start": 1783900800000, "end": 1783987200000, "interval": "1m"} + + first = client.orders.flow("BTC", **window) + assert first.next_cursor == "1783960740000" + assert http.calls[-1][0] == "/v1/hyperliquid/orders/BTC/flow" + first_params = http.calls[-1][1] + assert first_params is not None + assert "cursor" not in first_params or first_params["cursor"] is None + + http.response = _flow_page(None) + last = client.orders.flow("BTC", cursor=first.next_cursor, **window) + assert last.next_cursor is None + params = http.calls[-1][1] + assert params is not None + assert params["cursor"] == "1783960740000" + assert params["start"] == 1783900800000 + assert params["end"] == 1783987200000 + assert params["interval"] == "1m" + + +def test_order_flow_cursor_on_hip3_hip4_and_async() -> None: + http = FakeHttp(_flow_page(None)) + window = {"start": 1783900800000, "end": 1783987200000, "cursor": "1783960740000"} + + Hip3Client(cast(HttpClient, http)).orders.flow("km:US500", **window) + asyncio.run(Hip4Client(cast(HttpClient, http)).orders.aflow("0", **window)) + asyncio.run(HyperliquidClient(cast(HttpClient, http)).orders.aflow("BTC", **window)) + + assert [path for path, _ in http.calls] == [ + "/v1/hyperliquid/hip3/orders/km:US500/flow", + "/v1/hyperliquid/hip4/orders/0/flow", + "/v1/hyperliquid/orders/BTC/flow", + ] + for _, params in http.calls: + assert params is not None + assert params["cursor"] == "1783960740000"