Skip to content

Commit 42d3d6d

Browse files
committed
fix: correct EOD timestamp decoding and clarify option bulk filters
1 parent 9eb26e9 commit 42d3d6d

8 files changed

Lines changed: 153 additions & 13 deletions

File tree

crates/thetadatadx/build_support/ticks.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,11 @@ fn generate_parser(out: &mut String, type_name: &str, def: &TickTypeDef) {
8282
)
8383
.unwrap();
8484

85-
// If eod_style, emit the local eod_num and eod_price helpers inline.
85+
// If eod_style, emit the local EOD helpers inline.
8686
if def.eod_style {
87-
out.push_str(" // EOD rows may have Price-typed cells or plain Number cells.\n");
87+
out.push_str(
88+
" // EOD numeric/time fields may arrive as Price, Number, or Timestamp cells.\n",
89+
);
8890
out.push_str(" fn eod_num(row: &crate::proto::DataValueList, idx: usize) -> i32 {\n");
8991
out.push_str(" row.values\n");
9092
out.push_str(" .get(idx)\n");
@@ -96,6 +98,26 @@ fn generate_parser(out: &mut String, type_name: &str, def: &TickTypeDef) {
9698
out.push_str(
9799
" crate::proto::data_value::DataType::Price(p) => Some(p.value),\n",
98100
);
101+
out.push_str(
102+
" crate::proto::data_value::DataType::Timestamp(ts) => Some(crate::decode::timestamp_to_ms_of_day(ts.epoch_ms)),\n",
103+
);
104+
out.push_str(" _ => None,\n");
105+
out.push_str(" })\n");
106+
out.push_str(" .unwrap_or(0)\n");
107+
out.push_str(" }\n\n");
108+
109+
out.push_str(" // EOD date fields may arrive as Price, Number, or Timestamp cells.\n");
110+
out.push_str(" fn eod_date(row: &crate::proto::DataValueList, idx: usize) -> i32 {\n");
111+
out.push_str(" row.values\n");
112+
out.push_str(" .get(idx)\n");
113+
out.push_str(" .and_then(|dv| dv.data_type.as_ref())\n");
114+
out.push_str(" .and_then(|dt| match dt {\n");
115+
out.push_str(
116+
" crate::proto::data_value::DataType::Number(n) => Some(*n as i32),\n",
117+
);
118+
out.push_str(
119+
" crate::proto::data_value::DataType::Price(p) => Some(p.value),\n",
120+
);
99121
out.push_str(
100122
" crate::proto::data_value::DataType::Timestamp(ts) => Some(crate::decode::timestamp_to_date(ts.epoch_ms)),\n",
101123
);
@@ -318,6 +340,14 @@ fn generate_parser(out: &mut String, type_name: &str, def: &TickTypeDef) {
318340
)
319341
.unwrap();
320342
}
343+
"eod_date" => {
344+
writeln!(
345+
out,
346+
" {}: {var}.map(|i| eod_date(row, i)).unwrap_or(0),",
347+
col.field
348+
)
349+
.unwrap();
350+
}
321351
"eod_price" => {
322352
// EOD price field: decode to f64 from Price or Number cell.
323353
writeln!(

crates/thetadatadx/endpoint_schema.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
# f64 -> row_float(row, i) default: 0.0
1010
# String -> row_text(row, i) default: ""
1111
# price -> decode to f64 via Price::new(raw, pt).to_f64()
12-
# eod_num -> eod_num(row, i) handles both Price and Number cells default: 0
12+
# eod_num -> EOD numeric/time field: Price/Number direct, Timestamp -> ms_of_day
13+
# eod_date -> EOD date field: Price/Number direct, Timestamp -> YYYYMMDD
1314
# eod_price -> EOD price fields: decode to f64 at parse time
1415
#
1516
# Parser options:
@@ -115,7 +116,7 @@ columns = [
115116
{ name = "ask_exchange", field = "ask_exchange", type = "eod_num" },
116117
{ name = "ask", field = "ask", type = "eod_price" },
117118
{ name = "ask_condition", field = "ask_condition", type = "eod_num" },
118-
{ name = "date", field = "date", type = "eod_num" },
119+
{ name = "date", field = "date", type = "eod_date" },
119120
]
120121

121122
# ─────────────────────────────────────────────────────────────────────────────

crates/thetadatadx/src/decode.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ fn civil_to_epoch_days(year: i32, month: u32, day: u32) -> i64 {
170170
/// Convert `epoch_ms` to milliseconds-of-day in Eastern Time (DST-aware).
171171
// Reason: ms_of_day fits in i32; epoch_ms is in valid market data range.
172172
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
173-
fn timestamp_to_ms_of_day(epoch_ms: u64) -> i32 {
173+
pub(crate) fn timestamp_to_ms_of_day(epoch_ms: u64) -> i32 {
174174
let offset = eastern_offset_ms(epoch_ms);
175175
let local_ms = epoch_ms as i64 + offset;
176176
(local_ms.rem_euclid(86_400_000)) as i32
@@ -764,6 +764,15 @@ mod tests {
764764
}
765765
}
766766

767+
/// Build a DataValue containing a Timestamp.
768+
fn dv_timestamp(epoch_ms: u64) -> proto::DataValue {
769+
proto::DataValue {
770+
data_type: Some(proto::data_value::DataType::Timestamp(
771+
proto::ZonedDateTime { epoch_ms, zone: 0 },
772+
)),
773+
}
774+
}
775+
767776
/// Build a DataValue with no data_type set (missing).
768777
fn dv_missing() -> proto::DataValue {
769778
proto::DataValue { data_type: None }
@@ -1062,4 +1071,32 @@ mod tests {
10621071
assert_eq!(super::parse_time_text("invalid"), 0);
10631072
assert_eq!(super::parse_time_text(""), 0);
10641073
}
1074+
1075+
#[test]
1076+
fn parse_eod_timestamp_aliases_decode_time_and_date_separately() {
1077+
// 2026-04-01 13:30:00 UTC = 2026-04-01 09:30:00 ET (EDT).
1078+
let epoch_ms: u64 = 1_775_050_200_000;
1079+
let table = proto::DataTable {
1080+
headers: vec![
1081+
"timestamp".into(),
1082+
"timestamp2".into(),
1083+
"open".into(),
1084+
"close".into(),
1085+
],
1086+
data_table: vec![row_of(vec![
1087+
dv_timestamp(epoch_ms),
1088+
dv_timestamp(epoch_ms),
1089+
dv_number(15000),
1090+
dv_number(15100),
1091+
])],
1092+
};
1093+
1094+
let ticks = parse_eod_ticks(&table);
1095+
assert_eq!(ticks.len(), 1);
1096+
assert_eq!(ticks[0].ms_of_day, 34_200_000);
1097+
assert_eq!(ticks[0].ms_of_day2, 34_200_000);
1098+
assert_eq!(ticks[0].date, 20260401);
1099+
assert!((ticks[0].open - 15000.0).abs() < 1e-10);
1100+
assert!((ticks[0].close - 15100.0).abs() < 1e-10);
1101+
}
10651102
}

docs-site/docs/historical/option/history/eod.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ for (const auto& t : data) {
8080

8181
## Response
8282

83+
> `strike_range` filters a wildcard bulk request. If you pin `strike` to one contract, the response stays single-strike. Use `strike="0"` in ThetaDataDx SDK/MCP or `strike=*` in the v3 REST API when you want multi-strike EOD output.
84+
8385
<div class="param-list">
8486
<div class="param">
8587
<div class="param-header"><code>date</code><span class="param-type">string</span></div>
@@ -122,4 +124,3 @@ for (const auto& t : data) {
122124
```
123125

124126
> EOD data for SPY 2026-04-17 550 call. Days with no trades show `0.00` for OHLC but still have closing bid/ask.
125-

docs-site/docs/historical/option/history/greeks-eod.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,12 @@ for (const auto& t : data) {
9494
</div>
9595
<div class="param">
9696
<div class="param-header"><code>strike_range</code><span class="param-type">int</span><span class="param-badge optional">optional</span></div>
97-
<div class="param-desc">Strike range filter</div>
97+
<div class="param-desc">Strike range filter. This only narrows a wildcard bulk query; it does not expand a pinned strike into neighboring strikes.</div>
9898
</div>
9999
</div>
100100

101+
> For multi-strike EOD Greeks requests, use a wildcard strike selection first (`strike="0"` in ThetaDataDx SDK/MCP, `strike=*` in the v3 REST API), then apply `strike_range`.
102+
101103
## Response
102104

103105
<div class="param-list">

docs-site/docs/options.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@ for row in chain[:5]:
131131

132132
When you pass `"0"` for `expiration` or `strike`, the server returns data across all matching contracts. Each tick includes contract identification fields (`expiration`, `strike`, `right`) so you can distinguish which contract each tick belongs to.
133133

134+
`strike_range` only narrows one of these wildcard bulk queries. It does not expand a pinned strike into neighboring strikes. In other words:
135+
136+
- `strike="500"` + `strike_range=5` still targets the single 500 strike contract
137+
- `strike="0"` + `strike_range=5` returns a spot-relative range of strikes around ATM
138+
139+
If you are comparing against the v3 REST API, the same wildcard behavior is expressed with `strike=*` / `expiration=*` instead of `"0"`.
140+
134141
::: warning
135142
The `right` parameter does **not** accept `"0"` as a wildcard. Use `"C"` (call), `"P"` (put), or `"both"` (calls and puts). Only `expiration` and `strike` accept `"0"` as a wildcard.
136143
:::

tools/mcp/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ The server speaks standard MCP over stdio:
132132
- `option_history_greeks_implied_volatility`, `option_history_trade_greeks_implied_volatility`
133133
- `option_at_time_trade`, `option_at_time_quote`
134134

135+
### Wildcard Option Queries
136+
137+
For option tools, MCP uses `"0"` as the wildcard value for `strike` and `expiration`.
138+
139+
- Use a pinned strike like `"strike":"385"` when you want one contract.
140+
- Use `"strike":"0"` when you want a bulk chain-style response with contract identification fields on each row.
141+
- `strike_range` filters a wildcard bulk selection around spot / ATM. It does **not** fan out a pinned strike into neighboring strikes.
142+
143+
This matches the current Java terminal behavior. The v3 REST surface uses `*` for the same wildcard concept; the MCP server uses `"0"` because it follows the underlying SDK contract.
144+
135145
### Index Data (9 tools)
136146
- `index_list_symbols`, `index_list_dates`
137147
- `index_snapshot_ohlc`, `index_snapshot_price`, `index_snapshot_market_value`
@@ -161,6 +171,14 @@ Response:
161171
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"ticks\":[{\"date\":20240102,\"ms_of_day\":57600000,\"open\":187.15,\"high\":188.44,\"low\":183.89,\"close\":185.64,\"volume\":82488700,\"count\":1036575,\"bid\":185.63,\"ask\":185.65,\"bid_size\":1,\"ask_size\":3},...],\"count\":41}"}]}}
162172
```
163173

174+
### Fetch bulk option Greeks around ATM
175+
176+
```json
177+
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"option_history_greeks_eod","arguments":{"symbol":"SPY","expiration":"20230120","strike":"0","right":"C","start_date":"20221219","end_date":"20221220","strike_range":5}}}
178+
```
179+
180+
This returns a filtered bulk response across multiple strikes. If you change `strike` to `"385"`, the response is limited to that single contract.
181+
164182
### Compute Greeks offline
165183

166184
```json

tools/mcp/src/main.rs

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -445,14 +445,19 @@ fn serialize_eod_ticks(ticks: &[tdbe::types::tick::EodTick]) -> Value {
445445
let mut row = json!({
446446
"date": t.date,
447447
"ms_of_day": t.ms_of_day,
448+
"ms_of_day2": t.ms_of_day2,
448449
"open": t.open,
449450
"high": t.high,
450451
"low": t.low,
451452
"close": t.close,
452453
"volume": t.volume,
453454
"count": t.count,
455+
"bid_exchange": t.bid_exchange,
454456
"bid": t.bid,
457+
"bid_condition": t.bid_condition,
458+
"ask_exchange": t.ask_exchange,
455459
"ask": t.ask,
460+
"ask_condition": t.ask_condition,
456461
"bid_size": t.bid_size,
457462
"ask_size": t.ask_size,
458463
});
@@ -1095,22 +1100,22 @@ mod tests {
10951100

10961101
fn sample_eod_tick(expiration: i32, strike: f64, right: i32) -> EodTick {
10971102
EodTick {
1098-
ms_of_day: 0,
1099-
ms_of_day2: 0,
1103+
ms_of_day: 34_200_000,
1104+
ms_of_day2: 57_600_000,
11001105
open: 1.0,
11011106
high: 1.0,
11021107
low: 1.0,
11031108
close: 1.0,
11041109
volume: 10,
11051110
count: 1,
11061111
bid_size: 2,
1107-
bid_exchange: 0,
1112+
bid_exchange: 11,
11081113
bid: 0.9,
1109-
bid_condition: 0,
1114+
bid_condition: 22,
11101115
ask_size: 3,
1111-
ask_exchange: 0,
1116+
ask_exchange: 33,
11121117
ask: 1.1,
1113-
ask_condition: 0,
1118+
ask_condition: 44,
11141119
date: 20221219,
11151120
expiration,
11161121
strike,
@@ -1296,6 +1301,45 @@ mod tests {
12961301
);
12971302
}
12981303

1304+
#[test]
1305+
fn serialize_eod_ticks_preserves_full_eod_fields() {
1306+
let payload = serialize_eod_ticks(&[sample_eod_tick(0, 0.0, 0)]);
1307+
let tick = payload
1308+
.get("ticks")
1309+
.and_then(|value: &Value| value.as_array())
1310+
.and_then(|rows| rows.first())
1311+
.expect("serialized tick row should exist");
1312+
1313+
assert_eq!(
1314+
tick.get("ms_of_day").and_then(|value: &Value| value.as_i64()),
1315+
Some(34_200_000)
1316+
);
1317+
assert_eq!(
1318+
tick.get("ms_of_day2").and_then(|value: &Value| value.as_i64()),
1319+
Some(57_600_000)
1320+
);
1321+
assert_eq!(
1322+
tick.get("bid_exchange")
1323+
.and_then(|value: &Value| value.as_i64()),
1324+
Some(11)
1325+
);
1326+
assert_eq!(
1327+
tick.get("bid_condition")
1328+
.and_then(|value: &Value| value.as_i64()),
1329+
Some(22)
1330+
);
1331+
assert_eq!(
1332+
tick.get("ask_exchange")
1333+
.and_then(|value: &Value| value.as_i64()),
1334+
Some(33)
1335+
);
1336+
assert_eq!(
1337+
tick.get("ask_condition")
1338+
.and_then(|value: &Value| value.as_i64()),
1339+
Some(44)
1340+
);
1341+
}
1342+
12991343
#[test]
13001344
fn serialize_option_history_greeks_eod_omits_contract_identifiers_for_single_contract_rows() {
13011345
let payload = serialize_greeks_ticks(&[sample_greeks_tick(0, 0.0, 0)]);

0 commit comments

Comments
 (0)