Skip to content
Open
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
1 change: 1 addition & 0 deletions src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
18 changes: 15 additions & 3 deletions src/model/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
162 changes: 162 additions & 0 deletions src/model/serde_helpers.rs
Original file line number Diff line number Diff line change
@@ -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<T>` 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<i64, D::Error>
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::<i64>() {
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::<Wrap>(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);
}
}
41 changes: 39 additions & 2 deletions src/model/trade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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<Transaction>` 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<Transaction>` with a mix of epoch-ms and
/// string-formatted entries must decode end-to-end. This is the
/// exact shape `call_into_items::<Transaction>` 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<Transaction> = 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);
}
}