Skip to content

Commit cae1d38

Browse files
userFRMclaude
andauthored
fix(decode): silence find_header warn on optional Greeks columns (#473)
Closes #472. The v3 server splits the Greeks column set across `option_*_greeks_*_order` endpoints — `_first_order` ships seven Greeks, `_second_order` ships five, and `_third_order` ships four — but the shared `GreeksTick` schema in `tick_schema.toml` carries the full 23-Greek union. Every absent column on a subset response triggered `tracing::warn!("expected column header not found in DataTable", header=…)` from `find_header` in `crates/thetadatadx/src/decode.rs`, so a single `option_snapshot_greeks_third_order` call printed eight warn lines per row before any user-visible decoding finished. The reporter on Issue #472 saw the spam on every Python `option_snapshot_greeks_first_order` call. Demote the diagnostic to `tracing::trace!` so it stays reachable via `RUST_LOG=thetadatadx=trace` for genuine schema-drift investigations but stops competing with stderr on routine subset calls. Required-column drift continues to surface as a typed `Error::MissingRequiredHeader` from the generated parser, so the silenced path is strictly the documented optional-column case. Pin the per-endpoint vendor schema in the `GreeksTick` doc-comment against the upstream OpenAPI capture (`scripts/upstream_openapi.yaml`), so the column-list / endpoint mapping is visible from the schema file itself rather than scattered across endpoint metadata. Add three regression tests in `decode.rs::tests` that drive `parse_greeks_ticks` against `_first_order`, `_second_order`, and `_third_order` wire shapes. Each test asserts bit-exact decoded values for every wire-present column and `0.0` defaults for the documented gaps. A future regression of `find_header` back to `tracing::warn!`, or any column-list drift in either direction, surfaces here as a behavioural test failure rather than as live log spam. Workspace 8.0.25 → 8.0.26, tdbe 0.12.6 → 0.12.7. CHANGELOG + docs-site/docs/changelog.md kept byte-identical. Local CI gate: cargo fmt --all -- --check # clean cargo clippy --workspace --all-targets -- -D warnings # clean cargo test --workspace # 0 failures cargo deny check # advisories+bans+licenses+sources ok generate_sdk_surfaces --check # no drift Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d891109 commit cae1d38

25 files changed

Lines changed: 355 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [8.0.26] - 2026-05-05
11+
12+
### Fixed
13+
14+
- **`option_*_greeks_*_order` no longer spams `expected column header
15+
not found` warnings.** `crates/thetadatadx/src/decode.rs::find_header`
16+
emitted a `tracing::warn!` every time a generated parser asked for an
17+
optional column that was absent from the wire response. The Greeks
18+
family splits the column set across the wire — `_greeks_first_order`
19+
ships seven Greeks, `_greeks_second_order` ships five, and
20+
`_greeks_third_order` ships four — but the shared `GreeksTick` schema
21+
carries the full 23-Greek union. Calling
22+
`option_snapshot_greeks_third_order` therefore produced eight warn
23+
lines per response (zomma, color, ultima, d1, d2, dual_delta,
24+
dual_gamma, vera, …) before any user-visible decoding finished. The
25+
warn is now a `tracing::trace!` so the diagnostic is still reachable
26+
via `RUST_LOG=thetadatadx=trace` for genuine schema-drift
27+
investigations, but stays out of stderr on routine subset calls.
28+
Required-column drift continues to surface as a typed
29+
`Error::MissingRequiredHeader` from the generated parser. Closes #472.
30+
31+
### Changed
32+
33+
- Per-endpoint vendor-schema column lists for the four Greeks families
34+
pinned and documented in `tick_schema.toml::GreeksTick` against the
35+
upstream OpenAPI capture in `scripts/upstream_openapi.yaml`. The
36+
`GreeksTick` struct itself is unchanged — every Greeks endpoint still
37+
returns `Vec<GreeksTick>` and the union layout is the same — but the
38+
schema doc-comment now spells out which Greeks each endpoint
39+
publishes, why the others zero-default, and where the per-endpoint
40+
vendor schema is captured. The codegen pickup is doc-only; no SDK
41+
surface drift.
42+
- Three new unit tests in `crates/thetadatadx/src/decode.rs::tests`
43+
drive `parse_greeks_ticks` against the `_first_order`,
44+
`_second_order`, and `_third_order` wire shapes (column lists pinned
45+
to upstream OpenAPI). Each test asserts bit-exact decoded values for
46+
the wire-present columns and `0.0` defaults for the documented gaps,
47+
so a future regression of `find_header` back to `tracing::warn!`
48+
or any column-list drift in either direction — surfaces as a
49+
behavioural test failure rather than as live log spam.
50+
- `tdbe` 0.12.6 → 0.12.7.
51+
1052
## [8.0.25] - 2026-05-05
1153

1254
### Fixed

Cargo.lock

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

crates/tdbe/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tdbe"
3-
version = "0.12.6"
3+
version = "0.12.7"
44
edition.workspace = true
55
rust-version.workspace = true
66
authors.workspace = true

crates/thetadatadx/Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "thetadatadx"
3-
version = "8.0.25"
3+
version = "8.0.26"
44
edition.workspace = true
55
rust-version.workspace = true
66
authors.workspace = true
@@ -40,7 +40,7 @@ frames = ["polars", "arrow"]
4040
live-tests = []
4141

4242
[dependencies]
43-
tdbe = { version = "0.12.6", path = "../tdbe" }
43+
tdbe = { version = "0.12.7", path = "../tdbe" }
4444

4545
# gRPC + protobuf (tonic 0.14 extracted prost codec into tonic-prost)
4646
tonic = { version = "=0.14.5", features = ["tls-ring", "tls-native-roots", "channel", "transport"] }
@@ -132,7 +132,7 @@ prost-build = "=0.14.3"
132132
regex = "1.12.3"
133133
toml = "1.1.2"
134134
serde = { version = "1.0.228", features = ["derive"] }
135-
tdbe = { version = "0.12.6", path = "../tdbe" }
135+
tdbe = { version = "0.12.7", path = "../tdbe" }
136136

137137
[[bench]]
138138
name = "bench_decode"

crates/thetadatadx/src/decode.rs

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ const HEADER_ALIASES: &[(&str, &str)] = &[
105105
///
106106
/// The v3 MDDS server uses `timestamp` where the tick schema says `ms_of_day`.
107107
/// This function checks the primary name first, then falls back to known aliases.
108+
///
109+
/// Returns `None` silently when the header is absent — required-header
110+
/// guards in the generated parsers surface a typed
111+
/// [`Error::MissingRequiredHeader`] for the must-have columns; optional
112+
/// columns missing from a subset response (e.g. `option_snapshot_greeks_third_order`
113+
/// returning only the third-order Greek columns from the `GreeksTick`
114+
/// union schema) are by design. Header drift can be observed at the
115+
/// `trace` level via `RUST_LOG=thetadatadx=trace`.
108116
fn find_header(headers: &[&str], name: &str) -> Option<usize> {
109117
// Try exact match first.
110118
if let Some(pos) = headers.iter().position(|&s| s == name) {
@@ -118,9 +126,9 @@ fn find_header(headers: &[&str], name: &str) -> Option<usize> {
118126
}
119127
}
120128
}
121-
tracing::warn!(
129+
tracing::trace!(
122130
header = name,
123-
"expected column header not found in DataTable"
131+
"column header not present in DataTable (optional or subset response)"
124132
);
125133
None
126134
}
@@ -1752,4 +1760,179 @@ mod tests {
17521760
assert_eq!(ticks.len(), 1);
17531761
assert!(ticks[0].implied_volatility.abs() < 1e-10);
17541762
}
1763+
1764+
/// Vendor wire shape for `option_*_greeks_first_order`: only the seven
1765+
/// first-order columns plus IV pair — vanna/charm/vomma/veta/speed/
1766+
/// zomma/color/ultima/d1/d2/dual_delta/dual_gamma/vera are absent and
1767+
/// must default to `0.0` without surfacing any `find_header` warn.
1768+
/// Column layout pinned to `scripts/upstream_openapi.yaml` schema
1769+
/// `items_option_snapshot_greeks_first_order`.
1770+
#[test]
1771+
fn parse_greeks_ticks_decodes_first_order_subset_with_silent_gaps() {
1772+
let table = proto::DataTable {
1773+
headers: vec![
1774+
"ms_of_day".into(),
1775+
"implied_volatility".into(),
1776+
"delta".into(),
1777+
"theta".into(),
1778+
"vega".into(),
1779+
"rho".into(),
1780+
"epsilon".into(),
1781+
"lambda".into(),
1782+
"iv_error".into(),
1783+
"date".into(),
1784+
],
1785+
data_table: vec![row_of(vec![
1786+
dv_number(34_200_000),
1787+
dv_price(2142, 6), // implied_volatility = 0.2142
1788+
dv_price(5023, 6), // delta = 0.5023
1789+
dv_price(-114, 6), // theta = -0.0114
1790+
dv_price(8741, 6), // vega = 0.8741
1791+
dv_price(13598, 6), // rho = 1.3598
1792+
dv_price(-1976, 6), // epsilon = -0.1976
1793+
dv_price(32052, 6), // lambda = 3.2052
1794+
dv_price(-3, 6), // iv_error = -3 / 10^4 = -0.0003
1795+
dv_number(20_240_614),
1796+
])],
1797+
};
1798+
let ticks = parse_greeks_ticks(&table).unwrap();
1799+
assert_eq!(ticks.len(), 1);
1800+
let t = &ticks[0];
1801+
1802+
// Wire-present columns: bit-exact against the input.
1803+
// `dv_price(value, 6)` decodes as `value * 10^(6-10) = value / 10000`
1804+
// (see `tdbe::types::price::Price::to_f64`).
1805+
assert_eq!(t.ms_of_day, 34_200_000);
1806+
assert!((t.implied_volatility - 0.2142).abs() < 1e-9);
1807+
assert!((t.delta - 0.5023).abs() < 1e-9);
1808+
assert!((t.theta - -0.0114).abs() < 1e-9);
1809+
assert!((t.vega - 0.8741).abs() < 1e-9);
1810+
assert!((t.rho - 1.3598).abs() < 1e-9);
1811+
assert!((t.epsilon - -0.1976).abs() < 1e-9);
1812+
assert!((t.lambda - 3.2052).abs() < 1e-9);
1813+
assert!((t.iv_error - -0.0003).abs() < 1e-9);
1814+
assert_eq!(t.date, 20_240_614);
1815+
1816+
// Wire-absent columns: zero-defaulted. These are the columns the
1817+
// server does NOT publish for `_greeks_first_order` — `find_header`
1818+
// returning `None` for each must NOT yield an error and must NOT
1819+
// warn (the pre-fix behaviour spammed eight warn lines per row).
1820+
assert_eq!(t.gamma, 0.0);
1821+
assert_eq!(t.vanna, 0.0);
1822+
assert_eq!(t.charm, 0.0);
1823+
assert_eq!(t.vomma, 0.0);
1824+
assert_eq!(t.veta, 0.0);
1825+
assert_eq!(t.speed, 0.0);
1826+
assert_eq!(t.zomma, 0.0);
1827+
assert_eq!(t.color, 0.0);
1828+
assert_eq!(t.ultima, 0.0);
1829+
assert_eq!(t.d1, 0.0);
1830+
assert_eq!(t.d2, 0.0);
1831+
assert_eq!(t.dual_delta, 0.0);
1832+
assert_eq!(t.dual_gamma, 0.0);
1833+
assert_eq!(t.vera, 0.0);
1834+
}
1835+
1836+
/// Vendor wire shape for `option_*_greeks_second_order`: gamma / vanna
1837+
/// / charm / vomma / veta plus IV pair. Column layout pinned to
1838+
/// upstream OpenAPI schema `items_option_snapshot_greeks_second_order`.
1839+
#[test]
1840+
fn parse_greeks_ticks_decodes_second_order_subset_with_silent_gaps() {
1841+
let table = proto::DataTable {
1842+
headers: vec![
1843+
"ms_of_day".into(),
1844+
"implied_volatility".into(),
1845+
"gamma".into(),
1846+
"vanna".into(),
1847+
"charm".into(),
1848+
"vomma".into(),
1849+
"veta".into(),
1850+
"iv_error".into(),
1851+
"date".into(),
1852+
],
1853+
data_table: vec![row_of(vec![
1854+
dv_number(34_200_000),
1855+
dv_price(2142, 6), // implied_volatility = 0.2142
1856+
dv_price(120, 6), // gamma = 0.012
1857+
dv_price(45, 6), // vanna = 0.0045
1858+
dv_price(-12, 6), // charm = -0.0012
1859+
dv_price(900, 6), // vomma = 0.09
1860+
dv_price(-3, 6), // veta = -0.0003
1861+
dv_price(-3, 6), // iv_error = -0.0003
1862+
dv_number(20_240_614),
1863+
])],
1864+
};
1865+
let ticks = parse_greeks_ticks(&table).unwrap();
1866+
assert_eq!(ticks.len(), 1);
1867+
let t = &ticks[0];
1868+
1869+
assert!((t.gamma - 0.012).abs() < 1e-9);
1870+
assert!((t.vanna - 0.0045).abs() < 1e-9);
1871+
assert!((t.charm - -0.0012).abs() < 1e-9);
1872+
assert!((t.vomma - 0.09).abs() < 1e-9);
1873+
assert!((t.veta - -0.0003).abs() < 1e-9);
1874+
1875+
// First-order, third-order, and `_all`-only columns are absent
1876+
// on the wire and default to 0.0.
1877+
assert_eq!(t.delta, 0.0);
1878+
assert_eq!(t.speed, 0.0);
1879+
assert_eq!(t.zomma, 0.0);
1880+
assert_eq!(t.d1, 0.0);
1881+
assert_eq!(t.vera, 0.0);
1882+
}
1883+
1884+
/// Vendor wire shape for `option_*_greeks_third_order`: speed / zomma /
1885+
/// color / ultima plus IV pair. This is the exact endpoint the Issue
1886+
/// #472 reporter was hitting — `option_snapshot_greeks_third_order`
1887+
/// previously emitted eight warn lines per row for the absent
1888+
/// first-order / second-order / `_all`-only columns. The test pins the
1889+
/// silent-gap behaviour so a future regression of `find_header` back
1890+
/// to `tracing::warn!` would surface here as a behavioural change.
1891+
/// Column layout pinned to upstream OpenAPI schema
1892+
/// `items_option_snapshot_greeks_third_order` (notably `vera` is NOT
1893+
/// in the third-order subset; it only ships in `_greeks_all`).
1894+
#[test]
1895+
fn parse_greeks_ticks_decodes_third_order_subset_with_silent_gaps() {
1896+
let table = proto::DataTable {
1897+
headers: vec![
1898+
"ms_of_day".into(),
1899+
"implied_volatility".into(),
1900+
"speed".into(),
1901+
"zomma".into(),
1902+
"color".into(),
1903+
"ultima".into(),
1904+
"iv_error".into(),
1905+
"date".into(),
1906+
],
1907+
data_table: vec![row_of(vec![
1908+
dv_number(34_200_000),
1909+
dv_price(2142, 6), // implied_volatility = 0.2142
1910+
dv_price(7, 6), // speed = 0.0007
1911+
dv_price(15, 6), // zomma = 0.0015
1912+
dv_price(-2, 6), // color = -0.0002
1913+
dv_price(33, 6), // ultima = 0.0033
1914+
dv_price(-3, 6), // iv_error = -0.0003
1915+
dv_number(20_240_614),
1916+
])],
1917+
};
1918+
let ticks = parse_greeks_ticks(&table).unwrap();
1919+
assert_eq!(ticks.len(), 1);
1920+
let t = &ticks[0];
1921+
1922+
assert!((t.speed - 0.0007).abs() < 1e-9);
1923+
assert!((t.zomma - 0.0015).abs() < 1e-9);
1924+
assert!((t.color - -0.0002).abs() < 1e-9);
1925+
assert!((t.ultima - 0.0033).abs() < 1e-9);
1926+
1927+
// Vera is NOT a third-order column on the wire even though the
1928+
// generic `GreeksTick` struct carries the field. It must default
1929+
// to 0.0 here without warning.
1930+
assert_eq!(t.vera, 0.0);
1931+
// First-order and second-order columns also absent.
1932+
assert_eq!(t.delta, 0.0);
1933+
assert_eq!(t.gamma, 0.0);
1934+
assert_eq!(t.vanna, 0.0);
1935+
assert_eq!(t.d1, 0.0);
1936+
assert_eq!(t.dual_gamma, 0.0);
1937+
}
17551938
}

crates/thetadatadx/tick_schema.toml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,27 @@ columns = [
185185
]
186186

187187
[types.GreeksTick]
188-
doc = "Greeks tick -- 24 fields. Full set of option greeks."
188+
doc = """
189+
Greeks tick -- 24 fields. Full union of every Greek the v3 server
190+
publishes across the `option_*_greeks_*` family.
191+
192+
Endpoint-specific subsets reuse this struct; absent columns decode to
193+
`0.0` and the per-endpoint vendor schema is documented via the upstream
194+
OpenAPI capture (`scripts/upstream_openapi.yaml`):
195+
196+
* `option_*_greeks_first_order` -> delta / theta / vega / rho / epsilon / lambda + IV pair
197+
* `option_*_greeks_second_order` -> gamma / vanna / charm / vomma / veta + IV pair
198+
* `option_*_greeks_third_order` -> speed / zomma / color / ultima + IV pair (no vera)
199+
* `option_*_greeks_all` / `option_*_greeks_eod` -> the full union below
200+
* `option_*_greeks_implied_volatility` -> handled by `IvTick`
201+
202+
The wire-level `timestamp` -> `ms_of_day` and `implied_vol` ->
203+
`implied_volatility` mappings are applied through `HEADER_ALIASES`
204+
in `crates/thetadatadx/src/decode.rs`. Optional columns absent from a
205+
subset response (e.g. `zomma` on the first-order endpoint) are silently
206+
defaulted to `0.0`; the parser never warns on a documented subset gap
207+
(see `find_header` for the trace-level diagnostic path).
208+
"""
189209
copy = true
190210
align = 64
191211
contract_id = true

0 commit comments

Comments
 (0)