Skip to content

Commit 967e0cd

Browse files
committed
fix(mdds): align interval normalisation and validator with upstream
normalize_interval now maps `"0"` to `"tick"` instead of `"100ms"`. The historical mapping contradicted the previously-documented every-event semantics — the upstream enum spells that as `tick`, and the SDK silently downgraded user requests to a 100ms sample without warning. Mapping `0` to `tick` makes the behaviour match the doc without breaking callers that picked an interval explicitly. The small-ms snap range now also exposes `10ms`, which is the upstream preset between `tick` and `100ms` that the SDK previously skipped. validate_interval (CLI / MCP entry point) now enforces the upstream enum verbatim plus the SDK's millisecond shorthand. Garbage strings like `"twosec"` are rejected up front with a typed error listing the accepted values, instead of being passed through to the gRPC layer where they used to surface as opaque server-side rejections. Mirrors the upstream operation specs at `docs.thetadata.us/operations/option_history_quote.html` (and the other 14 endpoints that carry an `interval` parameter).
1 parent 5dc73e4 commit 967e0cd

2 files changed

Lines changed: 87 additions & 12 deletions

File tree

crates/thetadatadx/src/mdds/endpoints.rs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,24 +99,35 @@ impl From<&[String]> for SymbolInput {
9999
/// Convert an interval to the format the MDDS gRPC server accepts.
100100
///
101101
/// Users can pass either:
102-
/// - Milliseconds as a string: `"60000"`, `"300000"`, `"900000"`
103-
/// - Shorthand directly: `"1m"`, `"5m"`, `"1h"`
102+
/// - Shorthand directly: `"tick"`, `"10ms"`, `"100ms"`, `"500ms"`,
103+
/// `"1s"`, `"5s"`, `"10s"`, `"15s"`, `"30s"`, `"1m"`, `"5m"`,
104+
/// `"10m"`, `"15m"`, `"30m"`, `"1h"`.
105+
/// - Milliseconds as a string (e.g. `"60000"` or `"300000"`). Values
106+
/// are snapped to the nearest documented preset.
104107
///
105-
/// The server accepts these specific presets:
106-
/// `100ms`, `500ms`, `1s`, `5s`, `10s`, `15s`, `30s`, `1m`, `5m`, `10m`,
107-
/// `15m`, `30m`, `1h`.
108+
/// The full upstream enum is reproduced verbatim in
109+
/// `docs.thetadata.us/operations/option_history_quote.html` (and every
110+
/// other endpoint with an `interval` parameter).
108111
///
109-
/// If milliseconds are passed, they're converted to the nearest matching
110-
/// preset. If already a valid shorthand (contains 's', 'm', or 'h'), the
111-
/// value is passed through as-is.
112+
/// The historical SDK accepted `"0"` and silently mapped it to
113+
/// `"100ms"`. That contradicted the previously-documented behaviour
114+
/// (`"0"` was advertised as "every quote change"), so `"0"` now snaps
115+
/// to `"tick"` — which is the upstream every-event vocabulary. Calls
116+
/// that depended on the silent-100ms behaviour should switch to an
117+
/// explicit preset.
112118
fn normalize_interval(interval: &str) -> String {
113119
if interval.ends_with('s') || interval.ends_with('m') || interval.ends_with('h') {
114120
return interval.to_string();
115121
}
122+
if interval == "tick" {
123+
return "tick".to_string();
124+
}
116125

117126
match interval.parse::<u64>() {
118127
Ok(ms) => match ms {
119-
0..=100 => "100ms".to_string(),
128+
0 => "tick".to_string(),
129+
1..=10 => "10ms".to_string(),
130+
11..=100 => "100ms".to_string(),
120131
101..=500 => "500ms".to_string(),
121132
501..=1000 => "1s".to_string(),
122133
1_001..=5_000 => "5s".to_string(),
@@ -269,6 +280,8 @@ mod tests {
269280

270281
#[test]
271282
fn normalize_interval_passes_shorthand_through() {
283+
assert_eq!(normalize_interval("tick"), "tick");
284+
assert_eq!(normalize_interval("10ms"), "10ms");
272285
assert_eq!(normalize_interval("1m"), "1m");
273286
assert_eq!(normalize_interval("5m"), "5m");
274287
assert_eq!(normalize_interval("1h"), "1h");
@@ -281,4 +294,22 @@ mod tests {
281294
assert_eq!(normalize_interval("900000"), "15m");
282295
assert_eq!(normalize_interval("3600000"), "1h");
283296
}
297+
298+
#[test]
299+
fn normalize_interval_snaps_zero_to_tick() {
300+
// The historical mapping was `0 -> 100ms`, which contradicted
301+
// the previously-documented every-event semantics. The upstream
302+
// `tick` keyword is the every-event vocabulary, so the zero
303+
// sentinel snaps to it instead.
304+
assert_eq!(normalize_interval("0"), "tick");
305+
}
306+
307+
#[test]
308+
fn normalize_interval_snaps_small_milliseconds_to_documented_presets() {
309+
// 10ms-and-under -> 10ms; 11-100ms -> 100ms. The earlier
310+
// 0..=100 -> 100ms mapping skipped the 10ms preset entirely.
311+
assert_eq!(normalize_interval("10"), "10ms");
312+
assert_eq!(normalize_interval("11"), "100ms");
313+
assert_eq!(normalize_interval("100"), "100ms");
314+
}
284315
}

crates/thetadatadx/src/mdds/validate.rs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,40 @@ pub(crate) fn validate_symbol(value: &str, param_name: &str) -> Result<(), Endpo
8888
Ok(())
8989
}
9090

91+
/// The exact set of `interval` strings the v3 ThetaData server accepts.
92+
///
93+
/// Mirrors the upstream enum at
94+
/// `https://docs.thetadata.us/operations/option_history_quote.html`.
95+
/// The SDK additionally accepts decimal millisecond shorthand
96+
/// (`"60000"`, `"300000"`, ...) and snaps it to the nearest preset via
97+
/// [`crate::mdds::endpoints::normalize_interval`]; this validator
98+
/// recognises both shapes so the CLI / MCP layer rejects garbage
99+
/// before the gRPC dispatch.
100+
const VALID_INTERVAL_PRESETS: &[&str] = &[
101+
"tick", "10ms", "100ms", "500ms", "1s", "5s", "10s", "15s", "30s", "1m", "5m", "10m", "15m",
102+
"30m", "1h",
103+
];
104+
91105
pub(crate) fn validate_interval(value: &str, param_name: &str) -> Result<(), EndpointError> {
92-
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_alphanumeric()) {
106+
if value.is_empty() {
93107
return Err(EndpointError::InvalidParams(format!(
94-
"'{param_name}' must be a non-empty alphanumeric string (e.g. '60000' or '1m'), got: '{value}'"
108+
"'{param_name}' must be a non-empty string from the upstream enum ({}) or a millisecond value (e.g. '60000'), got empty string",
109+
VALID_INTERVAL_PRESETS.join(", "),
95110
)));
96111
}
97-
Ok(())
112+
if VALID_INTERVAL_PRESETS.contains(&value) {
113+
return Ok(());
114+
}
115+
if value.bytes().all(|b| b.is_ascii_digit()) {
116+
// Millisecond shorthand: `normalize_interval` will snap to the
117+
// nearest documented preset. Any positive integer is accepted
118+
// here; the snap range covers `0` (-> "tick") through `1h`.
119+
return Ok(());
120+
}
121+
Err(EndpointError::InvalidParams(format!(
122+
"'{param_name}' must be one of the upstream presets ({}) or a millisecond value (e.g. '60000'), got: '{value}'",
123+
VALID_INTERVAL_PRESETS.join(", "),
124+
)))
98125
}
99126

100127
pub(crate) fn validate_right(value: &str, param_name: &str) -> Result<(), EndpointError> {
@@ -241,4 +268,21 @@ mod tests {
241268
assert!(validate_strike(bad, "strike").is_err(), "{bad}");
242269
}
243270
}
271+
272+
#[test]
273+
fn interval_accepts_upstream_enum_and_ms_shorthand() {
274+
for good in [
275+
"tick", "10ms", "100ms", "500ms", "1s", "5s", "10s", "15s", "30s", "1m", "5m", "10m",
276+
"15m", "30m", "1h", "0", "60000", "300000",
277+
] {
278+
assert!(validate_interval(good, "interval").is_ok(), "{good}");
279+
}
280+
}
281+
282+
#[test]
283+
fn interval_rejects_garbage() {
284+
for bad in ["", "twosec", "2sec", "1minute", "-1", "1.5s", "1 s", "*"] {
285+
assert!(validate_interval(bad, "interval").is_err(), "{bad}");
286+
}
287+
}
244288
}

0 commit comments

Comments
 (0)