From ecba94042e248dfa6bcabd6c28e48fe4d97caaab Mon Sep 17 00:00:00 2001 From: 0xFantomMenace <93462858+0xFantomMenace@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:12:18 +0000 Subject: [PATCH] Times without a time zone are UTC; Lighter account summaries; positions freshness - Timestamp strings without an offset and dates alone convert as UTC; before, they silently became 0. From and From for Timestamp, as UTC. - LighterPositionsResource: account() and account_history() (position aggregates, now and hourly); a dex is refused before sending. - DataQualityResource::positions_freshness() and PositionsFreshness. - README: the sla() example passes both arguments. --- CHANGELOG.md | 17 ++- README.md | 32 ++++-- src/lib.rs | 2 +- src/resources/data_quality.rs | 29 +++-- src/resources/positions.rs | 39 ++++++- src/types.rs | 96 +++++++++++++++- tests/rh_lighter_and_positions.rs | 179 ++++++++++++++++++++++++++++++ 7 files changed, 366 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be9992..0b5a155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` and `From` for + `Timestamp`, converted as UTC. - Typed models `Position`, `MarketPosition`, `PositionChange`, `AccountSummary`, `MarketPositionsSummary`, `WalletPositions`, `LighterL1Accounts` and `LighterL1Account`, and parameter structs @@ -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 diff --git a/README.md b/README.md index 0c8eb46..e80c393 100644 --- a/README.md +++ b/README.md @@ -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"; @@ -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: @@ -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 @@ -1453,7 +1464,11 @@ 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; @@ -1461,11 +1476,14 @@ 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 +// chrono::DateTime, 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 diff --git a/src/lib.rs b/src/lib.rs index 47164c5..2f16366 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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::{ diff --git a/src/resources/data_quality.rs b/src/resources/data_quality.rs index 1fa7bdb..0a0a697 100644 --- a/src/resources/data_quality.rs +++ b/src/resources/data_quality.rs @@ -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. @@ -34,7 +34,11 @@ impl DataQualityResource { /// timeout instead of the default 30 seconds. pub async fn coverage(&self) -> Result { 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 } @@ -60,10 +64,7 @@ impl DataQualityResource { } /// List data incidents. - pub async fn list_incidents( - &self, - status: Option<&str>, - ) -> Result { + pub async fn list_incidents(&self, status: Option<&str>) -> Result { let mut qp = vec![]; if let Some(s) = status { qp.push(("status", s.to_string())); @@ -74,10 +75,7 @@ impl DataQualityResource { /// Get a single incident by ID. pub async fn get_incident(&self, incident_id: &str) -> Result { self.http - .get( - &format!("/v1/data-quality/incidents/{}", incident_id), - &[], - ) + .get(&format!("/v1/data-quality/incidents/{}", incident_id), &[]) .await } @@ -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> { + self.http.get("/v1/data-quality/positions", &[]).await + } } diff --git a/src/resources/positions.rs b/src/resources/positions.rs index f2062f4..5be8d77 100644 --- a/src/resources/positions.rs +++ b/src/resources/positions.rs @@ -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. @@ -94,7 +95,8 @@ pub struct AccountHistoryParams { pub dex: Option, /// Cursor from the previous page's `next_cursor`. pub cursor: Option, - /// 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, } @@ -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 @@ -561,6 +564,36 @@ impl LighterPositionsResource { fetch(&self.http, &path, &range_query(¶ms)).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>> { + 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>> { + 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`. /// diff --git a/src/types.rs b/src/types.rs index b509362..a26069d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -218,7 +218,12 @@ impl MetaResponse { // --------------------------------------------------------------------------- /// 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), @@ -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::().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 { + let s = s.trim(); + if let Ok(ms) = s.parse::() { + 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::TimeZone::from_utc_datetime(&chrono::Utc, &naive) } impl From for Timestamp { @@ -263,6 +299,20 @@ impl From> for Timestamp { } } +/// A date-time without a time zone is UTC. +impl From for Timestamp { + fn from(naive: chrono::NaiveDateTime) -> Self { + Timestamp::DateTime(utc(naive)) + } +} + +/// A date is midnight UTC. +impl From for Timestamp { + fn from(date: chrono::NaiveDate) -> Self { + Timestamp::DateTime(utc(date.and_hms_opt(0, 0, 0).unwrap_or_default())) + } +} + // --------------------------------------------------------------------------- // Orderbook // --------------------------------------------------------------------------- @@ -1943,6 +1993,44 @@ pub struct LighterL1Accounts { pub accounts: Vec, } +/// 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, + /// Age of the latest live snapshot, in seconds. + #[serde(default)] + pub live_age_seconds: Option, + /// `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, + /// Hour of the latest hourly snapshot. + #[serde(default)] + pub hourly_snapshot_ts: Option, + /// Every event before this instant is built into the change log and the + /// as-of state. + #[serde(default)] + pub built_through: Option, + /// Every event before this instant is final and will not be re-derived. + #[serde(default)] + pub finalized_through: Option, +} + #[cfg(test)] mod lighter_live_payload_tests { use super::LighterLiveData; diff --git a/tests/rh_lighter_and_positions.rs b/tests/rh_lighter_and_positions.rs index 6402e8f..b3bdff2 100644 --- a/tests/rh_lighter_and_positions.rs +++ b/tests/rh_lighter_and_positions.rs @@ -1198,6 +1198,185 @@ async fn lighter_and_rh_lighter_positions_are_keyed_by_account_index() { } } +#[tokio::test] +async fn lighter_account_summaries_on_both_deployments() { + let server = MockServer::start().await; + for prefix in ["/v1/lighter", "/v1/rh-lighter"] { + Mock::given(method("GET")) + .and(path(format!("{prefix}/accounts/7/account"))) + .respond_with(ok( + json!([{ + "account_index": "7", + "total_position_value": "21090.875", + "total_unrealized_pnl": "90.75", + "long_value": "21090.875", + "short_value": "0", + "n_positions": 1, + "quality": "complete" + }]), + json!({ + "count": 1, + "request_id": "la", + "as_of": "2026-09-28T22:55:39.000Z", + "snapshot_ts": "2026-09-28T22:55:39.000Z", + "source": "snapshot", + "quality": "complete", + "stale": false + }), + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("{prefix}/accounts/7/account/history"))) + .and(query_param("start", "1788220800000")) + .and(query_param("end", "1788264000000")) + .and(query_param("limit", "2")) + .and(query_param_is_missing("dex")) + .respond_with(ok( + json!([ + { + "snapshot_ts": "2026-09-01T00:00:00Z", + "account_index": "7", + "total_position_value": "0", + "total_unrealized_pnl": "0", + "long_value": "0", + "short_value": "0", + "n_positions": 0, + "quality": "complete" + } + ]), + json!({"count": 1, "request_id": "lh", "next_cursor": "h2", "source": "snapshot"}), + )) + .expect(1) + .mount(&server) + .await; + } + + let c = client(&server); + for positions in [&c.lighter.positions, &c.rh_lighter.positions] { + let now = positions.account(7).await.unwrap(); + let row = &now.data[0]; + assert_eq!(row.account_index.as_deref(), Some("7")); + assert_eq!(row.n_positions, 1); + assert_eq!(row.account_value, None); + assert_eq!(now.meta.source.as_deref(), Some("snapshot")); + assert_eq!(now.meta.stale, Some(false)); + + // Times without a time zone are UTC. + let hours = positions + .account_history( + 7, + AccountHistoryParams { + limit: Some(2), + ..AccountHistoryParams::new("2026-09-01", "2026-09-01T12:00:00") + }, + ) + .await + .unwrap(); + assert_eq!( + hours.data[0].snapshot_ts.as_deref(), + Some("2026-09-01T00:00:00Z") + ); + assert_eq!(hours.data[0].n_positions, 0); + assert_eq!(hours.next_cursor.as_deref(), Some("h2")); + + let dex = positions + .account_history( + 7, + AccountHistoryParams { + dex: Some("xyz".into()), + ..AccountHistoryParams::new(1_i64, 2_i64) + }, + ) + .await + .unwrap_err(); + assert!(matches!(dex, Error::InvalidParam(_))); + } +} + +#[tokio::test] +async fn data_quality_reports_positions_freshness_per_venue() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/data-quality/positions")) + .respond_with(ok( + json!([ + { + "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": null, + "live_age_seconds": null, + "stale": true, + "live_quality": null, + "hourly_snapshot_ts": null, + "built_through": null, + "finalized_through": null + } + ]), + meta(2), + )) + .expect(1) + .mount(&server) + .await; + + let venues = client(&server) + .data_quality + .positions_freshness() + .await + .unwrap(); + assert_eq!(venues.len(), 2); + assert_eq!( + (venues[0].venue.as_str(), venues[0].product.as_str()), + ("hyperliquid", "core") + ); + assert_eq!(venues[0].live_age_seconds, Some(119)); + assert_eq!( + venues[0].finalized_through.as_deref(), + Some("2026-09-28T19:00:00.000Z") + ); + assert!(venues[1].stale); + assert_eq!(venues[1].live_snapshot_ts, None); +} + +#[test] +fn times_without_a_time_zone_are_utc() { + const SEPT_1_UTC: i64 = 1_788_220_800_000; + for value in [ + "2026-09-01", + "2026-09-01T00:00:00", + "2026-09-01T00:00:00.000", + "2026-09-01 00:00:00", + "2026-09-01T00:00", + "2026-09-01T00:00:00Z", + "2026-09-01T00:00:00+00:00", + "2026-08-31T20:00:00-04:00", + "1788220800000", + " 1788220800000 ", + ] { + assert_eq!(Timestamp::from(value).to_millis(), SEPT_1_UTC, "{value}"); + } + let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(); + assert_eq!(Timestamp::from(date).to_millis(), SEPT_1_UTC); + let noon = date.and_hms_milli_opt(12, 0, 0, 123).unwrap(); + assert_eq!( + Timestamp::from(noon).to_millis(), + SEPT_1_UTC + 12 * 3_600_000 + 123 + ); + assert_eq!(Timestamp::from("not a time").to_millis(), 0); +} + #[tokio::test] async fn lighter_accounts_resolve_an_l1_address() { let server = MockServer::start().await;