Skip to content

Commit 24d00c7

Browse files
userFRMclaude
andcommitted
fix: normalize interval from milliseconds to HH:MM:SS.mmm for gRPC
Users pass interval as milliseconds (e.g. "60000" for 1-minute bars). The MDDS gRPC server expects HH:MM:SS.mmm format for some endpoints. normalize_interval() converts transparently -- no API change. If the value already contains ":" (already formatted), it passes through. If it's not a valid number, it passes through and lets the server reject. Fixes: "Invalid time format: Expected hh:mm:ss.SSS" error when passing millisecond intervals to index OHLC and other endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4c5e5ec commit 24d00c7

3 files changed

Lines changed: 86 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/thetadatadx/src/direct.rs

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -540,14 +540,14 @@ impl DirectClient {
540540
///
541541
/// gRPC: `BetaThetaTerminal/GetStockHistoryOhlc`
542542
///
543-
/// `interval` is in milliseconds (e.g. `"60000"` for 1-minute bars, `"300000"` for 5-minute).
543+
/// `interval` is in milliseconds (e.g. `60000` for 1-minute bars, `300000` for 5-minute).
544544
fn stock_history_ohlc(symbol: &str, date: &str, interval: &str) -> Vec<OhlcTick>;
545545
grpc: get_stock_history_ohlc;
546546
request: StockHistoryOhlcRequest;
547547
query: StockHistoryOhlcRequestQuery {
548548
symbol: symbol.to_string(),
549549
date: Some(date.to_string()),
550-
interval: interval.to_string(),
550+
interval: normalize_interval(interval),
551551
start_time: None,
552552
end_time: None,
553553
venue: None,
@@ -566,7 +566,7 @@ impl DirectClient {
566566
///
567567
/// Uses `start_date`/`end_date` instead of single `date`.
568568
///
569-
/// `interval` is in milliseconds (e.g. `"60000"` for 1-minute bars, `"300000"` for 5-minute).
569+
/// `interval` is in milliseconds (e.g. `60000` for 1-minute bars, `300000` for 5-minute).
570570
fn stock_history_ohlc_range(
571571
symbol: &str, start_date: &str, end_date: &str, interval: &str
572572
) -> Vec<OhlcTick>;
@@ -575,7 +575,7 @@ impl DirectClient {
575575
query: StockHistoryOhlcRequestQuery {
576576
symbol: symbol.to_string(),
577577
date: None,
578-
interval: interval.to_string(),
578+
interval: normalize_interval(interval),
579579
start_time: None,
580580
end_time: None,
581581
venue: None,
@@ -613,14 +613,14 @@ impl DirectClient {
613613
///
614614
/// gRPC: `BetaThetaTerminal/GetStockHistoryQuote`
615615
///
616-
/// `interval` is in milliseconds (e.g. `"60000"` for 1-minute bars, `"300000"` for 5-minute).
616+
/// `interval` is in milliseconds (e.g. `60000` for 1-minute bars, `300000` for 5-minute).
617617
fn stock_history_quote(symbol: &str, date: &str, interval: &str) -> Vec<QuoteTick>;
618618
grpc: get_stock_history_quote;
619619
request: StockHistoryQuoteRequest;
620620
query: StockHistoryQuoteRequestQuery {
621621
symbol: symbol.to_string(),
622622
date: Some(date.to_string()),
623-
interval: interval.to_string(),
623+
interval: normalize_interval(interval),
624624
start_time: None,
625625
end_time: None,
626626
venue: None,
@@ -667,14 +667,14 @@ impl DirectClient {
667667
///
668668
/// gRPC: `BetaThetaTerminal/GetStockHistoryQuote`
669669
///
670-
/// `interval` is in milliseconds (e.g. `"60000"` for 1-minute bars, `"300000"` for 5-minute).
670+
/// `interval` is in milliseconds (e.g. `60000` for 1-minute bars, `300000` for 5-minute).
671671
fn stock_history_quote_stream(symbol: &str, date: &str, interval: &str; handler: F) -> QuoteTick;
672672
grpc: get_stock_history_quote;
673673
request: StockHistoryQuoteRequest;
674674
query: StockHistoryQuoteRequestQuery {
675675
symbol: symbol.to_string(),
676676
date: Some(date.to_string()),
677-
interval: interval.to_string(),
677+
interval: normalize_interval(interval),
678678
start_time: None,
679679
end_time: None,
680680
venue: None,
@@ -1104,7 +1104,7 @@ impl DirectClient {
11041104
///
11051105
/// gRPC: `BetaThetaTerminal/GetOptionHistoryOhlc`
11061106
///
1107-
/// `interval` is in milliseconds (e.g. `"60000"` for 1-minute bars, `"300000"` for 5-minute).
1107+
/// `interval` is in milliseconds (e.g. `60000` for 1-minute bars, `300000` for 5-minute).
11081108
fn option_history_ohlc(
11091109
symbol: &str, expiration: &str, strike: &str, right: &str,
11101110
date: &str, interval: &str
@@ -1115,7 +1115,7 @@ impl DirectClient {
11151115
contract_spec: contract_spec!(symbol, expiration, strike, right),
11161116
date: Some(date.to_string()),
11171117
expiration: expiration.to_string(),
1118-
interval: interval.to_string(),
1118+
interval: normalize_interval(interval),
11191119
start_time: None,
11201120
end_time: None,
11211121
strike_range: None,
@@ -1157,7 +1157,7 @@ impl DirectClient {
11571157
///
11581158
/// gRPC: `BetaThetaTerminal/GetOptionHistoryQuote`
11591159
///
1160-
/// `interval` is in milliseconds (e.g. `"60000"` for 1-minute bars, `"300000"` for 5-minute).
1160+
/// `interval` is in milliseconds (e.g. `60000` for 1-minute bars, `300000` for 5-minute).
11611161
fn option_history_quote(
11621162
symbol: &str, expiration: &str, strike: &str, right: &str,
11631163
date: &str, interval: &str
@@ -1170,7 +1170,7 @@ impl DirectClient {
11701170
expiration: expiration.to_string(),
11711171
start_time: None,
11721172
end_time: None,
1173-
interval: interval.to_string(),
1173+
interval: normalize_interval(interval),
11741174
max_dte: None,
11751175
strike_range: None,
11761176
start_date: None,
@@ -1230,7 +1230,7 @@ impl DirectClient {
12301230
expiration: expiration.to_string(),
12311231
start_time: None,
12321232
end_time: None,
1233-
interval: interval.to_string(),
1233+
interval: normalize_interval(interval),
12341234
max_dte: None,
12351235
strike_range: None,
12361236
start_date: None,
@@ -1340,7 +1340,7 @@ impl DirectClient {
13401340
expiration: expiration.to_string(),
13411341
start_time: None,
13421342
end_time: None,
1343-
interval: interval.to_string(),
1343+
interval: normalize_interval(interval),
13441344
annual_dividend: None,
13451345
rate_type: None,
13461346
rate_value: None,
@@ -1401,7 +1401,7 @@ impl DirectClient {
14011401
expiration: expiration.to_string(),
14021402
start_time: None,
14031403
end_time: None,
1404-
interval: interval.to_string(),
1404+
interval: normalize_interval(interval),
14051405
annual_dividend: None,
14061406
rate_type: None,
14071407
rate_value: None,
@@ -1462,7 +1462,7 @@ impl DirectClient {
14621462
expiration: expiration.to_string(),
14631463
start_time: None,
14641464
end_time: None,
1465-
interval: interval.to_string(),
1465+
interval: normalize_interval(interval),
14661466
annual_dividend: None,
14671467
rate_type: None,
14681468
rate_value: None,
@@ -1523,7 +1523,7 @@ impl DirectClient {
15231523
expiration: expiration.to_string(),
15241524
start_time: None,
15251525
end_time: None,
1526-
interval: interval.to_string(),
1526+
interval: normalize_interval(interval),
15271527
annual_dividend: None,
15281528
rate_type: None,
15291529
rate_value: None,
@@ -1584,7 +1584,7 @@ impl DirectClient {
15841584
expiration: expiration.to_string(),
15851585
start_time: None,
15861586
end_time: None,
1587-
interval: interval.to_string(),
1587+
interval: normalize_interval(interval),
15881588
annual_dividend: None,
15891589
rate_type: None,
15901590
rate_value: None,
@@ -1792,7 +1792,7 @@ impl DirectClient {
17921792
symbol: symbol.to_string(),
17931793
start_date: start_date.to_string(),
17941794
end_date: end_date.to_string(),
1795-
interval: interval.to_string(),
1795+
interval: normalize_interval(interval),
17961796
start_time: None,
17971797
end_time: None,
17981798
};
@@ -1817,7 +1817,7 @@ impl DirectClient {
18171817
symbol: symbol.to_string(),
18181818
start_time: None,
18191819
end_time: None,
1820-
interval: interval.to_string(),
1820+
interval: normalize_interval(interval),
18211821
start_date: None,
18221822
end_date: None,
18231823
};
@@ -1980,6 +1980,33 @@ impl DirectClient {
19801980
// Private helpers
19811981
// ═══════════════════════════════════════════════════════════════════════
19821982

1983+
/// Normalize an interval string to the `HH:MM:SS.mmm` format the MDDS server expects.
1984+
///
1985+
/// Users pass milliseconds as a string (e.g. `"60000"` for 1-minute bars).
1986+
/// The server expects `HH:MM:SS.mmm`. If the input is already in that format
1987+
/// (contains `:`), it's passed through unchanged.
1988+
///
1989+
/// Examples: `"60000"` -> `"00:01:00.000"`, `"900000"` -> `"00:15:00.000"`, `"0"` -> `"00:00:00.000"`
1990+
fn normalize_interval(interval: &str) -> String {
1991+
// If it already contains `:`, assume it's in HH:MM:SS format -- pass through.
1992+
if interval.contains(':') {
1993+
return interval.to_string();
1994+
}
1995+
// Try parsing as milliseconds.
1996+
match interval.parse::<u32>() {
1997+
Ok(ms) => {
1998+
let total_secs = ms / 1000;
1999+
let millis = ms % 1000;
2000+
let h = total_secs / 3600;
2001+
let m = (total_secs % 3600) / 60;
2002+
let s = total_secs % 60;
2003+
format!("{h:02}:{m:02}:{s:02}.{millis:03}")
2004+
}
2005+
// Not a number and not HH:MM:SS -- pass through and let the server reject it.
2006+
Err(_) => interval.to_string(),
2007+
}
2008+
}
2009+
19832010
/// Validate that a date string is in YYYYMMDD format (exactly 8 ASCII digits).
19842011
fn validate_date(date: &str) -> Result<(), Error> {
19852012
if date.len() != 8 || !date.bytes().all(|b| b.is_ascii_digit()) {

examples/test_intervals.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use thetadatadx::{Credentials, DirectConfig, ThetaDataDx};
2+
3+
#[tokio::main]
4+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
5+
let creds = Credentials::from_file("/home/theta-gamma/thetadx/creds.txt")?;
6+
let tdx = ThetaDataDx::connect(&creds, DirectConfig::production()).await?;
7+
println!("Connected: {}\n", tdx.session_uuid());
8+
9+
let tests = vec![
10+
// (description, endpoint, args)
11+
("stock_history_ohlc AAPL '60000'", "stock", "60000"),
12+
("stock_history_ohlc AAPL '1s'", "stock", "1s"),
13+
("stock_history_ohlc AAPL '00:01:00.000'", "stock", "00:01:00.000"),
14+
("index_history_ohlc SPX '60000'", "index", "60000"),
15+
("index_history_ohlc SPX '1s'", "index", "1s"),
16+
("index_history_ohlc SPX '00:01:00.000'", "index", "00:01:00.000"),
17+
("index_history_ohlc SPX '1m'", "index", "1m"),
18+
("index_history_ohlc SPX '60'", "index", "60"),
19+
("index_history_ohlc SPX '1'", "index", "1"),
20+
];
21+
22+
for (desc, endpoint, interval) in tests {
23+
print!("{}: ", desc);
24+
let result = match endpoint {
25+
"stock" => tdx.stock_history_ohlc("AAPL", "20260325", interval).await,
26+
"index" => tdx.index_history_ohlc("SPX", "20260325", "20260325", interval).await,
27+
_ => unreachable!(),
28+
};
29+
match result {
30+
Ok(ticks) => println!("{} ticks", ticks.len()),
31+
Err(e) => println!("ERROR: {}", e),
32+
}
33+
}
34+
35+
Ok(())
36+
}

0 commit comments

Comments
 (0)