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
17 changes: 15 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,19 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com).
`client.hyperliquid.hip3` (keyed by `0x` wallet address), `client.lighter`
and `client.rh_lighter` (keyed by integer account index), with `get()`
(current, or as of a `timestamp`), `history()`, `changes()`, `market()`,
`market_summary()` and `all()`. Hyperliquid and HIP-3 also have
`account()` and `account_history()`. `client.lighter.accounts.by_l1()`
`market_summary()`, `all()`, `account()` and `account_history()`.
`account()` is the clearinghouse summary on Hyperliquid and HIP-3
(`account(address, dex)`) and the account's position aggregates (totals,
long/short value, position count) on Lighter and Robinhood Chain
(`account(account_index)`). `client.lighter.accounts.by_l1()`
finds the Lighter account indices of an L1 address (mainnet only). Lighter
market symbols are case-insensitive: the positions methods send them
uppercase.
- `client.data_quality.positions_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`.
- `From<chrono::NaiveDateTime>` and `From<chrono::NaiveDate>` for
`Timestamp`, converted as UTC.
- Typed models `Position`, `MarketPosition`, `PositionChange`,
`AccountSummary`, `MarketPositionsSummary`, `WalletPositions`,
`LighterL1Accounts` and `LighterL1Account`, and parameter structs
Expand Down Expand Up @@ -88,6 +96,11 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com).
`"1h"`.

### Fixed
- A `Timestamp` string without a time zone is UTC. Before, only RFC 3339
strings with an offset were read; a date alone (`"2026-09-01"`) or a
date-time without an offset (`"2026-09-01T00:00:00"`) silently became
`0`. Both now convert to the UTC instant, and a string of digits is still
Unix milliseconds.
- `freshness()` on every client (and `hip4.get_freshness()`) failed with a
deserialization error: the response carries a `symbol` string next to the
per-data-type entries, and the flattened `data_types` map tried to read it
Expand Down
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1014,13 +1014,14 @@ Spot and HIP-4 positions are not included.
| `market(symbol, params)` | Every open position in one market, largest value first, now or at `hour`; totals in `meta.totals` |
| `market_summary(symbol, params)` | Long/short counts, sizes, values, average entries and top-10 share, now or as an hourly series |
| `all(params)` | Every open position across markets at one committed hour |
| `account(address, dex)` | Current account summary (Hyperliquid and HIP-3) |
| `account_history(address, params)` | Hourly account summaries (Hyperliquid and HIP-3) |
| `account(key, ...)` | Current account summary: the clearinghouse summary on Hyperliquid and HIP-3 (`account(address, dex)`), position aggregates on Lighter (`account(account_index)`) |
| `account_history(key, params)` | Hourly account summaries (on Lighter, one row per hour, at most 744 per page) |
| `client.lighter.accounts.by_l1(l1_address, cursor, limit)` | Lighter account indices owned by an L1 address (mainnet only) |

```rust
use oxarchive::resources::positions::{
GetPositionsParams, MarketPositionsParams, MarketSummaryParams, PositionRangeParams,
AccountHistoryParams, GetPositionsParams, MarketPositionsParams, MarketSummaryParams,
PositionRangeParams,
};

let wallet = "0x0000000000000000000000000000000000000001";
Expand Down Expand Up @@ -1082,6 +1083,11 @@ for a in &accounts.data.accounts {
}
let rh_summary = client.rh_lighter.positions
.market_summary("BTC", Some(MarketSummaryParams::default())).await?;

// Lighter account summary (position aggregates): now, and one row per hour
let lighter_now = client.lighter.positions.account(4521).await?;
let rh_hourly = client.rh_lighter.positions
.account_history(4521, AccountHistoryParams::new("2026-09-01", "2026-09-02")).await?;
```

Semantics:
Expand Down Expand Up @@ -1188,7 +1194,12 @@ let incident = client.data_quality.get_incident("inc-123").await?;

// Latency and SLA
let latency = client.data_quality.latency().await?;
let sla = client.data_quality.sla(None).await?;
let sla = client.data_quality.sla(None, None).await?;

// Account positions freshness, one row per venue
for venue in client.data_quality.positions_freshness().await? {
println!("{} {}: {:?}s old, stale={}", venue.venue, venue.product, venue.live_age_seconds, venue.stale);
}
```

## Web3 Authentication
Expand Down Expand Up @@ -1453,19 +1464,26 @@ faster bursts are rejected with a "Subscription rate limit exceeded" error.

## Timestamp Formats

All time parameters accept the `Timestamp` enum:
All time parameters accept the `Timestamp` enum, sent as Unix milliseconds. A
time without a time zone is UTC: `"2024-01-01"` is midnight UTC,
`"2024-01-01T12:00:00"` is noon UTC, and `chrono::NaiveDateTime` and
`chrono::NaiveDate` convert as UTC. A string with an offset (`Z`, `+02:00`)
keeps it.

```rust
use oxarchive::types::Timestamp;

// Unix milliseconds (i64)
let ts: Timestamp = 1704067200000_i64.into();

// ISO 8601 string
// ISO 8601 string: with an offset, or without one (UTC)
let ts: Timestamp = "2024-01-01T00:00:00Z".into();
let ts: Timestamp = "2024-01-01T12:00:00".into();
let ts: Timestamp = "2024-01-01".into();

// chrono::DateTime<Utc>
// chrono::DateTime<Utc>, or a naive date or date-time (UTC)
let ts: Timestamp = chrono::Utc::now().into();
let ts: Timestamp = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().into();
```

## Error Handling
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ pub use types::{
LighterLiveOrderBook, LighterLiveTrade,
AccountSummary, CumulativeFunding, LighterL1Account, LighterL1Accounts, LighterLiquidation,
LighterLiquidationVolume, MarketPosition, MarketPositionsSummary, MetaResponse, Position,
PositionChange, PositionLeverage, ResponseMeta, WalletPositions,
PositionChange, PositionLeverage, PositionsFreshness, ResponseMeta, WalletPositions,
};
pub use resources::liquidations::{LevelsHistoryParams, LiquidationLevelsParams};
pub use resources::positions::{
Expand Down
29 changes: 18 additions & 11 deletions src/resources/data_quality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use std::time::Duration;
use crate::error::Result;
use crate::http::HttpClient;
use crate::types::{
CoverageResponse, ExchangeCoverage, Incident, IncidentsResponse, LatencyResponse, SlaResponse,
StatusResponse, SymbolCoverageResponse,
CoverageResponse, ExchangeCoverage, Incident, IncidentsResponse, LatencyResponse,
PositionsFreshness, SlaResponse, StatusResponse, SymbolCoverageResponse,
};

/// Timeout for data quality endpoints that aggregate across venue APIs/symbols.
Expand Down Expand Up @@ -34,7 +34,11 @@ impl DataQualityResource {
/// timeout instead of the default 30 seconds.
pub async fn coverage(&self) -> Result<CoverageResponse> {
self.http
.get_with_timeout("/v1/data-quality/coverage", &[], Some(SLOW_ENDPOINT_TIMEOUT))
.get_with_timeout(
"/v1/data-quality/coverage",
&[],
Some(SLOW_ENDPOINT_TIMEOUT),
)
.await
}

Expand All @@ -60,10 +64,7 @@ impl DataQualityResource {
}

/// List data incidents.
pub async fn list_incidents(
&self,
status: Option<&str>,
) -> Result<IncidentsResponse> {
pub async fn list_incidents(&self, status: Option<&str>) -> Result<IncidentsResponse> {
let mut qp = vec![];
if let Some(s) = status {
qp.push(("status", s.to_string()));
Expand All @@ -74,10 +75,7 @@ impl DataQualityResource {
/// Get a single incident by ID.
pub async fn get_incident(&self, incident_id: &str) -> Result<Incident> {
self.http
.get(
&format!("/v1/data-quality/incidents/{}", incident_id),
&[],
)
.get(&format!("/v1/data-quality/incidents/{}", incident_id), &[])
.await
}

Expand All @@ -103,4 +101,13 @@ impl DataQualityResource {
.get_with_timeout("/v1/data-quality/sla", &qp, Some(SLOW_ENDPOINT_TIMEOUT))
.await
}

/// Get the freshness of the account positions data, one row 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.
pub async fn positions_freshness(&self) -> Result<Vec<PositionsFreshness>> {
self.http.get("/v1/data-quality/positions", &[]).await
}
}
39 changes: 36 additions & 3 deletions src/resources/positions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ impl PositionRangeParams {
}
}

/// Time range for [`PositionsResource::account_history`]: `[start, end)`.
/// Time range for [`PositionsResource::account_history`] and
/// [`LighterPositionsResource::account_history`]: `[start, end)`.
#[derive(Debug, Clone)]
pub struct AccountHistoryParams {
/// Inclusive start.
Expand All @@ -94,7 +95,8 @@ pub struct AccountHistoryParams {
pub dex: Option<String>,
/// Cursor from the previous page's `next_cursor`.
pub cursor: Option<String>,
/// Rows per page (default 500, maximum 5,000).
/// Rows per page (default 500, maximum 5,000; on Lighter, hours per page,
/// at most 744).
pub limit: Option<i64>,
}

Expand Down Expand Up @@ -476,7 +478,8 @@ impl PositionsResource {

/// Account positions on Lighter (`client.lighter.positions`) and Lighter on
/// Robinhood Chain (`client.rh_lighter.positions`), keyed by integer account
/// index. Perp markets only.
/// index. Perp markets only. `account()` and `account_history()` return the
/// account's position aggregates.
///
/// Coverage: mainnet from 2025-01-17 and Robinhood Chain from 2026-06-26,
/// with hourly snapshots over the same span and a live snapshot every 2
Expand Down Expand Up @@ -561,6 +564,36 @@ impl LighterPositionsResource {
fetch(&self.http, &path, &range_query(&params)).await
}

/// Position aggregates of an account at the latest live snapshot (one
/// row): `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.
pub async fn account(&self, account_index: u64) -> Result<MetaResponse<Vec<AccountSummary>>> {
let path = format!("{}/accounts/{}/account", self.prefix, account_index);
fetch(&self.http, &path, &Query::new()).await
}

/// Hourly position aggregates of an account in `[start, end)`, one row
/// per hourly snapshot stamped with `snapshot_ts` (an hour with no open
/// position has zero totals). `limit` is hours per page (default 500, at
/// most 744). The Lighter routes take no `dex`.
pub async fn account_history(
&self,
account_index: u64,
params: AccountHistoryParams,
) -> Result<MetaResponse<Vec<AccountSummary>>> {
Self::check_no_dex(params.dex.as_ref())?;
let mut qp = vec![
("start", params.start.to_millis().to_string()),
("end", params.end.to_millis().to_string()),
];
push_opt(&mut qp, "cursor", params.cursor);
push_opt(&mut qp, "limit", params.limit.map(|l| l.to_string()));
let path = format!("{}/accounts/{}/account/history", self.prefix, account_index);
fetch(&self.http, &path, &qp).await
}

/// Every open position in one market, largest position value first, at
/// the latest live snapshot or at `params.hour`.
///
Expand Down
96 changes: 92 additions & 4 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@ impl<T> MetaResponse<T> {
// ---------------------------------------------------------------------------

/// A flexible timestamp that can be specified as Unix milliseconds, an ISO-8601
/// string, or a `chrono::DateTime`.
/// string, or a `chrono` date or time.
///
/// A time without a time zone is UTC: `"2026-09-01"` is midnight UTC,
/// `"2026-09-01T12:00:00"` is noon UTC, and a `chrono::NaiveDateTime` or
/// `chrono::NaiveDate` converts as UTC. A string with an offset (`Z`,
/// `+02:00`) keeps it, and a string of digits is Unix milliseconds.
#[derive(Debug, Clone)]
pub enum Timestamp {
Millis(i64),
Expand All @@ -228,15 +233,46 @@ pub enum Timestamp {

impl Timestamp {
/// Convert to Unix milliseconds for use in query parameters.
///
/// A string that is not a timestamp converts to `0`.
pub fn to_millis(&self) -> i64 {
match self {
Timestamp::Millis(ms) => *ms,
Timestamp::DateTime(dt) => dt.timestamp_millis(),
Timestamp::Iso(s) => chrono::DateTime::parse_from_rfc3339(s)
.map(|dt| dt.timestamp_millis())
.unwrap_or_else(|_| s.parse::<i64>().unwrap_or(0)),
Timestamp::Iso(s) => parse_timestamp_str(s).unwrap_or(0),
}
}
}

/// Unix milliseconds of a timestamp string: digits, RFC 3339 with an offset,
/// or an ISO 8601 date-time or date without one (read as UTC).
fn parse_timestamp_str(s: &str) -> Option<i64> {
let s = s.trim();
if let Ok(ms) = s.parse::<i64>() {
return Some(ms);
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(dt.timestamp_millis());
}
for format in [
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d %H:%M",
] {
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, format) {
return Some(utc(naive).timestamp_millis());
}
}
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.ok()
.and_then(|date| date.and_hms_opt(0, 0, 0))
.map(|naive| utc(naive).timestamp_millis())
}

/// A date-time without a time zone, read as UTC.
fn utc(naive: chrono::NaiveDateTime) -> chrono::DateTime<chrono::Utc> {
chrono::TimeZone::from_utc_datetime(&chrono::Utc, &naive)
}

impl From<i64> for Timestamp {
Expand All @@ -263,6 +299,20 @@ impl From<chrono::DateTime<chrono::Utc>> for Timestamp {
}
}

/// A date-time without a time zone is UTC.
impl From<chrono::NaiveDateTime> for Timestamp {
fn from(naive: chrono::NaiveDateTime) -> Self {
Timestamp::DateTime(utc(naive))
}
}

/// A date is midnight UTC.
impl From<chrono::NaiveDate> for Timestamp {
fn from(date: chrono::NaiveDate) -> Self {
Timestamp::DateTime(utc(date.and_hms_opt(0, 0, 0).unwrap_or_default()))
}
}

// ---------------------------------------------------------------------------
// Orderbook
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1943,6 +1993,44 @@ pub struct LighterL1Accounts {
pub accounts: Vec<LighterL1Account>,
}

/// Freshness of the account positions data of one venue, from
/// `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`). Instants are
/// RFC 3339 UTC strings.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PositionsFreshness {
/// `hyperliquid`, `lighter` or `rh_lighter`.
pub venue: String,
/// `core`, `hip3`, `lighter` or `rh_lighter`.
pub product: String,
/// Time of the latest live snapshot.
#[serde(default)]
pub live_snapshot_ts: Option<String>,
/// Age of the latest live snapshot, in seconds.
#[serde(default)]
pub live_age_seconds: Option<i64>,
/// `true` when the latest live snapshot is older than 12 minutes (or
/// there is none).
pub stale: bool,
/// Quality of the latest live snapshot: `complete`, `partial` or
/// `degraded`.
#[serde(default)]
pub live_quality: Option<String>,
/// Hour of the latest hourly snapshot.
#[serde(default)]
pub hourly_snapshot_ts: Option<String>,
/// Every event before this instant is built into the change log and the
/// as-of state.
#[serde(default)]
pub built_through: Option<String>,
/// Every event before this instant is final and will not be re-derived.
#[serde(default)]
pub finalized_through: Option<String>,
}

#[cfg(test)]
mod lighter_live_payload_tests {
use super::LighterLiveData;
Expand Down
Loading
Loading