From 7aea30852c4ee1c9548c0520c50c7e17a4d2d49e Mon Sep 17 00:00:00 2001 From: Jem Date: Sun, 10 May 2026 16:26:46 +0700 Subject: [PATCH] Lenient timestamp deserializer for Transaction + Order time fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiger's paper /order_transactions endpoint has been observed returning `transactedAt` and `time` as "YYYY-MM-DD HH:MM:SS" naive datetime strings instead of epoch milliseconds. The strict `i64` typing in v0.3.0 makes `Vec` decode fail on the first such row, breaking every call site that lists transactions: invalid type: string "2026-05-08 22:57:14", expected i64 This patch adds `model::serde_helpers::deserialize_lenient_timestamp` which accepts: - JSON number (epoch ms) — pass through - numeric string ("1700000000000") - naive datetime ("YYYY-MM-DD HH:MM:SS") — interpreted as UTC - RFC 3339 ("2026-05-08T22:57:14Z" or with offset) - null / empty string / unknown shape — fall back to 0 + tracing::warn Applied to: - Transaction.transacted_at, Transaction.time (the actual bug) - Order.open_time, Order.update_time, Order.latest_time (defensive — Tiger may flip these to strings on a future release) Naive strings without timezone are interpreted as UTC; Tiger does not document the actual TZ for these strings, but UTC is consistent with the rest of Tiger's epoch fields (also UTC) so relative ordering within the events log is preserved. +10 deserializer tests (every shape) + 2 integration tests covering Vec with mixed shapes. Total fork tests: 241 pass. --- src/model/mod.rs | 1 + src/model/order.rs | 18 ++++- src/model/serde_helpers.rs | 162 +++++++++++++++++++++++++++++++++++++ src/model/trade.rs | 41 +++++++++- 4 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/model/serde_helpers.rs diff --git a/src/model/mod.rs b/src/model/mod.rs index 0d5e93b..2ccc100 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -6,6 +6,7 @@ pub mod order; pub mod position; pub mod quote; pub mod quote_requests; +pub mod serde_helpers; pub mod trade; pub mod trade_requests; pub use quote_requests::*; diff --git a/src/model/order.rs b/src/model/order.rs index d9f103d..27538f9 100644 --- a/src/model/order.rs +++ b/src/model/order.rs @@ -98,11 +98,23 @@ pub struct Order { pub commission: f64, #[serde(default)] pub realized_pnl: f64, - #[serde(default)] + // Defensive: Tiger has been observed returning these as + // "YYYY-MM-DD HH:MM:SS" strings on the transactions endpoint; + // accept the same shape here in case Order ever follows. + #[serde( + default, + deserialize_with = "crate::model::serde_helpers::deserialize_lenient_timestamp" + )] pub open_time: i64, - #[serde(default)] + #[serde( + default, + deserialize_with = "crate::model::serde_helpers::deserialize_lenient_timestamp" + )] pub update_time: i64, - #[serde(default)] + #[serde( + default, + deserialize_with = "crate::model::serde_helpers::deserialize_lenient_timestamp" + )] pub latest_time: i64, #[serde(default)] pub remark: String, diff --git a/src/model/serde_helpers.rs b/src/model/serde_helpers.rs new file mode 100644 index 0000000..11b7598 --- /dev/null +++ b/src/model/serde_helpers.rs @@ -0,0 +1,162 @@ +//! Lenient deserializers for Tiger API quirks. +//! +//! Tiger's wire format is *mostly* numeric epoch-millisecond timestamps, +//! but some endpoints occasionally return human-readable date strings +//! ("YYYY-MM-DD HH:MM:SS", no timezone) — observed in practice from +//! `/order_transactions` for paper accounts. Without a permissive +//! deserializer the whole response decode fails on the first such row. +//! +//! `deserialize_lenient_timestamp` accepts any of: +//! - JSON number: passed through (or `f64 → i64` truncation if needed) +//! - JSON null: `0` +//! - String of digits ("1747353034000"): parsed as i64 +//! - Naive datetime "YYYY-MM-DD HH:MM:SS": parsed as **UTC**, returned +//! as epoch millis. Tiger does not document the actual timezone for +//! these strings; UTC is a defensible default for ordering / +//! display since the rest of Tiger's epoch fields are also UTC. +//! - RFC 3339 ("2026-05-08T22:57:14Z" or with offset): parsed +//! +//! Anything else (object, array, garbled string) deserializes to `0` +//! and emits a `tracing::warn!`. The fallback keeps a single bad row +//! from poisoning a whole `Vec` decode. +//! +//! Naive datetimes are converted via `NaiveDateTime::and_utc()` +//! (chrono 0.4.31+ — already required transitively by tigeropen's +//! existing chrono dep). + +use serde::{Deserialize, Deserializer}; +use serde_json::Value; + +pub(crate) fn deserialize_lenient_timestamp<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + Ok(parse_lenient_timestamp(&value)) +} + +fn parse_lenient_timestamp(value: &Value) -> i64 { + match value { + Value::Null => 0, + Value::Number(n) => n + .as_i64() + .unwrap_or_else(|| n.as_f64().map(|f| f as i64).unwrap_or(0)), + Value::String(s) => { + let s = s.trim(); + if s.is_empty() { + return 0; + } + if let Ok(n) = s.parse::() { + return n; + } + if let Ok(dt) = + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + { + return dt.and_utc().timestamp_millis(); + } + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { + return dt.timestamp_millis(); + } + tracing::warn!( + value = %s, + "lenient_timestamp: unrecognized string shape; defaulting to 0" + ); + 0 + } + other => { + tracing::warn!( + ?other, + "lenient_timestamp: unexpected JSON shape; defaulting to 0" + ); + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Deserialize, Debug, PartialEq)] + struct Wrap { + #[serde(deserialize_with = "deserialize_lenient_timestamp")] + ts: i64, + } + + fn parse(s: &str) -> i64 { + serde_json::from_str::(s).unwrap().ts + } + + #[test] + fn integer_passes_through() { + assert_eq!(parse(r#"{"ts": 1700000000000}"#), 1_700_000_000_000); + assert_eq!(parse(r#"{"ts": 0}"#), 0); + assert_eq!(parse(r#"{"ts": -1}"#), -1); + } + + #[test] + fn float_truncates_to_i64() { + // serde_json may emit i64 as f64 in some configs. + assert_eq!(parse(r#"{"ts": 1700000000000.0}"#), 1_700_000_000_000); + assert_eq!(parse(r#"{"ts": 1700000000000.5}"#), 1_700_000_000_000); + } + + #[test] + fn null_becomes_zero() { + assert_eq!(parse(r#"{"ts": null}"#), 0); + } + + #[test] + fn numeric_string_parses() { + assert_eq!(parse(r#"{"ts": "1700000000000"}"#), 1_700_000_000_000); + assert_eq!(parse(r#"{"ts": " 1700000000000 "}"#), 1_700_000_000_000); + } + + #[test] + fn naive_datetime_parses_as_utc() { + // The exact string observed from Tiger paper: + // "2026-05-08 22:57:14" + // 2026-05-08 22:57:14 UTC ≡ 1778281034 epoch seconds + // = 1_778_281_034_000 epoch millis + let got = parse(r#"{"ts": "2026-05-08 22:57:14"}"#); + assert_eq!(got, 1_778_281_034_000); + } + + #[test] + fn rfc3339_z_parses() { + assert_eq!( + parse(r#"{"ts": "2026-05-08T22:57:14Z"}"#), + 1_778_281_034_000 + ); + } + + #[test] + fn rfc3339_with_offset_normalizes_to_utc() { + // 2026-05-08 22:57:14 +08:00 == 2026-05-08 14:57:14 UTC + // 14:57:14 = 53834 s into the day; 20581*86400 + 53834 = 1778252234 + assert_eq!( + parse(r#"{"ts": "2026-05-08T22:57:14+08:00"}"#), + 1_778_252_234_000 + ); + } + + #[test] + fn empty_string_becomes_zero() { + assert_eq!(parse(r#"{"ts": ""}"#), 0); + assert_eq!(parse(r#"{"ts": " "}"#), 0); + } + + #[test] + fn unknown_string_falls_back_to_zero() { + assert_eq!(parse(r#"{"ts": "not a date"}"#), 0); + assert_eq!(parse(r#"{"ts": "2026-13-45"}"#), 0); + } + + #[test] + fn unexpected_shape_falls_back_to_zero() { + assert_eq!(parse(r#"{"ts": []}"#), 0); + assert_eq!(parse(r#"{"ts": {}}"#), 0); + assert_eq!(parse(r#"{"ts": true}"#), 0); + } +} diff --git a/src/model/trade.rs b/src/model/trade.rs index 12612f3..551c9b0 100644 --- a/src/model/trade.rs +++ b/src/model/trade.rs @@ -229,9 +229,18 @@ pub struct Transaction { pub amount: f64, #[serde(default)] pub commission: f64, - #[serde(default)] + // Tiger paper has been observed returning these as + // "YYYY-MM-DD HH:MM:SS" date strings instead of epoch ms. + // The lenient deserializer accepts both shapes. + #[serde( + default, + deserialize_with = "crate::model::serde_helpers::deserialize_lenient_timestamp" + )] pub transacted_at: i64, - #[serde(default)] + #[serde( + default, + deserialize_with = "crate::model::serde_helpers::deserialize_lenient_timestamp" + )] pub time: i64, } @@ -560,4 +569,32 @@ mod tests { assert_eq!(t.sec_type, "STK"); assert_eq!(t.price, 150.0); } + + /// Regression: Tiger paper has been observed returning `transactedAt` + /// and `time` as "YYYY-MM-DD HH:MM:SS" strings (no timezone) instead + /// of epoch ms. Without the lenient deserializer, decoding a + /// `Vec` with one such entry fails the entire batch + /// with `invalid type: string ..., expected i64`. + #[test] + fn test_transaction_string_timestamp_deserializes() { + let json = r#"{"id":1,"orderId":2,"symbol":"AAPL","secType":"STK","price":150.0,"quantity":100,"filledQuantity":100,"transactedAt":"2026-05-08 22:57:14","time":"2026-05-08 22:57:14"}"#; + let t: Transaction = serde_json::from_str(json).unwrap(); + assert_eq!(t.transacted_at, 1_778_281_034_000); + assert_eq!(t.time, 1_778_281_034_000); + } + + /// Regression: a `Vec` with a mix of epoch-ms and + /// string-formatted entries must decode end-to-end. This is the + /// exact shape `call_into_items::` chokes on. + #[test] + fn test_vec_transaction_mixed_timestamp_shapes() { + let json = r#"[ + {"id":1,"orderId":10,"transactedAt":1700000000000,"time":1700000000000}, + {"id":2,"orderId":11,"transactedAt":"2026-05-08 22:57:14","time":"2026-05-08 22:57:14"} + ]"#; + let v: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(v.len(), 2); + assert_eq!(v[0].transacted_at, 1_700_000_000_000); + assert_eq!(v[1].transacted_at, 1_778_281_034_000); + } }