diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd4bfb9..7102e415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.28] - 2026-05-06 + +### Breaking + +- **`Contract`, `OptionContract`, `FlatFileRow`, and `IndexEntry` rename + `root` to `symbol` and `exp_date` to `expiration` to match the v3 + vendor surface documented in the [v2 → v3 migration guide][v3-mig]. + The wire codec is unchanged — `Contract::to_bytes` / + `Contract::from_bytes` still serialize the field as `root` per + `Contract.java` parity, and the FLATFILES decoder still resolves both + v2 (`root`) and v3 (`symbol`) response columns through the existing + `decode::HEADER_ALIASES`. Per-language renames: + + - **Rust** (`thetadatadx::fpss::protocol::Contract`, + `tdbe::types::tick::OptionContract`, + `thetadatadx::flatfiles::FlatFileRow`): + - `Contract.root` → `Contract.symbol` + - `Contract.exp_date` → `Contract.expiration` + - `Contract::stock(root)` → `Contract::stock(symbol)` + - `Contract::index(root)` → `Contract::index(symbol)` + - `Contract::rate(root)` → `Contract::rate(symbol)` + - `Contract::option(root, exp_date, …)` → + `Contract::option(symbol, expiration, …)` + - `Contract::option_raw(root, exp_date, …)` → + `Contract::option_raw(symbol, expiration, …)` + - `OptionContract.root` → `OptionContract.symbol` + - `FlatFileRow.root` → `FlatFileRow.symbol` + - **Python** (`thetadatadx.Contract`, `thetadatadx.OptionContract`): + `contract.root` / `contract.exp_date` → + `contract.symbol` / `contract.expiration`; + `OptionContract(root=…)` constructor keyword → `symbol=…`. + - **TypeScript** (`Contract`, `OptionContract`): + `contract.root` / `contract.expDate` → + `contract.symbol` / `contract.expiration`. + - **Go** (`thetadatadx.Contract`, `thetadatadx.OptionContract`): + `c.Root` / `c.ExpDate` → `c.Symbol` / `c.Expiration`. + - **C++** (`OptionContract`, `TdxContract`, `TdxOptionContract`): + `c.root` / `c.exp_date` / `c.has_exp_date` → + `c.symbol` / `c.expiration` / `c.has_expiration`. + - **C ABI**: `TdxContract.root` → `TdxContract.symbol`, + `TdxContract.exp_date` → `TdxContract.expiration`, + `TdxContract.has_exp_date` → `TdxContract.has_expiration`, + `TdxOptionContract.root` → `TdxOptionContract.symbol`. + - **FLATFILES CSV / JSONL**: contract-prefix headers and JSON keys + change from `root,expiration,strike,right,…` to + `symbol,expiration,strike,right,…`. Stock blobs go from `root,…` to + `symbol,…`. The vendor's response columns are unchanged; only the + SDK's emitted file headers change. + - **REST / WebSocket / MCP outputs** in `tools/server` and + `tools/mcp` emit `"symbol"` / `"expiration"` keys on every contract + payload (option lists, FPSS event contracts, FLATFILES rows). + +[v3-mig]: https://docs.thetadata.us/Articles/Getting-Started/v2-migration-guide.html#_5-parameter-mapping + +### Changed + +- Workspace 8.0.27 → 8.0.28, tdbe 0.12.7 → 0.12.8. The tdbe bump rides + the regenerated `OptionContract.symbol` field in + `crates/tdbe/src/types/tick_generated.rs`; every other change ships + as patch deltas off the existing v8 line per repo policy. +- `tools/cli` raw column header for `OptionContract` is `symbol` + instead of `root`, sourced from `tick_schema.toml::field` so future + schema renames flow through the CLI without a helper edit. + ## [8.0.27] - 2026-05-06 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 682c230b..cdce52cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "tdbe" -version = "0.12.7" +version = "0.12.8" dependencies = [ "criterion", "thiserror 2.0.18", @@ -3094,7 +3094,7 @@ dependencies = [ [[package]] name = "thetadatadx" -version = "8.0.27" +version = "8.0.28" dependencies = [ "arrow-array", "arrow-schema", @@ -3133,7 +3133,7 @@ dependencies = [ [[package]] name = "thetadatadx-cli" -version = "8.0.27" +version = "8.0.28" dependencies = [ "clap", "comfy-table", @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "thetadatadx-ffi" -version = "8.0.27" +version = "8.0.28" dependencies = [ "tdbe", "thetadatadx", diff --git a/crates/tdbe/Cargo.toml b/crates/tdbe/Cargo.toml index b6942d41..808dd5c7 100644 --- a/crates/tdbe/Cargo.toml +++ b/crates/tdbe/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tdbe" -version = "0.12.7" +version = "0.12.8" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/tdbe/src/types/enums.rs b/crates/tdbe/src/types/enums.rs index 1612e924..3ef8f437 100644 --- a/crates/tdbe/src/types/enums.rs +++ b/crates/tdbe/src/types/enums.rs @@ -4,7 +4,7 @@ /// The FPSS decoder uses it for the empty-contract placeholder that flows on /// data events arriving before their `ContractAssigned` frame — downstream /// consumers can pattern-match `sec_type == SecType::Unknown` instead of -/// relying on `contract.root.is_empty()`. `Unknown` has no wire-protocol +/// relying on `contract.symbol.is_empty()`. `Unknown` has no wire-protocol /// representation: [`SecType::from_code`] never returns it, and it is not /// serialized in subscribe payloads. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/tdbe/src/types/tick.rs b/crates/tdbe/src/types/tick.rs index f009e501..98049062 100644 --- a/crates/tdbe/src/types/tick.rs +++ b/crates/tdbe/src/types/tick.rs @@ -8,7 +8,7 @@ //! ...). These read `flags::*` constants and don't fit the schema's //! field-only model. //! * `impl OptionContract` for `is_call` / `is_put` -- a non-`Copy` struct -//! so the macro doesn't apply. +//! (because of the `String` `symbol` field) so the macro doesn't apply. //! //! The structs themselves are generated at build-time from //! `crates/thetadatadx/tick_schema.toml` by diff --git a/crates/tdbe/src/types/tick_generated.rs b/crates/tdbe/src/types/tick_generated.rs index 768c7a87..5b93b37d 100644 --- a/crates/tdbe/src/types/tick_generated.rs +++ b/crates/tdbe/src/types/tick_generated.rs @@ -275,12 +275,12 @@ pub struct OpenInterestTick { /// Option contract -- 4 fields. Contract specification. /// -/// Cannot be `Copy` because of the `String` root field. +/// Cannot be `Copy` because of the `String` symbol field. #[must_use] #[derive(Debug, Clone)] #[repr(C)] pub struct OptionContract { - pub root: String, + pub symbol: String, pub expiration: i32, pub strike: f64, pub right: i32, diff --git a/crates/thetadatadx/Cargo.toml b/crates/thetadatadx/Cargo.toml index 301c9a20..133b15b5 100644 --- a/crates/thetadatadx/Cargo.toml +++ b/crates/thetadatadx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thetadatadx" -version = "8.0.27" +version = "8.0.28" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -40,7 +40,7 @@ frames = ["polars", "arrow"] live-tests = [] [dependencies] -tdbe = { version = "0.12.7", path = "../tdbe" } +tdbe = { version = "0.12.8", path = "../tdbe" } # gRPC + protobuf (tonic 0.14 extracted prost codec into tonic-prost) tonic = { version = "=0.14.5", features = ["tls-ring", "tls-native-roots", "channel", "transport"] } @@ -132,7 +132,7 @@ prost-build = "=0.14.3" regex = "1.12.3" toml = "1.1.2" serde = { version = "1.0.228", features = ["derive"] } -tdbe = { version = "0.12.7", path = "../tdbe" } +tdbe = { version = "0.12.8", path = "../tdbe" } [[bench]] name = "bench_decode" diff --git a/crates/thetadatadx/build_support/endpoints/render/templates/cpp/option_contracts_convert.cpp.tmpl b/crates/thetadatadx/build_support/endpoints/render/templates/cpp/option_contracts_convert.cpp.tmpl index edf1763a..000632be 100644 --- a/crates/thetadatadx/build_support/endpoints/render/templates/cpp/option_contracts_convert.cpp.tmpl +++ b/crates/thetadatadx/build_support/endpoints/render/templates/cpp/option_contracts_convert.cpp.tmpl @@ -9,7 +9,7 @@ result.reserve(arr.len); for (size_t i = 0; i < arr.len; ++i) { OptionContract c; - c.root = arr.data[i].root ? std::string(arr.data[i].root) : ""; + c.symbol = arr.data[i].symbol ? std::string(arr.data[i].symbol) : ""; c.expiration = arr.data[i].expiration; c.strike = arr.data[i].strike; c.right = arr.data[i].right; diff --git a/crates/thetadatadx/build_support/fpss_events/ffi_c.rs b/crates/thetadatadx/build_support/fpss_events/ffi_c.rs index 79995530..6b5c5d18 100644 --- a/crates/thetadatadx/build_support/fpss_events/ffi_c.rs +++ b/crates/thetadatadx/build_support/fpss_events/ffi_c.rs @@ -10,16 +10,16 @@ use super::schema::{sorted_data_events, Schema}; /// `#[repr(C)] TdxContract`. Layout must match field-for-field with /// `render_contract_struct_rust` in `ffi_rust.rs`. fn render_contract_struct_c() -> &'static str { - "/* FPSS Contract shared across every data event. `root` is a\n\ + "/* FPSS Contract shared across every data event. `symbol` is a\n\ * NUL-terminated C string (may be null when not yet resolved);\n\ * optional option fields use a tagged-present bool because C has no\n\ * Option. Layout is byte-identical to Rust's #[repr(C)] TdxContract.\n\ */\n\ typedef struct {\n\ - const char *root;\n\ + const char *symbol;\n\ int32_t sec_type;\n\ - bool has_exp_date;\n\ - int32_t exp_date;\n\ + bool has_expiration;\n\ + int32_t expiration;\n\ bool has_is_call;\n\ bool is_call;\n\ bool has_strike;\n\ diff --git a/crates/thetadatadx/build_support/fpss_events/ffi_rust.rs b/crates/thetadatadx/build_support/fpss_events/ffi_rust.rs index 45c1f3d5..af6fdc71 100644 --- a/crates/thetadatadx/build_support/fpss_events/ffi_rust.rs +++ b/crates/thetadatadx/build_support/fpss_events/ffi_rust.rs @@ -12,7 +12,7 @@ use super::schema::{sorted_data_events, Schema}; /// event's `contract` field points at. Uses `#[repr(C)]` so the C header /// mirror gets byte-identical layout. /// -/// Strings (the `root` field) cross as C strings — the field is +/// Strings (the `symbol` field) cross as C strings — the field is /// `*const c_char` backed by a `CString` inside `FfiBufferedEvent` so the /// pointer stays valid for the lifetime of the buffered event. Optional /// fields use a tagged-optional pattern (`has_*: bool` + value) because @@ -21,20 +21,20 @@ fn render_contract_struct_rust() -> String { String::from( "/// FPSS `Contract` shared across every data event.\n\ /// \n\ -/// `root` is a NUL-terminated C string; may be null when the SDK has not\n\ +/// `symbol` is a NUL-terminated C string; may be null when the SDK has not\n\ /// yet resolved the server-assigned contract_id to a `ContractAssigned`\n\ -/// frame. Optional option fields (`exp_date`, `is_call`, `strike`) use a\n\ +/// frame. Optional option fields (`expiration`, `is_call`, `strike`) use a\n\ /// tagged-present bool because `#[repr(C)]` cannot express `Option`\n\ /// directly.\n\ #[repr(C)]\n\ pub struct TdxContract {\n\ - /// Ticker root (e.g. \"AAPL\"). Null until ContractAssigned arrives.\n\ - pub root: *const c_char,\n\ + /// Ticker symbol (e.g. \"AAPL\"). Null until ContractAssigned arrives.\n\ + pub symbol: *const c_char,\n\ /// Security type code — matches `tdbe::types::enums::SecType`.\n\ pub sec_type: i32,\n\ - /// Whether `exp_date` is meaningful (options only).\n\ - pub has_exp_date: bool,\n\ - pub exp_date: i32,\n\ + /// Whether `expiration` is meaningful (options only).\n\ + pub has_expiration: bool,\n\ + pub expiration: i32,\n\ /// Whether `is_call` is meaningful (options only).\n\ pub has_is_call: bool,\n\ pub is_call: bool,\n\ @@ -44,10 +44,10 @@ pub struct TdxContract {\n\ }\n\ \n\ pub(crate) const ZERO_CONTRACT_STRUCT: TdxContract = TdxContract {\n\ - root: ptr::null(),\n\ + symbol: ptr::null(),\n\ sec_type: 0,\n\ - has_exp_date: false,\n\ - exp_date: 0,\n\ + has_expiration: false,\n\ + expiration: 0,\n\ has_is_call: false,\n\ is_call: false,\n\ has_strike: false,\n\ @@ -178,14 +178,15 @@ pub(super) fn render_ffi_fpss_event_converter(schema: &Schema) -> String { // cursors / auxiliary fields without breaking the FFI conversion. out.push_str(" ..\n"); out.push_str(" }) => {\n"); - // Stage the CString backing the Contract.root pointer so it + // Stage the CString backing the Contract.symbol pointer so it // outlives the TdxFpssEvent inside the FfiBufferedEvent. The // backing-memory wrapper already has a `_detail_string` slot we - // repurpose here — only one owned CString per event, and Contract - // .root is mutually exclusive with Control.detail on Data variants. + // repurpose here — only one owned CString per event, and + // Contract.symbol is mutually exclusive with Control.detail on + // Data variants. if has_contract { out.push_str( - " let contract_root_cstring = if contract.root.is_empty() {\n None\n } else {\n std::ffi::CString::new(contract.root.as_str()).ok()\n };\n let contract_root_ptr = contract_root_cstring\n .as_ref()\n .map_or(ptr::null(), |cs| cs.as_ptr());\n let tdx_contract = TdxContract {\n root: contract_root_ptr,\n sec_type: contract.sec_type as i32,\n has_exp_date: contract.exp_date.is_some(),\n exp_date: contract.exp_date.unwrap_or(0),\n has_is_call: contract.is_call.is_some(),\n is_call: contract.is_call.unwrap_or(false),\n has_strike: contract.strike.is_some(),\n strike: contract.strike.unwrap_or(0),\n };\n", + " let contract_symbol_cstring = if contract.symbol.is_empty() {\n None\n } else {\n std::ffi::CString::new(contract.symbol.as_str()).ok()\n };\n let contract_symbol_ptr = contract_symbol_cstring\n .as_ref()\n .map_or(ptr::null(), |cs| cs.as_ptr());\n let tdx_contract = TdxContract {\n symbol: contract_symbol_ptr,\n sec_type: contract.sec_type as i32,\n has_expiration: contract.expiration.is_some(),\n expiration: contract.expiration.unwrap_or(0),\n has_is_call: contract.is_call.is_some(),\n is_call: contract.is_call.unwrap_or(false),\n has_strike: contract.strike.is_some(),\n strike: contract.strike.unwrap_or(0),\n };\n", ); } out.push_str(" FfiBufferedEvent {\n"); @@ -225,7 +226,7 @@ pub(super) fn render_ffi_fpss_event_converter(schema: &Schema) -> String { out.push_str(" raw_data: ZERO_RAW,\n"); out.push_str(" },\n"); if has_contract { - out.push_str(" _detail_string: contract_root_cstring,\n"); + out.push_str(" _detail_string: contract_symbol_cstring,\n"); } else { out.push_str(" _detail_string: None,\n"); } diff --git a/crates/thetadatadx/build_support/fpss_events/go_structs.rs b/crates/thetadatadx/build_support/fpss_events/go_structs.rs index 2a982fb2..b6154a5e 100644 --- a/crates/thetadatadx/build_support/fpss_events/go_structs.rs +++ b/crates/thetadatadx/build_support/fpss_events/go_structs.rs @@ -82,19 +82,19 @@ pub(super) fn render_go_fpss_offset_checks(schema: &Schema) -> String { } /// Go Contract struct — optional fields use `*int32` / `*bool` pointers -/// so nil represents `None` (Java-style). `Root` is always present as a -/// string (empty when not yet resolved). +/// so nil represents `None` (Java-style). `Symbol` is always present as +/// a string (empty when not yet resolved). fn render_contract_go() -> &'static str { - "// Contract identifies a subscribed instrument. Root is always present;\n\ -// option fields (ExpDate, IsCall, Strike) are non-nil only for options.\n\ + "// Contract identifies a subscribed instrument. Symbol is always present;\n\ +// option fields (Expiration, IsCall, Strike) are non-nil only for options.\n\ // The same Contract value is attached to every FPSS data event the SDK\n\ // emits for the matching contract_id.\n\ type Contract struct {\n\ -\tRoot string\n\ -\tSecType int32\n\ -\tExpDate *int32\n\ -\tIsCall *bool\n\ -\tStrike *int32\n\ +\tSymbol string\n\ +\tSecType int32\n\ +\tExpiration *int32\n\ +\tIsCall *bool\n\ +\tStrike *int32\n\ }\n\n" } diff --git a/crates/thetadatadx/build_support/fpss_events/python.rs b/crates/thetadatadx/build_support/fpss_events/python.rs index 69ec7cae..a5e70f7a 100644 --- a/crates/thetadatadx/build_support/fpss_events/python.rs +++ b/crates/thetadatadx/build_support/fpss_events/python.rs @@ -8,7 +8,7 @@ use super::schema::{sorted_event_names, ColumnDef, EventDef, Schema}; /// Emit the `Contract` pyclass + helper constructor. Every data event /// carries a `Py` field so Python code can read -/// `event.contract.root`, `event.contract.strike`, etc. through the +/// `event.contract.symbol`, `event.contract.strike`, etc. through the /// normal pyo3 getter machinery. fn render_contract_pyclass() -> &'static str { "/// FPSS contract identifier. Surfaced on every decoded FPSS data\n\ @@ -18,9 +18,9 @@ fn render_contract_pyclass() -> &'static str { #[pyclass(module = \"thetadatadx\", frozen, skip_from_py_object)]\n\ #[derive(Clone)]\n\ pub(crate) struct Contract {\n\ - #[pyo3(get)] pub root: String,\n\ + #[pyo3(get)] pub symbol: String,\n\ #[pyo3(get)] pub sec_type: i32,\n\ - #[pyo3(get)] pub exp_date: Option,\n\ + #[pyo3(get)] pub expiration: Option,\n\ #[pyo3(get)] pub is_call: Option,\n\ #[pyo3(get)] pub strike: Option,\n\ }\n\ @@ -28,8 +28,8 @@ pub(crate) struct Contract {\n\ impl Contract {\n\ fn __repr__(&self) -> String {\n\ format!(\n\ - \"Contract(root={:?}, sec_type={}, exp_date={:?}, is_call={:?}, strike={:?})\",\n\ - self.root, self.sec_type, self.exp_date, self.is_call, self.strike\n\ + \"Contract(symbol={:?}, sec_type={}, expiration={:?}, is_call={:?}, strike={:?})\",\n\ + self.symbol, self.sec_type, self.expiration, self.is_call, self.strike\n\ )\n\ }\n\ }\n\ @@ -41,9 +41,9 @@ impl Contract {\n\ /// exports if they need a symbolic reading.\n\ pub(crate) fn from_core(c: &fpss::protocol::Contract) -> Self {\n\ Self {\n\ - root: c.root.clone(),\n\ + symbol: c.symbol.clone(),\n\ sec_type: c.sec_type as i32,\n\ - exp_date: c.exp_date,\n\ + expiration: c.expiration,\n\ is_call: c.is_call,\n\ strike: c.strike,\n\ }\n\ diff --git a/crates/thetadatadx/build_support/fpss_events/typescript.rs b/crates/thetadatadx/build_support/fpss_events/typescript.rs index f7a78355..85128611 100644 --- a/crates/thetadatadx/build_support/fpss_events/typescript.rs +++ b/crates/thetadatadx/build_support/fpss_events/typescript.rs @@ -7,7 +7,7 @@ use super::common::{snake_case, snake_to_camel, ts_rust_field_type}; use super::schema::{load_schema, sorted_data_event_names, sorted_event_names, EventDef, Schema}; /// Emit the Contract napi struct. Same shape across every language — -/// `root` is always present (empty when not yet resolved), option fields +/// `symbol` is always present (empty when not yet resolved), option fields /// are `Option` on the Rust side / `?: T` on the TS side. /// /// Uses a raw string literal (`r#""#`) so the field indentation is @@ -22,9 +22,9 @@ fn render_contract_napi() -> &'static str { #[napi(object)] #[derive(Clone)] pub struct Contract { - pub root: String, + pub symbol: String, pub sec_type: i32, - pub exp_date: Option, + pub expiration: Option, pub is_call: Option, pub strike: Option, } @@ -164,11 +164,11 @@ pub(super) fn render_ts_fpss_event_classes(schema: &Schema) -> String { name = column.name ) .unwrap(), - // Contract is constructed explicitly — `root` clones, the - // option fields transfer by value. + // Contract is constructed explicitly — `symbol` clones, + // the option fields transfer by value. "Contract" => writeln!( out, - " {name}: Contract {{\n root: {name}.root.clone(),\n sec_type: {name}.sec_type as i32,\n exp_date: {name}.exp_date,\n is_call: {name}.is_call,\n strike: {name}.strike,\n }},", + " {name}: Contract {{\n symbol: {name}.symbol.clone(),\n sec_type: {name}.sec_type as i32,\n expiration: {name}.expiration,\n is_call: {name}.is_call,\n strike: {name}.strike,\n }},", name = column.name ) .unwrap(), diff --git a/crates/thetadatadx/build_support/sdk_surface/templates/go/next_event_body.go.tmpl b/crates/thetadatadx/build_support/sdk_surface/templates/go/next_event_body.go.tmpl index 8c116b93..1334ed6e 100644 --- a/crates/thetadatadx/build_support/sdk_surface/templates/go/next_event_body.go.tmpl +++ b/crates/thetadatadx/build_support/sdk_surface/templates/go/next_event_body.go.tmpl @@ -8,19 +8,19 @@ } // contractFromC converts a C TdxContract into the Go-idiomatic *Contract. - // Returns nil only when root is NULL, which indicates the SDK has not yet - // resolved the contract_id to a ContractAssigned frame. + // Returns nil only when symbol is NULL, which indicates the SDK has not + // yet resolved the contract_id to a ContractAssigned frame. contractFromC := func(c C.TdxContract) *Contract { - if c.root == nil { + if c.symbol == nil { return nil } out := &Contract{ - Root: C.GoString(c.root), + Symbol: C.GoString(c.symbol), SecType: int32(c.sec_type), } - if bool(c.has_exp_date) { - v := int32(c.exp_date) - out.ExpDate = &v + if bool(c.has_expiration) { + v := int32(c.expiration) + out.Expiration = &v } if bool(c.has_is_call) { v := bool(c.is_call) diff --git a/crates/thetadatadx/build_support/ticks/cli_headers.rs b/crates/thetadatadx/build_support/ticks/cli_headers.rs index f9af5c8f..a855a8a3 100644 --- a/crates/thetadatadx/build_support/ticks/cli_headers.rs +++ b/crates/thetadatadx/build_support/ticks/cli_headers.rs @@ -39,10 +39,16 @@ fn cli_raw_header_const_name(type_name: &str) -> String { } fn cli_raw_headers(type_name: &str, def: &TickTypeDef) -> Vec { + // The raw headers are user-facing CLI column labels, so they track + // the public struct field name rather than the wire-protocol column. + // For most ticks `name` and `field` coincide; the divergence appears + // on `OptionContract` where the wire still ships `root` but the + // public surface (per the v3 vendor migration guide) names it + // `symbol`. let mut headers: Vec = def .columns .iter() - .map(|column| column.name.clone()) + .map(|column| column.field.clone()) .collect(); if type_name == "QuoteTick" { headers.push("midpoint".to_string()); diff --git a/crates/thetadatadx/build_support/ticks/go.rs b/crates/thetadatadx/build_support/ticks/go.rs index 85b1f611..a88b71e3 100644 --- a/crates/thetadatadx/build_support/ticks/go.rs +++ b/crates/thetadatadx/build_support/ticks/go.rs @@ -143,9 +143,9 @@ fn render_go_tick_converter(schema: &Schema, type_name: &str, def: &TickTypeDef) writeln!(out, " result := make([]{public_type}, n)").unwrap(); out.push_str(" for i, t := range src {\n"); if type_name == "OptionContract" { - out.push_str(" root := \"\"\n"); - out.push_str(" if t.Root != 0 {\n"); - out.push_str(" root = C.GoString((*C.char)(unsafe.Pointer(t.Root)))\n"); + out.push_str(" symbol := \"\"\n"); + out.push_str(" if t.Symbol != 0 {\n"); + out.push_str(" symbol = C.GoString((*C.char)(unsafe.Pointer(t.Symbol)))\n"); out.push_str(" }\n"); } writeln!(out, " result[i] = {public_type}{{").unwrap(); @@ -298,7 +298,7 @@ fn go_public_field_name(type_name: &str, field: &str) -> &'static str { fn go_source_expr(type_name: &str, field: &str, kind: &str) -> String { let ffi_field = go_ffi_field_name(field); match (type_name, field, kind) { - ("OptionContract", "root", "String") => "root".into(), + ("OptionContract", "symbol", "String") => "symbol".into(), ("OptionContract", "right", "i32") => "RightStr(t.Right)".into(), (_, "right", "i32") => "RightStr(t.Right)".into(), (_, "is_open", "i32") => "t.IsOpen != 0".into(), @@ -471,7 +471,11 @@ fn render_go_public_struct(type_name: &str, def: &TickTypeDef) -> String { for column in &def.columns { let public_field = go_public_field_name(type_name, &column.field); let go_type_str = go_public_field_type(type_name, &column.field, &column.r#type); - let json_tag = go_json_tag(&column.name); + // JSON tag tracks the public field name (matches the Rust / + // Python / C++ surface). For most ticks `name` and `field` + // coincide; OptionContract is the exception where the wire still + // carries `root` but the public surface emits `symbol`. + let json_tag = go_json_tag(&column.field); writeln!( out, "\t{:14} {:<8} `json:\"{json_tag}\"`", diff --git a/crates/thetadatadx/build_support/ticks/rust_frames.rs b/crates/thetadatadx/build_support/ticks/rust_frames.rs index 9aa97b84..a93f19d5 100644 --- a/crates/thetadatadx/build_support/ticks/rust_frames.rs +++ b/crates/thetadatadx/build_support/ticks/rust_frames.rs @@ -156,10 +156,15 @@ fn render_arrow_impl(type_name: &str, def: &TickTypeDef) -> String { } else { arrow_data_type_expr(column.r#type.as_str()) }; + // Arrow / Polars schema names mirror the public struct field name + // (matches the Rust / Python / TypeScript / Go surfaces). For most + // ticks `name` and `field` coincide; the divergence appears on + // `OptionContract` where the wire ships `root` but the public + // surface emits `symbol` per the v3 vendor migration guide. writeln!( out, " Field::new(\"{name}\", {dt}, false),", - name = column.name + name = column.field ) .unwrap(); } @@ -283,10 +288,12 @@ fn render_polars_impl(type_name: &str, def: &TickTypeDef) -> String { // zero-row DataFrame with the typed columns intact. out.push_str(" DataFrame::new(n, vec![\n"); for column in &def.columns { + // Polars Series name mirrors the public struct field name — + // same justification as the Arrow `Field::new` emitter above. writeln!( out, " Series::new(PlSmallStr::from_static(\"{name}\"), col_{field}).into(),", - name = column.name, + name = column.field, field = column.field ) .unwrap(); diff --git a/crates/thetadatadx/src/decode.rs b/crates/thetadatadx/src/decode.rs index 2a364e36..c7731668 100644 --- a/crates/thetadatadx/src/decode.rs +++ b/crates/thetadatadx/src/decode.rs @@ -734,8 +734,11 @@ pub fn parse_option_contracts_v3( // Same schema-drift guard as the generated parsers: "no contracts today" // is legitimate, but a rows-present response missing the required `root` - // column is a silent data-loss trap. - let root_idx = match find_header(&h, "root") { + // column is a silent data-loss trap. The wire column is still named + // `root` (or `symbol` via the v3 alias in `decode::HEADER_ALIASES`); the + // `symbol` binding here is the public-API field name documented in the + // v3 vendor migration guide. + let symbol_idx = match find_header(&h, "root") { Some(i) => i, None => { if table.data_table.is_empty() { @@ -756,7 +759,7 @@ pub fn parse_option_contracts_v3( .data_table .iter() .map(|row| { - let root = row_text(row, root_idx)?.unwrap_or_default(); + let symbol = row_text(row, symbol_idx)?.unwrap_or_default(); // Expiration: `Number` carries YYYYMMDD directly; `Text` carries // an ISO "2026-04-13" that we parse here. `NullValue` → 0 (legit @@ -820,7 +823,7 @@ pub fn parse_option_contracts_v3( }; Ok(OptionContract { - root, + symbol, expiration, strike, right, diff --git a/crates/thetadatadx/src/flatfiles/decoded.rs b/crates/thetadatadx/src/flatfiles/decoded.rs index 1da5b97c..55e6de35 100644 --- a/crates/thetadatadx/src/flatfiles/decoded.rs +++ b/crates/thetadatadx/src/flatfiles/decoded.rs @@ -236,8 +236,8 @@ pub(crate) fn decode_to_memory(raw_path: &Path, sec: SecType) -> Result, /// Strike in vendor units (1/1000 of a dollar). `None` for stocks. @@ -49,7 +49,7 @@ impl FlatFileRow { /// `Price.price_type`). `None` means the schema has no PRICE_TYPE /// column, so price-bearing values are emitted as raw integers. pub(crate) fn from_decoded( - root: &str, + symbol: &str, expiration: Option, strike: Option, right: Option, @@ -75,7 +75,7 @@ impl FlatFileRow { fields.push((dt.name().into_owned(), cell)); } Self { - root: root.to_string(), + symbol: symbol.to_string(), expiration, strike, right, diff --git a/crates/thetadatadx/src/flatfiles/index.rs b/crates/thetadatadx/src/flatfiles/index.rs index 54daf70e..9fa72e28 100644 --- a/crates/thetadatadx/src/flatfiles/index.rs +++ b/crates/thetadatadx/src/flatfiles/index.rs @@ -108,10 +108,10 @@ pub(crate) fn parse_header(blob: &[u8]) -> Result { /// One INDEX entry's contract key + DATA-section pointer. #[derive(Debug, Clone)] pub(crate) struct IndexEntry { - /// UTF-8 root symbol (e.g. `"AAPL"`, `"SPY"`, `"ABBV"`). - pub(crate) root: String, + /// UTF-8 ticker symbol (e.g. `"AAPL"`, `"SPY"`, `"ABBV"`). + pub(crate) symbol: String, /// Option expiration (YYYYMMDD), or `None` for stock entries. - pub(crate) exp: Option, + pub(crate) expiration: Option, /// Strike price in tenths of a cent, or `None` for stocks. pub(crate) strike: Option, /// `'C'` or `'P'` for options, `None` for stocks. @@ -194,8 +194,8 @@ fn parse_one_entry(cur: &mut Cursor<&[u8]>, sec: SecType) -> Result { - buf.push_str(&entry.root); + buf.push_str(&entry.symbol); buf.push(','); - let _ = write!(buf, "{}", entry.exp.unwrap_or(0)); + let _ = write!(buf, "{}", entry.expiration.unwrap_or(0)); buf.push(','); let _ = write!(buf, "{}", entry.strike.unwrap_or(0)); buf.push(','); @@ -104,7 +104,7 @@ fn append_csv_prefix(buf: &mut String, entry: &IndexEntry, sec: SecType) { buf.push(','); } SecType::Stock => { - buf.push_str(&entry.root); + buf.push_str(&entry.symbol); buf.push(','); } } @@ -148,8 +148,10 @@ impl RowSink for CsvSink { fn write_header(&mut self) -> Result<(), Error> { self.line.clear(); match self.sec { - SecType::Option | SecType::Index => self.line.push_str("root,expiration,strike,right,"), - SecType::Stock => self.line.push_str("root,"), + SecType::Option | SecType::Index => { + self.line.push_str("symbol,expiration,strike,right,"); + } + SecType::Stock => self.line.push_str("symbol,"), } for (n, &i) in self.data_idx.iter().enumerate() { if n > 0 { @@ -237,12 +239,12 @@ impl RowSink for JsonlSink { match self.sec { SecType::Option | SecType::Index => { obj.insert( - "root".into(), - serde_json::Value::String(row.entry.root.clone()), + "symbol".into(), + serde_json::Value::String(row.entry.symbol.clone()), ); obj.insert( "expiration".into(), - serde_json::Value::Number(row.entry.exp.unwrap_or(0).into()), + serde_json::Value::Number(row.entry.expiration.unwrap_or(0).into()), ); obj.insert( "strike".into(), @@ -255,8 +257,8 @@ impl RowSink for JsonlSink { } SecType::Stock => { obj.insert( - "root".into(), - serde_json::Value::String(row.entry.root.clone()), + "symbol".into(), + serde_json::Value::String(row.entry.symbol.clone()), ); } } diff --git a/crates/thetadatadx/src/fpss/decode.rs b/crates/thetadatadx/src/fpss/decode.rs index 15a0814e..250bf5fc 100644 --- a/crates/thetadatadx/src/fpss/decode.rs +++ b/crates/thetadatadx/src/fpss/decode.rs @@ -35,9 +35,9 @@ use super::reconnect_delay; /// real contract whose root is merely absent or transiently empty. static EMPTY_CONTRACT: std::sync::LazyLock> = std::sync::LazyLock::new(|| { Arc::new(Contract { - root: String::new(), + symbol: String::new(), sec_type: tdbe::types::enums::SecType::Unknown, - exp_date: None, + expiration: None, is_call: None, strike: None, }) @@ -124,7 +124,7 @@ pub(super) fn decode_frame( // Wrap the parsed contract in Arc once on insert. Every // subsequent data event refcount-clones this Arc, so the // only `Contract::clone` (and therefore the only - // `String::clone` of `contract.root`) happens here — + // `String::clone` of `contract.symbol`) happens here — // at most once per contract_id per session. let arc_contract: Arc = Arc::new(contract); // Insert into thread-local cache (zero-lock hot-path lookups). @@ -975,7 +975,7 @@ mod tests { match primary.expect("ContractAssigned must emit a primary event") { FpssEvent::Control(FpssControl::ContractAssigned { id, contract }) => { assert_eq!(id, 777); - assert_eq!(contract.root, "AAPL"); + assert_eq!(contract.symbol, "AAPL"); // The Arc inside the event, the Arc in the shared map, and // the Arc in the thread-local cache must all point at the // SAME Contract heap cell — a different pointer would mean @@ -1047,9 +1047,9 @@ mod tests { ); match primary.expect("Quote must emit a primary event") { FpssEvent::Data(FpssData::Quote { contract, .. }) => { - assert_eq!(contract.root, "AAPL"); + assert_eq!(contract.symbol, "AAPL"); // Arc::ptr_eq proves both events share the SAME heap - // allocation — `assert_eq!(contract.root, "AAPL")` alone + // allocation — `assert_eq!(contract.symbol, "AAPL")` alone // only checks that both events carry the same *value*, // which a regression to per-event Contract::clone would // still pass. Pointer equality pins down the exact @@ -1126,7 +1126,7 @@ mod tests { "missing contract_id must surface sec_type = Unknown" ); assert!( - contract.root.is_empty(), + contract.symbol.is_empty(), "empty-contract sentinel must also have an empty root" ); // Same Arc as EMPTY_CONTRACT — the hot path never @@ -1249,7 +1249,7 @@ mod tests { "post-Restart tick on known-but-cleared ID must surface Unknown" ); assert_ne!( - contract.root, "SEED", + contract.symbol, "SEED", "post-Restart decoder must NOT resurrect the pre-restart Contract" ); } diff --git a/crates/thetadatadx/src/fpss/events.rs b/crates/thetadatadx/src/fpss/events.rs index 687c6d49..d4011843 100644 --- a/crates/thetadatadx/src/fpss/events.rs +++ b/crates/thetadatadx/src/fpss/events.rs @@ -28,7 +28,7 @@ use super::protocol::Contract; /// placeholder. Detect it via /// `contract.sec_type == tdbe::types::enums::SecType::Unknown` — /// this is the canonical check documented in `fpss::decode`. The -/// secondary `contract.root.is_empty()` check is kept for +/// secondary `contract.symbol.is_empty()` check is kept for /// backwards-compatibility, but the `SecType::Unknown` match survives /// future contract-root relaxations (e.g. unicode roots, numeric-prefix /// tickers) where an empty root might coincidentally appear on a real @@ -314,7 +314,7 @@ mod tests { .. }) => { assert_eq!(*contract_id, 42); - assert_eq!(contract.root, "AAPL"); + assert_eq!(contract.symbol, "AAPL"); assert!((*price - 150.25).abs() < f64::EPSILON); } other => panic!("expected Data(Trade), got {other:?}"), diff --git a/crates/thetadatadx/src/fpss/mod.rs b/crates/thetadatadx/src/fpss/mod.rs index 2ee3d225..91324b31 100644 --- a/crates/thetadatadx/src/fpss/mod.rs +++ b/crates/thetadatadx/src/fpss/mod.rs @@ -34,11 +34,11 @@ //! // Push to your own queue for heavy processing. //! match event { //! FpssEvent::Data(FpssData::Quote { contract, bid, ask, .. }) => { -//! let _root = &contract.root; // symbol / option root +//! let _root = &contract.symbol; // symbol / option root //! let _ = (bid, ask); // f64 prices //! } //! FpssEvent::Data(FpssData::Trade { contract, price, size, .. }) => { -//! let _root = &contract.root; +//! let _root = &contract.symbol; //! let _ = (price, size); //! } //! FpssEvent::Control(_) => { /* lifecycle */ } diff --git a/crates/thetadatadx/src/fpss/protocol.rs b/crates/thetadatadx/src/fpss/protocol.rs index 09154300..ef640151 100644 --- a/crates/thetadatadx/src/fpss/protocol.rs +++ b/crates/thetadatadx/src/fpss/protocol.rs @@ -85,12 +85,14 @@ pub const READ_TIMEOUT_MS: u64 = 10_000; /// Source: `Contract.java` — `toBytes()`, `fromBytes()`, constructor overloads. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Contract { - /// Root ticker symbol (ASCII, max ~6 chars in practice). - pub root: String, + /// Ticker symbol (ASCII, max ~6 chars in practice). Named `symbol` to + /// match the v3 vendor surface; the wire codec still encodes it as the + /// root field per `Contract.toBytes()` parity. + pub symbol: String, /// Security type. pub sec_type: SecType, /// Expiration date as YYYYMMDD integer (options only). - pub exp_date: Option, + pub expiration: Option, /// True = call, false = put (options only). pub is_call: Option, /// Strike price in fixed-point (options only). The encoding matches @@ -102,33 +104,33 @@ impl Contract { /// Create a stock contract. /// /// Source: `Contract(String root)` constructor in `Contract.java` — defaults to STOCK. - pub fn stock(root: impl Into) -> Self { + pub fn stock(symbol: impl Into) -> Self { Self { - root: root.into(), + symbol: symbol.into(), sec_type: SecType::Stock, - exp_date: None, + expiration: None, is_call: None, strike: None, } } /// Create an index contract. - pub fn index(root: impl Into) -> Self { + pub fn index(symbol: impl Into) -> Self { Self { - root: root.into(), + symbol: symbol.into(), sec_type: SecType::Index, - exp_date: None, + expiration: None, is_call: None, strike: None, } } /// Create a rate contract. - pub fn rate(root: impl Into) -> Self { + pub fn rate(symbol: impl Into) -> Self { Self { - root: root.into(), + symbol: symbol.into(), sec_type: SecType::Rate, - exp_date: None, + expiration: None, is_call: None, strike: None, } @@ -137,8 +139,8 @@ impl Contract { /// Create an option contract. /// /// # Arguments - /// - `root`: Underlying ticker (e.g., `"AAPL"`) - /// - `exp_date`: Expiration as `"YYYYMMDD"` (e.g., `"20260320"`) + /// - `symbol`: Underlying ticker (e.g., `"AAPL"`) + /// - `expiration`: Expiration as `"YYYYMMDD"` (e.g., `"20260320"`) /// - `strike`: Strike price in dollars as string (e.g., `"550"`) /// - `right`: option right — accepts `"call"`/`"put"`/`"C"`/`"P"` /// (case-insensitive). FPSS per-contract subscriptions cannot carry @@ -146,19 +148,19 @@ impl Contract { /// /// # Errors /// - /// Returns [`Error::Config`] if `exp_date` is not a valid integer date, + /// Returns [`Error::Config`] if `expiration` is not a valid integer date, /// if `right` cannot be parsed to a single side, if `strike` is not a /// valid f64, or if `strike * 1000` would overflow `i32`. pub fn option( - root: impl Into, - exp_date: &str, + symbol: impl Into, + expiration: &str, strike: &str, right: &str, ) -> Result { - let exp: i32 = exp_date + let exp: i32 = expiration .replace('-', "") .parse() - .map_err(|e| Error::Config(format!("invalid expiration date {exp_date:?}: {e}")))?; + .map_err(|e| Error::Config(format!("invalid expiration date {expiration:?}: {e}")))?; let is_call = tdbe::right::parse_right_strict(right)? .as_is_call() .ok_or_else(|| { @@ -180,9 +182,9 @@ impl Contract { #[allow(clippy::cast_possible_truncation)] let strike_raw = strike_scaled as i32; Ok(Self { - root: root.into(), + symbol: symbol.into(), sec_type: SecType::Option, - exp_date: Some(exp), + expiration: Some(exp), is_call: Some(is_call), strike: Some(strike_raw), }) @@ -192,11 +194,16 @@ impl Contract { /// /// Prefer [`Contract::option`] for user-facing code. This constructor is for the /// drop-in REST/WS server which must match the Java terminal's contract format. - pub fn option_raw(root: impl Into, exp_date: i32, is_call: bool, strike: i32) -> Self { + pub fn option_raw( + symbol: impl Into, + expiration: i32, + is_call: bool, + strike: i32, + ) -> Self { Self { - root: root.into(), + symbol: symbol.into(), sec_type: SecType::Option, - exp_date: Some(exp_date), + expiration: Some(expiration), is_call: Some(is_call), strike: Some(strike), } @@ -207,7 +214,7 @@ impl Contract { /// /// Full-type subscriptions are not addressed by a real `Contract`, but the /// failure list keeps a homogeneous `(SubscriptionKind, Contract)` shape - /// so callers can iterate the list with one match arm. `root` is empty + /// so callers can iterate the list with one match arm. `symbol` is empty /// and option fields are `None`, which mirrors the lack of per-contract /// addressability for a full-type subscription. Operators see the /// original `SecType` via the per-failure `tracing::warn!` line emitted @@ -215,9 +222,9 @@ impl Contract { #[must_use] pub fn full_type_marker(sec_type: SecType) -> Self { Self { - root: String::new(), + symbol: String::new(), sec_type, - exp_date: None, + expiration: None, is_call: None, strike: None, } @@ -336,7 +343,7 @@ impl Contract { // maps to 2099 rather than 1999. The FPSS live feed only // ships contracts with future expirations, so pre-2000 OCC // symbols cannot reach this parser over the wire. - let exp_date: i32 = 20_000_000 + yymmdd; + let expiration: i32 = 20_000_000 + yymmdd; // Right byte. let right_byte = bytes[12]; @@ -367,9 +374,9 @@ impl Contract { })?; Ok(Self { - root, + symbol: root, sec_type: SecType::Option, - exp_date: Some(exp_date), + expiration: Some(expiration), is_call: Some(is_call), strike: Some(strike), }) @@ -436,20 +443,24 @@ impl Contract { /// Returns [`Error::Config`] if the root is empty or longer than 16 /// bytes. The 16-byte limit matches Java's `Contract.toBytes()`. pub fn validate(&self) -> Result<(), Error> { - let len = self.root.len(); + let len = self.symbol.len(); if len == 0 { - return Err(Error::Config("contract root is empty".into())); + return Err(Error::Config("contract symbol is empty".into())); } if len > 16 { return Err(Error::Config(format!( - "contract root too long: {len} bytes (max 16 to match Java Contract.toBytes())" + "contract symbol too long: {len} bytes (max 16 to match Java Contract.toBytes())" ))); } Ok(()) } fn encode_unchecked(&self) -> Vec { - let root_bytes = self.root.as_bytes(); + // Local names mirror the wire spec (`Contract.java::toBytes`): + // the wire field is "root_len / root", and keeping the byte-level + // codec named that way keeps this file diffing cleanly against the + // upstream binary protocol. The struct binding is `symbol`. + let root_bytes = self.symbol.as_bytes(); let root_len = u8::try_from(root_bytes.len()).expect("validate() bounds root_len to <= 16"); let is_option = self.sec_type == SecType::Option; @@ -476,7 +487,7 @@ impl Contract { if is_option { // exp_date: i32 big-endian - buf.extend_from_slice(&self.exp_date.unwrap_or(0).to_be_bytes()); + buf.extend_from_slice(&self.expiration.unwrap_or(0).to_be_bytes()); // is_call: u8 (1 = call, 0 = put) buf.push(u8::from(self.is_call.unwrap_or(false))); // strike: i32 big-endian @@ -550,9 +561,9 @@ impl Contract { Ok(( Contract { - root, + symbol: root, sec_type, - exp_date: Some(exp_date), + expiration: Some(exp_date), is_call: Some(is_call), strike: Some(strike), }, @@ -561,9 +572,9 @@ impl Contract { } else { Ok(( Contract { - root, + symbol: root, sec_type, - exp_date: None, + expiration: None, is_call: None, strike: None, }, @@ -585,14 +596,14 @@ impl std::fmt::Display for Contract { write!( f, "{} {} {} {} {}", - self.root, + self.symbol, self.sec_type.as_str(), - self.exp_date.unwrap_or(0), + self.expiration.unwrap_or(0), right, self.strike.unwrap_or(0), ) } - _ => write!(f, "{} {}", self.root, self.sec_type.as_str()), + _ => write!(f, "{} {}", self.symbol, self.sec_type.as_str()), } } } @@ -610,7 +621,7 @@ impl std::str::FromStr for Contract { /// # use std::str::FromStr; /// # use thetadatadx::fpss::protocol::Contract; /// let c = "AAPL".parse::().unwrap(); - /// assert_eq!(c.root, "AAPL"); + /// assert_eq!(c.symbol, "AAPL"); /// ``` /// 2. **OCC-21 option identifier**. 21 ASCII characters: /// `[root (6, space-padded)] [YYMMDD (6)] [C|P (1)] [strike (8, 1/1000$)]`. @@ -625,8 +636,8 @@ impl std::str::FromStr for Contract { /// # use std::str::FromStr; /// # use thetadatadx::fpss::protocol::Contract; /// let c = "SPY 260417C00550000".parse::().unwrap(); - /// assert_eq!(c.root, "SPY"); - /// assert_eq!(c.exp_date, Some(20_260_417)); + /// assert_eq!(c.symbol, "SPY"); + /// assert_eq!(c.expiration, Some(20_260_417)); /// assert_eq!(c.is_call, Some(true)); /// assert_eq!(c.strike, Some(550_000)); /// ``` @@ -1002,7 +1013,7 @@ mod tests { let (parsed, consumed) = Contract::from_bytes(&bytes).unwrap(); assert_eq!(consumed, 15); assert_eq!(parsed, c); - assert_eq!(parsed.exp_date, Some(20261218)); + assert_eq!(parsed.expiration, Some(20261218)); assert_eq!(parsed.is_call, Some(true)); assert_eq!(parsed.strike, Some(60000)); } @@ -1012,7 +1023,7 @@ mod tests { let c = Contract::index("SPX"); let bytes = c.to_bytes(); let (parsed, _) = Contract::from_bytes(&bytes).unwrap(); - assert_eq!(parsed.root, "SPX"); + assert_eq!(parsed.symbol, "SPX"); assert_eq!(parsed.sec_type, SecType::Index); } @@ -1270,9 +1281,9 @@ mod tests { fn from_str_bare_root_stock() { use std::str::FromStr; let c = Contract::from_str("AAPL").unwrap(); - assert_eq!(c.root, "AAPL"); + assert_eq!(c.symbol, "AAPL"); assert_eq!(c.sec_type, SecType::Stock); - assert!(c.exp_date.is_none()); + assert!(c.expiration.is_none()); assert!(c.is_call.is_none()); assert!(c.strike.is_none()); } @@ -1281,7 +1292,7 @@ mod tests { fn from_str_bare_root_short_ticker() { use std::str::FromStr; let c = Contract::from_str("A").unwrap(); - assert_eq!(c.root, "A"); + assert_eq!(c.symbol, "A"); assert_eq!(c.sec_type, SecType::Stock); } @@ -1290,7 +1301,7 @@ mod tests { use std::str::FromStr; // BRK.A style tickers must parse as stock roots. let c = Contract::from_str("BRK.A").unwrap(); - assert_eq!(c.root, "BRK.A"); + assert_eq!(c.symbol, "BRK.A"); assert_eq!(c.sec_type, SecType::Stock); } @@ -1298,7 +1309,7 @@ mod tests { fn from_str_bare_root_trims_surrounding_whitespace() { use std::str::FromStr; let c = Contract::from_str(" SPY ").unwrap(); - assert_eq!(c.root, "SPY"); + assert_eq!(c.symbol, "SPY"); } #[test] @@ -1306,9 +1317,9 @@ mod tests { use std::str::FromStr; // SPY (4 chars -> 6 chars padded) 26-04-17 Call 550.00. let c = Contract::from_str("SPY 260417C00550000").unwrap(); - assert_eq!(c.root, "SPY"); + assert_eq!(c.symbol, "SPY"); assert_eq!(c.sec_type, SecType::Option); - assert_eq!(c.exp_date, Some(20_260_417)); + assert_eq!(c.expiration, Some(20_260_417)); assert_eq!(c.is_call, Some(true)); assert_eq!(c.strike, Some(550_000)); } @@ -1318,9 +1329,9 @@ mod tests { use std::str::FromStr; // QQQ 26-06-20 Put 350.00. let c = Contract::from_str("QQQ 260620P00350000").unwrap(); - assert_eq!(c.root, "QQQ"); + assert_eq!(c.symbol, "QQQ"); assert_eq!(c.is_call, Some(false)); - assert_eq!(c.exp_date, Some(20_260_620)); + assert_eq!(c.expiration, Some(20_260_620)); assert_eq!(c.strike, Some(350_000)); } @@ -1329,8 +1340,8 @@ mod tests { use std::str::FromStr; // The exact example from the spec. let c = Contract::from_str("AAPL 260417C00550000").unwrap(); - assert_eq!(c.root, "AAPL"); - assert_eq!(c.exp_date, Some(20_260_417)); + assert_eq!(c.symbol, "AAPL"); + assert_eq!(c.expiration, Some(20_260_417)); assert_eq!(c.is_call, Some(true)); assert_eq!(c.strike, Some(550_000)); } @@ -1340,8 +1351,8 @@ mod tests { use std::str::FromStr; // Full six-char root: no spaces in the root field. let c = Contract::from_str("ABCDEF260417C00550000").unwrap(); - assert_eq!(c.root, "ABCDEF"); - assert_eq!(c.exp_date, Some(20_260_417)); + assert_eq!(c.symbol, "ABCDEF"); + assert_eq!(c.expiration, Some(20_260_417)); assert_eq!(c.strike, Some(550_000)); } @@ -1458,7 +1469,7 @@ mod tests { fn from_str_accepts_seven_char_root() { use std::str::FromStr; let c = Contract::from_str("ABCDEFG").expect("7-char root must parse"); - assert_eq!(c.root, "ABCDEFG"); + assert_eq!(c.symbol, "ABCDEFG"); assert_eq!(c.sec_type, SecType::Stock); } @@ -1469,7 +1480,7 @@ mod tests { let sixteen = "AAAAAAAAAAAAAAAA"; assert_eq!(sixteen.len(), 16); let c = Contract::from_str(sixteen).expect("16-char root must parse"); - assert_eq!(c.root, sixteen); + assert_eq!(c.symbol, sixteen); assert_eq!(c.sec_type, SecType::Stock); } @@ -1484,7 +1495,7 @@ mod tests { let root: String = "A".repeat(n); let parsed = Contract::from_str(&root) .unwrap_or_else(|_| panic!("from_str must accept {n}-char root")); - assert_eq!(parsed.root, root); + assert_eq!(parsed.symbol, root); let wire = parsed.to_bytes(); let (decoded, consumed) = Contract::from_bytes(&wire) .unwrap_or_else(|_| panic!("from_bytes must decode {n}-char root")); @@ -1534,8 +1545,8 @@ mod tests { // with a trailing space, which shifted the right-byte into a // digit slot and either errored or decoded a different contract. assert_eq!(c20, c21, "20-char and 21-char forms must parse identically"); - assert_eq!(c20.root, "SPY"); - assert_eq!(c20.exp_date, Some(20_260_417)); + assert_eq!(c20.symbol, "SPY"); + assert_eq!(c20.expiration, Some(20_260_417)); assert_eq!(c20.is_call, Some(true)); assert_eq!(c20.strike, Some(550_000)); } @@ -1548,7 +1559,7 @@ mod tests { let twenty = "T 260417C00150000"; assert_eq!(twenty.len(), 20); let c = Contract::from_str(twenty).expect("20-char OCC-21 with short root must repair"); - assert_eq!(c.root, "T"); + assert_eq!(c.symbol, "T"); assert_eq!(c.is_call, Some(true)); assert_eq!(c.strike, Some(150_000)); } @@ -1563,8 +1574,12 @@ mod tests { // the live FPSS feed ships only live contracts so a pre-2000 // OCC symbol cannot reach this parser over the wire. let c = Contract::from_str("AAPL 990101C00100000").expect("YY=99 must parse"); - assert_eq!(c.exp_date, Some(20_990_101), "YY=99 must map to 2099-01-01"); - assert_eq!(c.root, "AAPL"); + assert_eq!( + c.expiration, + Some(20_990_101), + "YY=99 must map to 2099-01-01" + ); + assert_eq!(c.symbol, "AAPL"); assert_eq!(c.is_call, Some(true)); assert_eq!(c.strike, Some(100_000)); } @@ -1593,6 +1608,6 @@ mod tests { // Industry-practice single-dot compound tickers MUST still // parse — keeping BRK.A / BRK.B / RDS.A reachable. let c = Contract::from_str("BRK.B").expect("single-dot root must parse"); - assert_eq!(c.root, "BRK.B"); + assert_eq!(c.symbol, "BRK.B"); } } diff --git a/crates/thetadatadx/src/frames_generated.rs b/crates/thetadatadx/src/frames_generated.rs index a4589276..0fbcce68 100644 --- a/crates/thetadatadx/src/frames_generated.rs +++ b/crates/thetadatadx/src/frames_generated.rs @@ -1292,24 +1292,24 @@ impl crate::frames::TicksPolarsExt for [tdbe::types::tick::OpenInterestTick] { impl crate::frames::TicksArrowExt for [tdbe::types::tick::OptionContract] { fn to_arrow(&self) -> ::core::result::Result { let n = self.len(); - let mut col_root: Vec = Vec::with_capacity(n); + let mut col_symbol: Vec = Vec::with_capacity(n); let mut col_expiration: Vec = Vec::with_capacity(n); let mut col_strike: Vec = Vec::with_capacity(n); let mut col_right: Vec = Vec::with_capacity(n); for t in self { - col_root.push(t.root.clone()); + col_symbol.push(t.symbol.clone()); col_expiration.push(t.expiration); col_strike.push(t.strike); col_right.push(if t.is_call() { "C".to_string() } else if t.is_put() { "P".to_string() } else { String::new() }); } let schema = Arc::new(ArrowSchema::new(vec![ - Field::new("root", DataType::Utf8, false), + Field::new("symbol", DataType::Utf8, false), Field::new("expiration", DataType::Int32, false), Field::new("strike", DataType::Float64, false), Field::new("right", DataType::Utf8, false), ])); let columns: Vec = vec![ - Arc::new(StringArray::from(col_root)) as ArrayRef, + Arc::new(StringArray::from(col_symbol)) as ArrayRef, Arc::new(Int32Array::from(col_expiration)) as ArrayRef, Arc::new(Float64Array::from(col_strike)) as ArrayRef, Arc::new(StringArray::from(col_right)) as ArrayRef, @@ -1323,18 +1323,18 @@ impl crate::frames::TicksArrowExt for [tdbe::types::tick::OptionContract] { impl crate::frames::TicksPolarsExt for [tdbe::types::tick::OptionContract] { fn to_polars(&self) -> PolarsResult { let n = self.len(); - let mut col_root: Vec = Vec::with_capacity(n); + let mut col_symbol: Vec = Vec::with_capacity(n); let mut col_expiration: Vec = Vec::with_capacity(n); let mut col_strike: Vec = Vec::with_capacity(n); let mut col_right: Vec = Vec::with_capacity(n); for t in self { - col_root.push(t.root.clone()); + col_symbol.push(t.symbol.clone()); col_expiration.push(t.expiration); col_strike.push(t.strike); col_right.push(if t.is_call() { "C".to_string() } else if t.is_put() { "P".to_string() } else { String::new() }); } DataFrame::new(n, vec![ - Series::new(PlSmallStr::from_static("root"), col_root).into(), + Series::new(PlSmallStr::from_static("symbol"), col_symbol).into(), Series::new(PlSmallStr::from_static("expiration"), col_expiration).into(), Series::new(PlSmallStr::from_static("strike"), col_strike).into(), Series::new(PlSmallStr::from_static("right"), col_right).into(), diff --git a/crates/thetadatadx/src/unified.rs b/crates/thetadatadx/src/unified.rs index 8120da58..dbaf3afe 100644 --- a/crates/thetadatadx/src/unified.rs +++ b/crates/thetadatadx/src/unified.rs @@ -783,7 +783,7 @@ mod tests { &per_contract, &full_type, |_kind, contract| { - if contract.root == "MSFT" { + if contract.symbol == "MSFT" { Err(Error::Fpss { kind: crate::error::FpssErrorKind::Disconnected, message: "injected: MSFT subscribe rejected".to_string(), @@ -844,9 +844,9 @@ mod tests { assert_eq!(*kind, SubscriptionKind::OpenInterest); assert_eq!(contract.sec_type, SecType::Option); assert!( - contract.root.is_empty(), + contract.symbol.is_empty(), "full-type marker carries empty root, got {:?}", - contract.root + contract.symbol ); } diff --git a/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs b/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs index 728bb508..70da6800 100644 --- a/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs +++ b/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs @@ -121,7 +121,7 @@ fn synthetic_option_blob() -> Vec { /// /// Manual derivation: /// - header columns (price_type column suppressed by the writer): -/// `root,expiration,strike,right,ms_of_day,bid,date`. +/// `symbol,expiration,strike,right,ms_of_day,bid,date`. /// - row 1: contract prefix `SPY,20240315,580000,C` plus /// `34200000,123.45,20240315`. Bid=12345 with price_type=8 → /// `12345 / 10^(10-8) = 12345 / 100 = 123.45`. @@ -129,7 +129,7 @@ fn synthetic_option_blob() -> Vec { /// 34200100; bid delta +5 → 12350 → 123.5 (Rust f64 Display drops /// the trailing zero); date carried forward → 20240315. const EXPECTED_CSV: &str = "\ -root,expiration,strike,right,ms_of_day,bid,date +symbol,expiration,strike,right,ms_of_day,bid,date SPY,20240315,580000,C,34200000,123.45,20240315 SPY,20240315,580000,C,34200100,123.5,20240315 "; diff --git a/crates/thetadatadx/tick_schema.toml b/crates/thetadatadx/tick_schema.toml index c7d26180..be2be237 100644 --- a/crates/thetadatadx/tick_schema.toml +++ b/crates/thetadatadx/tick_schema.toml @@ -717,12 +717,12 @@ ts_class_vec = "interest_rate_ticks_to_class_vec" pyclass = "InterestRateTick" [types.OptionContract] -doc = "Option contract -- 4 fields. Contract specification.\n\nCannot be `Copy` because of the `String` root field." +doc = "Option contract -- 4 fields. Contract specification.\n\nCannot be `Copy` because of the `String` symbol field." copy = false parser = "parse_option_contracts" required = ["root"] columns = [ - { name = "root", field = "root", type = "String" }, + { name = "root", field = "symbol", type = "String" }, { name = "expiration", field = "expiration", type = "i32" }, { name = "strike", field = "strike", type = "price" }, { name = "right", field = "right", type = "i32" }, diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index bcd4bfb9..7102e415 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.28] - 2026-05-06 + +### Breaking + +- **`Contract`, `OptionContract`, `FlatFileRow`, and `IndexEntry` rename + `root` to `symbol` and `exp_date` to `expiration` to match the v3 + vendor surface documented in the [v2 → v3 migration guide][v3-mig]. + The wire codec is unchanged — `Contract::to_bytes` / + `Contract::from_bytes` still serialize the field as `root` per + `Contract.java` parity, and the FLATFILES decoder still resolves both + v2 (`root`) and v3 (`symbol`) response columns through the existing + `decode::HEADER_ALIASES`. Per-language renames: + + - **Rust** (`thetadatadx::fpss::protocol::Contract`, + `tdbe::types::tick::OptionContract`, + `thetadatadx::flatfiles::FlatFileRow`): + - `Contract.root` → `Contract.symbol` + - `Contract.exp_date` → `Contract.expiration` + - `Contract::stock(root)` → `Contract::stock(symbol)` + - `Contract::index(root)` → `Contract::index(symbol)` + - `Contract::rate(root)` → `Contract::rate(symbol)` + - `Contract::option(root, exp_date, …)` → + `Contract::option(symbol, expiration, …)` + - `Contract::option_raw(root, exp_date, …)` → + `Contract::option_raw(symbol, expiration, …)` + - `OptionContract.root` → `OptionContract.symbol` + - `FlatFileRow.root` → `FlatFileRow.symbol` + - **Python** (`thetadatadx.Contract`, `thetadatadx.OptionContract`): + `contract.root` / `contract.exp_date` → + `contract.symbol` / `contract.expiration`; + `OptionContract(root=…)` constructor keyword → `symbol=…`. + - **TypeScript** (`Contract`, `OptionContract`): + `contract.root` / `contract.expDate` → + `contract.symbol` / `contract.expiration`. + - **Go** (`thetadatadx.Contract`, `thetadatadx.OptionContract`): + `c.Root` / `c.ExpDate` → `c.Symbol` / `c.Expiration`. + - **C++** (`OptionContract`, `TdxContract`, `TdxOptionContract`): + `c.root` / `c.exp_date` / `c.has_exp_date` → + `c.symbol` / `c.expiration` / `c.has_expiration`. + - **C ABI**: `TdxContract.root` → `TdxContract.symbol`, + `TdxContract.exp_date` → `TdxContract.expiration`, + `TdxContract.has_exp_date` → `TdxContract.has_expiration`, + `TdxOptionContract.root` → `TdxOptionContract.symbol`. + - **FLATFILES CSV / JSONL**: contract-prefix headers and JSON keys + change from `root,expiration,strike,right,…` to + `symbol,expiration,strike,right,…`. Stock blobs go from `root,…` to + `symbol,…`. The vendor's response columns are unchanged; only the + SDK's emitted file headers change. + - **REST / WebSocket / MCP outputs** in `tools/server` and + `tools/mcp` emit `"symbol"` / `"expiration"` keys on every contract + payload (option lists, FPSS event contracts, FLATFILES rows). + +[v3-mig]: https://docs.thetadata.us/Articles/Getting-Started/v2-migration-guide.html#_5-parameter-mapping + +### Changed + +- Workspace 8.0.27 → 8.0.28, tdbe 0.12.7 → 0.12.8. The tdbe bump rides + the regenerated `OptionContract.symbol` field in + `crates/tdbe/src/types/tick_generated.rs`; every other change ships + as patch deltas off the existing v8 line per repo policy. +- `tools/cli` raw column header for `OptionContract` is `symbol` + instead of `root`, sourced from `tick_schema.toml::field` so future + schema renames flow through the CLI without a helper edit. + ## [8.0.27] - 2026-05-06 ### Changed diff --git a/docs-site/docs/getting-started/quickstart.md b/docs-site/docs/getting-started/quickstart.md index ef3443c5..606fa0ac 100644 --- a/docs-site/docs/getting-started/quickstart.md +++ b/docs-site/docs/getting-started/quickstart.md @@ -221,10 +221,10 @@ async fn main() -> Result<(), thetadatadx::Error> { tdx.start_streaming(|event: &FpssEvent| match event { FpssEvent::Data(FpssData::Quote { contract, bid, ask, .. }) => { - println!("Quote: {} {bid:.2}/{ask:.2}", contract.root); + println!("Quote: {} {bid:.2}/{ask:.2}", contract.symbol); } FpssEvent::Data(FpssData::Trade { contract, price, size, .. }) => { - println!("Trade: {} {price:.2} x {size}", contract.root); + println!("Trade: {} {price:.2} x {size}", contract.symbol); } _ => {} })?; diff --git a/docs-site/docs/getting-started/streaming.md b/docs-site/docs/getting-started/streaming.md index 7b311d44..4c6f4e3e 100644 --- a/docs-site/docs/getting-started/streaming.md +++ b/docs-site/docs/getting-started/streaming.md @@ -69,10 +69,10 @@ async fn main() -> Result<(), thetadatadx::Error> { tdx.start_streaming(|event: &FpssEvent| match event { FpssEvent::Data(FpssData::Quote { contract, bid, ask, .. }) => { - println!("Quote: {} {bid:.2}/{ask:.2}", contract.root); + println!("Quote: {} {bid:.2}/{ask:.2}", contract.symbol); } FpssEvent::Data(FpssData::Trade { contract, price, size, .. }) => { - println!("Trade: {} {price:.2} x {size}", contract.root); + println!("Trade: {} {price:.2} x {size}", contract.symbol); } _ => {} })?; diff --git a/docs-site/docs/historical/option/list/contracts.md b/docs-site/docs/historical/option/list/contracts.md index b43850c8..da365069 100644 --- a/docs-site/docs/historical/option/list/contracts.md +++ b/docs-site/docs/historical/option/list/contracts.md @@ -16,13 +16,13 @@ List all option contracts available for a given underlying symbol on a specific let data = tdx.option_list_contracts("TRADE", "SPY", "20260402").await?; for t in &data { println!("symbol={} expiration={} strike={:.2} right={}", - t.root, t.expiration, t.strike, t.right); + t.symbol, t.expiration, t.strike, t.right); } ``` ```python [Python] data = tdx.option_list_contracts("TRADE", "SPY", "20260402") for t in data: - print(f"symbol={t.root} expiration={t.expiration} strike={t.strike:.2f} right={t.right}") + print(f"symbol={t.symbol} expiration={t.expiration} strike={t.strike:.2f} right={t.right}") ``` ```typescript [TypeScript] const data = tdx.optionListContracts('TRADE', 'SPY', '20260402'); @@ -39,7 +39,7 @@ for _, t := range data { auto data = client.option_list_contracts("TRADE", "SPY", "20260402"); for (const auto& t : data) { printf("symbol=%s expiration=%d strike=%.2f right=%s\n", - t.root, t.expiration, t.strike, t.right); + t.symbol, t.expiration, t.strike, t.right); } ``` ::: diff --git a/docs-site/docs/streaming/connection.md b/docs-site/docs/streaming/connection.md index f50fdd44..d7e195a3 100644 --- a/docs-site/docs/streaming/connection.md +++ b/docs-site/docs/streaming/connection.md @@ -37,10 +37,10 @@ tdx.start_streaming(|event: &FpssEvent| { // Every data event carries an `Arc` — read the root ticker // directly, no contract-ID map lookup required. FpssEvent::Data(FpssData::Quote { contract, bid, ask, received_at_ns, .. }) => { - println!("Quote: {} bid={bid:.2} ask={ask:.2} rx={received_at_ns}ns", contract.root); + println!("Quote: {} bid={bid:.2} ask={ask:.2} rx={received_at_ns}ns", contract.symbol); } FpssEvent::Data(FpssData::Trade { contract, price, size, received_at_ns, .. }) => { - println!("Trade: {} price={price:.2} size={size} rx={received_at_ns}ns", contract.root); + println!("Trade: {} price={price:.2} size={size} rx={received_at_ns}ns", contract.symbol); } FpssEvent::Control(FpssControl::ContractAssigned { id, contract }) => { println!("Contract {id} = {contract}"); @@ -377,9 +377,9 @@ tdx.start_streaming(move |event: &FpssEvent| { FpssEvent::Control(FpssControl::ContractAssigned { id, contract }) => { contracts_clone.lock().unwrap().insert(*id, (**contract).clone()); } - // Preferred: read `contract.root` directly off the data event. + // Preferred: read `contract.symbol` directly off the data event. FpssEvent::Data(FpssData::Quote { contract, bid, ask, .. }) => { - println!("{}: bid={bid:.2} ask={ask:.2}", contract.root); + println!("{}: bid={bid:.2} ask={ask:.2}", contract.symbol); } _ => {} } diff --git a/docs-site/docs/streaming/events.md b/docs-site/docs/streaming/events.md index edb754d1..36038964 100644 --- a/docs-site/docs/streaming/events.md +++ b/docs-site/docs/streaming/events.md @@ -12,31 +12,31 @@ description: Process data and control events from the FPSS streaming connection tdx.start_streaming(|event: &FpssEvent| { match event { // --- Data events --- - // Each data variant carries an `Arc`, so `contract.root` + // Each data variant carries an `Arc`, so `contract.symbol` // (plus `.exp_date` / `.strike` / `.is_call` on options) is readable // inline — no contract-ID map lookup required. FpssEvent::Data(FpssData::Quote { contract, ms_of_day, bid, ask, bid_size, ask_size, received_at_ns, .. }) => { - println!("Quote: {} bid={bid:.2} ask={ask:.2} rx={received_at_ns}ns", contract.root); + println!("Quote: {} bid={bid:.2} ask={ask:.2} rx={received_at_ns}ns", contract.symbol); } FpssEvent::Data(FpssData::Trade { contract, price, size, sequence, received_at_ns, .. }) => { - println!("Trade: {} price={price:.2} size={size} seq={sequence}", contract.root); + println!("Trade: {} price={price:.2} size={size} seq={sequence}", contract.symbol); } FpssEvent::Data(FpssData::OpenInterest { contract, open_interest, received_at_ns, .. }) => { - println!("OI: {} oi={open_interest} rx={received_at_ns}ns", contract.root); + println!("OI: {} oi={open_interest} rx={received_at_ns}ns", contract.symbol); } FpssEvent::Data(FpssData::Ohlcvc { contract, open, high, low, close, volume, count, received_at_ns, .. }) => { // volume and count are i64 to avoid overflow on high-volume symbols - println!("OHLCVC: {} O={open:.2} H={high:.2} L={low:.2} C={close:.2} vol={volume} n={count}", contract.root); + println!("OHLCVC: {} O={open:.2} H={high:.2} L={low:.2} C={close:.2} vol={volume} n={count}", contract.symbol); } // --- Control events --- diff --git a/docs-site/docs/streaming/reconnection.md b/docs-site/docs/streaming/reconnection.md index 0c94970f..a4dabd22 100644 --- a/docs-site/docs/streaming/reconnection.md +++ b/docs-site/docs/streaming/reconnection.md @@ -203,7 +203,7 @@ async fn main() -> Result<(), thetadatadx::Error> { }) => { if let Some(c) = contracts_clone.lock().unwrap().get(contract_id) { println!("[QUOTE] {}: bid={bid:.2} ask={ask:.2} rx={received_at_ns}ns", - c.root); + c.symbol); } } FpssEvent::Data(FpssData::Trade { @@ -211,7 +211,7 @@ async fn main() -> Result<(), thetadatadx::Error> { }) => { if let Some(c) = contracts_clone.lock().unwrap().get(contract_id) { println!("[TRADE] {}: price={price:.2} size={size} rx={received_at_ns}ns", - c.root); + c.symbol); } } FpssEvent::Control(FpssControl::Disconnected { reason }) => { diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 2cba4fb1..4a6fcfb7 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thetadatadx-ffi" -version = "8.0.27" +version = "8.0.28" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -31,7 +31,7 @@ testing-panic-boundary = [] [dependencies] thetadatadx = { path = "../crates/thetadatadx" } -tdbe = { version = "0.12.7", path = "../crates/tdbe" } +tdbe = { version = "0.12.8", path = "../crates/tdbe" } tokio = { version = "1.52.1", features = ["rt-multi-thread"] } # Used by the FPSS streaming callback silent-drop observability path # (see `tdx_fpss_dropped_events` / `tdx_unified_dropped_events`). Keep diff --git a/ffi/src/fpss_event_converter.rs b/ffi/src/fpss_event_converter.rs index bdeeb283..2053b72c 100644 --- a/ffi/src/fpss_event_converter.rs +++ b/ffi/src/fpss_event_converter.rs @@ -20,19 +20,19 @@ pub(crate) fn fpss_event_to_ffi(event: &thetadatadx::fpss::FpssEvent) -> FfiBuff received_at_ns, .. }) => { - let contract_root_cstring = if contract.root.is_empty() { + let contract_symbol_cstring = if contract.symbol.is_empty() { None } else { - std::ffi::CString::new(contract.root.as_str()).ok() + std::ffi::CString::new(contract.symbol.as_str()).ok() }; - let contract_root_ptr = contract_root_cstring + let contract_symbol_ptr = contract_symbol_cstring .as_ref() .map_or(ptr::null(), |cs| cs.as_ptr()); let tdx_contract = TdxContract { - root: contract_root_ptr, + symbol: contract_symbol_ptr, sec_type: contract.sec_type as i32, - has_exp_date: contract.exp_date.is_some(), - exp_date: contract.exp_date.unwrap_or(0), + has_expiration: contract.expiration.is_some(), + expiration: contract.expiration.unwrap_or(0), has_is_call: contract.is_call.is_some(), is_call: contract.is_call.unwrap_or(false), has_strike: contract.strike.is_some(), @@ -60,7 +60,7 @@ pub(crate) fn fpss_event_to_ffi(event: &thetadatadx::fpss::FpssEvent) -> FfiBuff control: ZERO_CONTROL, raw_data: ZERO_RAW, }, - _detail_string: contract_root_cstring, + _detail_string: contract_symbol_cstring, _raw_payload: None, } } @@ -74,19 +74,19 @@ pub(crate) fn fpss_event_to_ffi(event: &thetadatadx::fpss::FpssEvent) -> FfiBuff received_at_ns, .. }) => { - let contract_root_cstring = if contract.root.is_empty() { + let contract_symbol_cstring = if contract.symbol.is_empty() { None } else { - std::ffi::CString::new(contract.root.as_str()).ok() + std::ffi::CString::new(contract.symbol.as_str()).ok() }; - let contract_root_ptr = contract_root_cstring + let contract_symbol_ptr = contract_symbol_cstring .as_ref() .map_or(ptr::null(), |cs| cs.as_ptr()); let tdx_contract = TdxContract { - root: contract_root_ptr, + symbol: contract_symbol_ptr, sec_type: contract.sec_type as i32, - has_exp_date: contract.exp_date.is_some(), - exp_date: contract.exp_date.unwrap_or(0), + has_expiration: contract.expiration.is_some(), + expiration: contract.expiration.unwrap_or(0), has_is_call: contract.is_call.is_some(), is_call: contract.is_call.unwrap_or(false), has_strike: contract.strike.is_some(), @@ -109,7 +109,7 @@ pub(crate) fn fpss_event_to_ffi(event: &thetadatadx::fpss::FpssEvent) -> FfiBuff control: ZERO_CONTROL, raw_data: ZERO_RAW, }, - _detail_string: contract_root_cstring, + _detail_string: contract_symbol_cstring, _raw_payload: None, } } @@ -130,19 +130,19 @@ pub(crate) fn fpss_event_to_ffi(event: &thetadatadx::fpss::FpssEvent) -> FfiBuff received_at_ns, .. }) => { - let contract_root_cstring = if contract.root.is_empty() { + let contract_symbol_cstring = if contract.symbol.is_empty() { None } else { - std::ffi::CString::new(contract.root.as_str()).ok() + std::ffi::CString::new(contract.symbol.as_str()).ok() }; - let contract_root_ptr = contract_root_cstring + let contract_symbol_ptr = contract_symbol_cstring .as_ref() .map_or(ptr::null(), |cs| cs.as_ptr()); let tdx_contract = TdxContract { - root: contract_root_ptr, + symbol: contract_symbol_ptr, sec_type: contract.sec_type as i32, - has_exp_date: contract.exp_date.is_some(), - exp_date: contract.exp_date.unwrap_or(0), + has_expiration: contract.expiration.is_some(), + expiration: contract.expiration.unwrap_or(0), has_is_call: contract.is_call.is_some(), is_call: contract.is_call.unwrap_or(false), has_strike: contract.strike.is_some(), @@ -172,7 +172,7 @@ pub(crate) fn fpss_event_to_ffi(event: &thetadatadx::fpss::FpssEvent) -> FfiBuff control: ZERO_CONTROL, raw_data: ZERO_RAW, }, - _detail_string: contract_root_cstring, + _detail_string: contract_symbol_cstring, _raw_payload: None, } } @@ -198,19 +198,19 @@ pub(crate) fn fpss_event_to_ffi(event: &thetadatadx::fpss::FpssEvent) -> FfiBuff received_at_ns, .. }) => { - let contract_root_cstring = if contract.root.is_empty() { + let contract_symbol_cstring = if contract.symbol.is_empty() { None } else { - std::ffi::CString::new(contract.root.as_str()).ok() + std::ffi::CString::new(contract.symbol.as_str()).ok() }; - let contract_root_ptr = contract_root_cstring + let contract_symbol_ptr = contract_symbol_cstring .as_ref() .map_or(ptr::null(), |cs| cs.as_ptr()); let tdx_contract = TdxContract { - root: contract_root_ptr, + symbol: contract_symbol_ptr, sec_type: contract.sec_type as i32, - has_exp_date: contract.exp_date.is_some(), - exp_date: contract.exp_date.unwrap_or(0), + has_expiration: contract.expiration.is_some(), + expiration: contract.expiration.unwrap_or(0), has_is_call: contract.is_call.is_some(), is_call: contract.is_call.unwrap_or(false), has_strike: contract.strike.is_some(), @@ -245,7 +245,7 @@ pub(crate) fn fpss_event_to_ffi(event: &thetadatadx::fpss::FpssEvent) -> FfiBuff control: ZERO_CONTROL, raw_data: ZERO_RAW, }, - _detail_string: contract_root_cstring, + _detail_string: contract_symbol_cstring, _raw_payload: None, } } diff --git a/ffi/src/fpss_event_structs.rs b/ffi/src/fpss_event_structs.rs index f2be025a..02129f0d 100644 --- a/ffi/src/fpss_event_structs.rs +++ b/ffi/src/fpss_event_structs.rs @@ -18,20 +18,20 @@ pub enum TdxFpssEventKind { /// FPSS `Contract` shared across every data event. /// -/// `root` is a NUL-terminated C string; may be null when the SDK has not +/// `symbol` is a NUL-terminated C string; may be null when the SDK has not /// yet resolved the server-assigned contract_id to a `ContractAssigned` -/// frame. Optional option fields (`exp_date`, `is_call`, `strike`) use a +/// frame. Optional option fields (`expiration`, `is_call`, `strike`) use a /// tagged-present bool because `#[repr(C)]` cannot express `Option` /// directly. #[repr(C)] pub struct TdxContract { -/// Ticker root (e.g. "AAPL"). Null until ContractAssigned arrives. -pub root: *const c_char, +/// Ticker symbol (e.g. "AAPL"). Null until ContractAssigned arrives. +pub symbol: *const c_char, /// Security type code — matches `tdbe::types::enums::SecType`. pub sec_type: i32, -/// Whether `exp_date` is meaningful (options only). -pub has_exp_date: bool, -pub exp_date: i32, +/// Whether `expiration` is meaningful (options only). +pub has_expiration: bool, +pub expiration: i32, /// Whether `is_call` is meaningful (options only). pub has_is_call: bool, pub is_call: bool, @@ -41,10 +41,10 @@ pub strike: i32, } pub(crate) const ZERO_CONTRACT_STRUCT: TdxContract = TdxContract { -root: ptr::null(), +symbol: ptr::null(), sec_type: 0, -has_exp_date: false, -exp_date: 0, +has_expiration: false, +expiration: 0, has_is_call: false, is_call: false, has_strike: false, diff --git a/ffi/src/streaming.rs b/ffi/src/streaming.rs index 738dab78..cd5a8ed1 100644 --- a/ffi/src/streaming.rs +++ b/ffi/src/streaming.rs @@ -1043,7 +1043,7 @@ pub unsafe extern "C" fn tdx_unified_reconnect(handle: *const TdxUnified) -> i32 target: "thetadatadx::ffi::reconnect", error = %e, kind = ?kind, - root = %contract.root, + root = %contract.symbol, "resubscribe failed after reconnect" ); } @@ -2354,7 +2354,7 @@ pub unsafe extern "C" fn tdx_fpss_reconnect(handle: *const TdxFpssHandle) -> i32 target: "thetadatadx::ffi::reconnect", error = %e, kind = ?kind, - root = %contract.root, + root = %contract.symbol, "resubscribe failed after reconnect" ); } diff --git a/ffi/src/types.rs b/ffi/src/types.rs index 20f7c90e..278051e5 100644 --- a/ffi/src/types.rs +++ b/ffi/src/types.rs @@ -213,11 +213,12 @@ tick_array_free!(tdx_trade_quote_tick_array_free, TdxTradeQuoteTickArray); /// FFI-safe option contract descriptor. /// -/// The `root` field is a heap-allocated C string. Freed when the array is freed. +/// The `symbol` field is a heap-allocated C string. Freed when the array +/// is freed. #[repr(C)] pub struct TdxOptionContract { /// Heap-allocated NUL-terminated C string. Freed with the array. - pub root: *const c_char, + pub symbol: *const c_char, pub expiration: i32, pub strike: f64, pub right: i32, @@ -245,7 +246,7 @@ impl TdxOptionContractArray { .into_iter() .map(|c| { Ok(TdxOptionContract { - root: CString::new(c.root)?.into_raw().cast_const(), + symbol: CString::new(c.symbol)?.into_raw().cast_const(), expiration: c.expiration, strike: c.strike, right: c.right, @@ -258,16 +259,17 @@ impl TdxOptionContractArray { } } -/// Free an option contract array, including all heap-allocated root strings. +/// Free an option contract array, including all heap-allocated symbol +/// strings. #[no_mangle] pub unsafe extern "C" fn tdx_option_contract_array_free(arr: TdxOptionContractArray) { ffi_boundary!((), { if !arr.data.is_null() && arr.len > 0 { - // First free each root C string + // First free each symbol C string let slice = unsafe { std::slice::from_raw_parts(arr.data, arr.len) }; for contract in slice { - if !contract.root.is_null() { - drop(unsafe { CString::from_raw(contract.root.cast_mut()) }); + if !contract.symbol.is_null() { + drop(unsafe { CString::from_raw(contract.symbol.cast_mut()) }); } } // Then free the array itself diff --git a/sdks/cpp/include/fpss_event_structs.h.inc b/sdks/cpp/include/fpss_event_structs.h.inc index 4ede6fc0..7986d6fd 100644 --- a/sdks/cpp/include/fpss_event_structs.h.inc +++ b/sdks/cpp/include/fpss_event_structs.h.inc @@ -15,16 +15,16 @@ typedef enum { TDX_FPSS_RAW_DATA = 5, } TdxFpssEventKind; -/* FPSS Contract shared across every data event. `root` is a +/* FPSS Contract shared across every data event. `symbol` is a * NUL-terminated C string (may be null when not yet resolved); * optional option fields use a tagged-present bool because C has no * Option. Layout is byte-identical to Rust's #[repr(C)] TdxContract. */ typedef struct { -const char *root; +const char *symbol; int32_t sec_type; -bool has_exp_date; -int32_t exp_date; +bool has_expiration; +int32_t expiration; bool has_is_call; bool is_call; bool has_strike; diff --git a/sdks/cpp/include/thetadx.h b/sdks/cpp/include/thetadx.h index dbf343ee..19d40b72 100644 --- a/sdks/cpp/include/thetadx.h +++ b/sdks/cpp/include/thetadx.h @@ -364,10 +364,10 @@ typedef struct { const TdxCalendarDay* data; size_t len; } TdxCalendarDayArray; typedef struct { const TdxInterestRateTick* data; size_t len; } TdxInterestRateTickArray; typedef struct { const TdxTradeQuoteTick* data; size_t len; } TdxTradeQuoteTickArray; -/* ── OptionContract (has heap-allocated root string) ── */ +/* ── OptionContract (has heap-allocated symbol string) ── */ typedef struct { - const char* root; /* heap-allocated, freed with tdx_option_contract_array_free */ + const char* symbol; /* heap-allocated, freed with tdx_option_contract_array_free */ int32_t expiration; /* 4 bytes padding before f64 */ double strike; diff --git a/sdks/cpp/include/thetadx.hpp b/sdks/cpp/include/thetadx.hpp index 1f6d6276..a20f2206 100644 --- a/sdks/cpp/include/thetadx.hpp +++ b/sdks/cpp/include/thetadx.hpp @@ -81,11 +81,11 @@ using TradeQuoteTick = TdxTradeQuoteTick; // Generated layout guards for the FPSS event C mirror structs. #include "fpss_layout_asserts.hpp.inc" -// OptionContract uses std::string for root to avoid use-after-free. +// OptionContract uses std::string for symbol to avoid use-after-free. // The C FFI TdxOptionContract uses a raw char* that is freed with the array, // so we deep-copy the string during conversion. struct OptionContract { - std::string root; + std::string symbol; int32_t expiration; double strike; int32_t right; diff --git a/sdks/cpp/src/historical.cpp.inc b/sdks/cpp/src/historical.cpp.inc index 19a9d783..e3acf798 100644 --- a/sdks/cpp/src/historical.cpp.inc +++ b/sdks/cpp/src/historical.cpp.inc @@ -247,7 +247,7 @@ std::vector Client::option_list_contracts(const std::string& req result.reserve(arr.len); for (size_t i = 0; i < arr.len; ++i) { OptionContract c; - c.root = arr.data[i].root ? std::string(arr.data[i].root) : ""; + c.symbol = arr.data[i].symbol ? std::string(arr.data[i].symbol) : ""; c.expiration = arr.data[i].expiration; c.strike = arr.data[i].strike; c.right = arr.data[i].right; diff --git a/sdks/go/fpss_event_structs.go b/sdks/go/fpss_event_structs.go index 91948c73..07efd6c0 100644 --- a/sdks/go/fpss_event_structs.go +++ b/sdks/go/fpss_event_structs.go @@ -40,16 +40,16 @@ const ( // Value 7 is reserved for future use. ) -// Contract identifies a subscribed instrument. Root is always present; -// option fields (ExpDate, IsCall, Strike) are non-nil only for options. +// Contract identifies a subscribed instrument. Symbol is always present; +// option fields (Expiration, IsCall, Strike) are non-nil only for options. // The same Contract value is attached to every FPSS data event the SDK // emits for the matching contract_id. type Contract struct { - Root string - SecType int32 - ExpDate *int32 - IsCall *bool - Strike *int32 + Symbol string + SecType int32 + Expiration *int32 + IsCall *bool + Strike *int32 } // FPSS OHLCVC bar. Mirrors `FpssData::Ohlcvc`. diff --git a/sdks/go/fpss_event_structs.h.inc b/sdks/go/fpss_event_structs.h.inc index 4ede6fc0..7986d6fd 100644 --- a/sdks/go/fpss_event_structs.h.inc +++ b/sdks/go/fpss_event_structs.h.inc @@ -15,16 +15,16 @@ typedef enum { TDX_FPSS_RAW_DATA = 5, } TdxFpssEventKind; -/* FPSS Contract shared across every data event. `root` is a +/* FPSS Contract shared across every data event. `symbol` is a * NUL-terminated C string (may be null when not yet resolved); * optional option fields use a tagged-present bool because C has no * Option. Layout is byte-identical to Rust's #[repr(C)] TdxContract. */ typedef struct { -const char *root; +const char *symbol; int32_t sec_type; -bool has_exp_date; -int32_t exp_date; +bool has_expiration; +int32_t expiration; bool has_is_call; bool is_call; bool has_strike; diff --git a/sdks/go/fpss_methods.go b/sdks/go/fpss_methods.go index 28d98671..741a60ee 100644 --- a/sdks/go/fpss_methods.go +++ b/sdks/go/fpss_methods.go @@ -272,19 +272,19 @@ func (f *FpssClient) NextEvent(timeoutMs uint64) (*FpssEvent, error) { } // contractFromC converts a C TdxContract into the Go-idiomatic *Contract. - // Returns nil only when root is NULL, which indicates the SDK has not yet - // resolved the contract_id to a ContractAssigned frame. + // Returns nil only when symbol is NULL, which indicates the SDK has not + // yet resolved the contract_id to a ContractAssigned frame. contractFromC := func(c C.TdxContract) *Contract { - if c.root == nil { + if c.symbol == nil { return nil } out := &Contract{ - Root: C.GoString(c.root), + Symbol: C.GoString(c.symbol), SecType: int32(c.sec_type), } - if bool(c.has_exp_date) { - v := int32(c.exp_date) - out.ExpDate = &v + if bool(c.has_expiration) { + v := int32(c.expiration) + out.Expiration = &v } if bool(c.has_is_call) { v := bool(c.is_call) diff --git a/sdks/go/tick_converters.go b/sdks/go/tick_converters.go index 8beac148..3a43cbeb 100644 --- a/sdks/go/tick_converters.go +++ b/sdks/go/tick_converters.go @@ -310,12 +310,12 @@ func convertOptionContracts(arr C.TdxOptionContractArray) []OptionContract { src := unsafe.Slice((*cOptionContract)(arr.data), n) result := make([]OptionContract, n) for i, t := range src { - root := "" - if t.Root != 0 { - root = C.GoString((*C.char)(unsafe.Pointer(t.Root))) + symbol := "" + if t.Symbol != 0 { + symbol = C.GoString((*C.char)(unsafe.Pointer(t.Symbol))) } result[i] = OptionContract{ - Root: root, + Symbol: symbol, Expiration: int(t.Expiration), Strike: t.Strike, Right: RightStr(t.Right), diff --git a/sdks/go/tick_ffi_mirrors.go b/sdks/go/tick_ffi_mirrors.go index 75a2af29..8344cc3e 100644 --- a/sdks/go/tick_ffi_mirrors.go +++ b/sdks/go/tick_ffi_mirrors.go @@ -352,9 +352,9 @@ type cTradeQuoteTick struct { } // cOptionContract mirrors TdxOptionContract from FFI -// Layout: root(8 ptr), exp(4), pad(4), strike(8), right(4), pad(4) = 32 +// Layout: symbol(8 ptr), exp(4), pad(4), strike(8), right(4), pad(4) = 32 type cOptionContract struct { - Root uintptr // *const c_char + Symbol uintptr // *const c_char Expiration int32 _pad1 int32 Strike float64 diff --git a/sdks/go/tick_structs.go b/sdks/go/tick_structs.go index c0c299c2..d383d5ab 100644 --- a/sdks/go/tick_structs.go +++ b/sdks/go/tick_structs.go @@ -190,7 +190,7 @@ type OpenInterestTick struct { // OptionContract — Option contract -- 4 fields. Contract specification. type OptionContract struct { - Root string `json:"root"` + Symbol string `json:"symbol"` Expiration int `json:"expiration"` Strike float64 `json:"strike"` Right string `json:"right"` diff --git a/sdks/python/Cargo.lock b/sdks/python/Cargo.lock index e0bf69b0..51a34c4a 100644 --- a/sdks/python/Cargo.lock +++ b/sdks/python/Cargo.lock @@ -2310,7 +2310,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tdbe" -version = "0.12.7" +version = "0.12.8" dependencies = [ "thiserror 2.0.18", ] @@ -2330,7 +2330,7 @@ dependencies = [ [[package]] name = "thetadatadx" -version = "8.0.27" +version = "8.0.28" dependencies = [ "disruptor", "metrics", @@ -2364,7 +2364,7 @@ dependencies = [ [[package]] name = "thetadatadx-py" -version = "8.0.27" +version = "8.0.28" dependencies = [ "arrow", "arrow-array", diff --git a/sdks/python/Cargo.toml b/sdks/python/Cargo.toml index c834a40b..4df19174 100644 --- a/sdks/python/Cargo.toml +++ b/sdks/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thetadatadx-py" -version = "8.0.27" +version = "8.0.28" edition = "2021" description = "Python bindings for thetadatadx — native ThetaData SDK powered by Rust" license = "Apache-2.0" @@ -19,7 +19,7 @@ doc = false [dependencies] # The Rust SDK we're wrapping thetadatadx = { path = "../../crates/thetadatadx" } -tdbe = { version = "0.12.7", path = "../../crates/tdbe" } +tdbe = { version = "0.12.8", path = "../../crates/tdbe" } # Direct prost dep for decoding `thetadatadx::proto::ResponseData` bytes in # the `decode_response_bytes` hook. The main crate no longer re-exports diff --git a/sdks/python/src/fpss_event_classes.rs b/sdks/python/src/fpss_event_classes.rs index 6f4e39c1..8b88e841 100644 --- a/sdks/python/src/fpss_event_classes.rs +++ b/sdks/python/src/fpss_event_classes.rs @@ -10,9 +10,9 @@ #[pyclass(module = "thetadatadx", frozen, skip_from_py_object)] #[derive(Clone)] pub(crate) struct Contract { -#[pyo3(get)] pub root: String, +#[pyo3(get)] pub symbol: String, #[pyo3(get)] pub sec_type: i32, -#[pyo3(get)] pub exp_date: Option, +#[pyo3(get)] pub expiration: Option, #[pyo3(get)] pub is_call: Option, #[pyo3(get)] pub strike: Option, } @@ -20,8 +20,8 @@ pub(crate) struct Contract { impl Contract { fn __repr__(&self) -> String { format!( -"Contract(root={:?}, sec_type={}, exp_date={:?}, is_call={:?}, strike={:?})", -self.root, self.sec_type, self.exp_date, self.is_call, self.strike +"Contract(symbol={:?}, sec_type={}, expiration={:?}, is_call={:?}, strike={:?})", +self.symbol, self.sec_type, self.expiration, self.is_call, self.strike ) } } @@ -33,9 +33,9 @@ impl Contract { /// exports if they need a symbolic reading. pub(crate) fn from_core(c: &fpss::protocol::Contract) -> Self { Self { -root: c.root.clone(), +symbol: c.symbol.clone(), sec_type: c.sec_type as i32, -exp_date: c.exp_date, +expiration: c.expiration, is_call: c.is_call, strike: c.strike, } diff --git a/sdks/python/src/tick_arrow.rs b/sdks/python/src/tick_arrow.rs index 6a433b8d..a32cb20e 100644 --- a/sdks/python/src/tick_arrow.rs +++ b/sdks/python/src/tick_arrow.rs @@ -905,18 +905,18 @@ pub(crate) mod slice_arrow { fn read_arrow_batch_from_option_contract_slice(ticks: &[tick::OptionContract]) -> PyResult { let schema = arrow_schema_for_qualname("OptionContract").expect("generated schema must be present for OptionContract"); let n = ticks.len(); - let mut col_root: Vec = Vec::with_capacity(n); + let mut col_symbol: Vec = Vec::with_capacity(n); let mut col_expiration: Vec = Vec::with_capacity(n); let mut col_strike: Vec = Vec::with_capacity(n); let mut col_right: Vec = Vec::with_capacity(n); for t in ticks { - col_root.push(t.root.clone()); + col_symbol.push(t.symbol.clone()); col_expiration.push(t.expiration); col_strike.push(t.strike); col_right.push(if t.is_call() { "C".to_string() } else if t.is_put() { "P".to_string() } else { String::new() }); } let columns: Vec = vec![ - Arc::new(StringArray::from(col_root)) as ArrayRef, + Arc::new(StringArray::from(col_symbol)) as ArrayRef, Arc::new(Int32Array::from(col_expiration)) as ArrayRef, Arc::new(Float64Array::from(col_strike)) as ArrayRef, Arc::new(StringArray::from(col_right)) as ArrayRef, diff --git a/sdks/python/src/tick_classes.rs b/sdks/python/src/tick_classes.rs index 7ace4abf..dea07956 100644 --- a/sdks/python/src/tick_classes.rs +++ b/sdks/python/src/tick_classes.rs @@ -524,12 +524,12 @@ impl OpenInterestTick { /// Option contract. Contract specification. /// -/// Cannot be `Copy` because of the `String` root field. +/// Cannot be `Copy` because of the `String` symbol field. #[must_use] #[pyclass(module = "thetadatadx", frozen, skip_from_py_object)] #[derive(Clone)] pub(crate) struct OptionContract { - #[pyo3(get)] pub root: String, + #[pyo3(get)] pub symbol: String, #[pyo3(get)] pub expiration: i32, #[pyo3(get)] pub strike: f64, #[pyo3(get)] pub right: String, @@ -537,17 +537,17 @@ pub(crate) struct OptionContract { #[pymethods] impl OptionContract { #[new] - #[pyo3(signature = (*, root = String::new(), expiration = 0i32, strike = 0.0f64, right = String::new()))] - fn new(root: String, expiration: i32, strike: f64, right: String) -> Self { + #[pyo3(signature = (*, symbol = String::new(), expiration = 0i32, strike = 0.0f64, right = String::new()))] + fn new(symbol: String, expiration: i32, strike: f64, right: String) -> Self { Self { - root, + symbol, expiration, strike, right, } } fn __repr__(&self) -> String { - format!("OptionContract(root={:?}, expiration={}, strike={}, right={:?})", self.root, self.expiration, self.strike, self.right) + format!("OptionContract(symbol={:?}, expiration={}, strike={}, right={:?})", self.symbol, self.expiration, self.strike, self.right) } } @@ -2718,7 +2718,7 @@ impl OptionContractList { let mut inner = Vec::with_capacity(ticks.len()); for t in &ticks { inner.push( tick::OptionContract { - root: t.root.clone(), + symbol: t.symbol.clone(), expiration: t.expiration, strike: t.strike, right: match t.right.as_str() { "C" => 67, "P" => 80, _ => 0 }, @@ -2750,7 +2750,7 @@ impl OptionContractList { } let t = &self.inner[resolved as usize]; Ok( OptionContract { - root: t.root.clone(), + symbol: t.symbol.clone(), expiration: t.expiration, strike: t.strike, right: (if t.is_call() { "C" } else if t.is_put() { "P" } else { "" }).to_string(), @@ -2769,7 +2769,7 @@ impl OptionContractList { let list = pyo3::types::PyList::empty(py); for t in &self.inner { let obj = OptionContract { - root: t.root.clone(), + symbol: t.symbol.clone(), expiration: t.expiration, strike: t.strike, right: (if t.is_call() { "C" } else if t.is_put() { "P" } else { "" }).to_string(), @@ -2826,7 +2826,7 @@ impl OptionContractListIter { let t = &self.inner[self.cursor]; self.cursor += 1; Some( OptionContract { - root: t.root.clone(), + symbol: t.symbol.clone(), expiration: t.expiration, strike: t.strike, right: (if t.is_call() { "C" } else if t.is_put() { "P" } else { "" }).to_string(), diff --git a/sdks/typescript/Cargo.lock b/sdks/typescript/Cargo.lock index 2d886f38..40093b73 100644 --- a/sdks/typescript/Cargo.lock +++ b/sdks/typescript/Cargo.lock @@ -1934,7 +1934,7 @@ dependencies = [ [[package]] name = "tdbe" -version = "0.12.7" +version = "0.12.8" dependencies = [ "thiserror 2.0.18", ] @@ -1954,7 +1954,7 @@ dependencies = [ [[package]] name = "thetadatadx" -version = "8.0.27" +version = "8.0.28" dependencies = [ "disruptor", "metrics", @@ -1988,7 +1988,7 @@ dependencies = [ [[package]] name = "thetadatadx-napi" -version = "8.0.27" +version = "8.0.28" dependencies = [ "chrono", "napi", diff --git a/sdks/typescript/Cargo.toml b/sdks/typescript/Cargo.toml index e0a1175f..d4990a66 100644 --- a/sdks/typescript/Cargo.toml +++ b/sdks/typescript/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thetadatadx-napi" -version = "8.0.27" +version = "8.0.28" edition = "2021" description = "TypeScript/Node.js bindings for thetadatadx — native ThetaData SDK powered by Rust" license = "Apache-2.0" @@ -13,7 +13,7 @@ crate-type = ["cdylib"] [dependencies] thetadatadx = { path = "../../crates/thetadatadx" } -tdbe = { version = "0.12.7", path = "../../crates/tdbe" } +tdbe = { version = "0.12.8", path = "../../crates/tdbe" } napi = { version = "3.8.5", features = ["async", "tokio_rt", "serde-json", "napi6", "chrono_date"] } napi-derive = "3.5.4" diff --git a/sdks/typescript/index.d.ts b/sdks/typescript/index.d.ts index a56bee9a..4ed4c1f5 100644 --- a/sdks/typescript/index.d.ts +++ b/sdks/typescript/index.d.ts @@ -826,9 +826,9 @@ export interface CalendarDay { * event as `event.quote.contract` / `event.trade.contract` / etc. */ export interface Contract { - root: string + symbol: string secType: number - expDate?: number + expiration?: number isCall?: boolean strike?: number } @@ -1096,7 +1096,7 @@ export interface OpenInterestTick { /** Option contract. Contract specification. */ export interface OptionContract { - root: string + symbol: string expiration: number strike: number right: string diff --git a/sdks/typescript/src/fpss_event_classes.rs b/sdks/typescript/src/fpss_event_classes.rs index e201f39a..896af442 100644 --- a/sdks/typescript/src/fpss_event_classes.rs +++ b/sdks/typescript/src/fpss_event_classes.rs @@ -16,9 +16,9 @@ #[napi(object)] #[derive(Clone)] pub struct Contract { - pub root: String, + pub symbol: String, pub sec_type: i32, - pub exp_date: Option, + pub expiration: Option, pub is_call: Option, pub strike: Option, } @@ -173,9 +173,9 @@ pub(crate) fn buffered_event_to_typed(event: BufferedEvent) -> FpssEvent { out.ohlcvc = Some(Ohlcvc { contract_id, contract: Contract { - root: contract.root.clone(), + symbol: contract.symbol.clone(), sec_type: contract.sec_type as i32, - exp_date: contract.exp_date, + expiration: contract.expiration, is_call: contract.is_call, strike: contract.strike, }, @@ -202,9 +202,9 @@ pub(crate) fn buffered_event_to_typed(event: BufferedEvent) -> FpssEvent { out.open_interest = Some(OpenInterest { contract_id, contract: Contract { - root: contract.root.clone(), + symbol: contract.symbol.clone(), sec_type: contract.sec_type as i32, - exp_date: contract.exp_date, + expiration: contract.expiration, is_call: contract.is_call, strike: contract.strike, }, @@ -233,9 +233,9 @@ pub(crate) fn buffered_event_to_typed(event: BufferedEvent) -> FpssEvent { out.quote = Some(Quote { contract_id, contract: Contract { - root: contract.root.clone(), + symbol: contract.symbol.clone(), sec_type: contract.sec_type as i32, - exp_date: contract.exp_date, + expiration: contract.expiration, is_call: contract.is_call, strike: contract.strike, }, @@ -276,9 +276,9 @@ pub(crate) fn buffered_event_to_typed(event: BufferedEvent) -> FpssEvent { out.trade = Some(Trade { contract_id, contract: Contract { - root: contract.root.clone(), + symbol: contract.symbol.clone(), sec_type: contract.sec_type as i32, - exp_date: contract.exp_date, + expiration: contract.expiration, is_call: contract.is_call, strike: contract.strike, }, diff --git a/sdks/typescript/src/tick_classes.rs b/sdks/typescript/src/tick_classes.rs index 4683082a..39340819 100644 --- a/sdks/typescript/src/tick_classes.rs +++ b/sdks/typescript/src/tick_classes.rs @@ -225,7 +225,7 @@ pub struct OpenInterestTick { #[napi(object)] #[derive(Clone)] pub struct OptionContract { - pub root: String, + pub symbol: String, pub expiration: i32, pub strike: f64, pub right: String, @@ -575,7 +575,7 @@ fn option_contracts_to_class_vec(ticks: &[tick::OptionContract]) -> Vec Vec sonic_rs::Value { let sec_type_str = format!("{:?}", c.sec_type).to_uppercase(); let mut obj = sonic_rs::Object::new(); - obj.insert("root", sonic_rs::Value::from(c.root.as_str())); + obj.insert("symbol", sonic_rs::Value::from(c.symbol.as_str())); obj.insert("sec_type", sonic_rs::Value::from(sec_type_str.as_str())); - if let Some(exp) = c.exp_date { + if let Some(exp) = c.expiration { obj.insert("expiration", sonic_rs::Value::from(exp)); } if let Some(strike) = c.strike { @@ -257,7 +257,7 @@ mod tests { // Peek under the lock — mirrors what the callback thread does. let peeked = lookup_event_contract(&event, &map); assert!(peeked.is_some(), "pre-peek must find the contract"); - assert_eq!(peeked.as_ref().unwrap().root, "AAPL"); + assert_eq!(peeked.as_ref().unwrap().symbol, "AAPL"); // Simulate a reconnect / market-close clearing the shared map // AFTER the callback has peeked but BEFORE the broadcast task @@ -268,12 +268,12 @@ mod tests { // With the fix, the peeked snapshot still carries the full // contract via the Arc refcount — serialization must succeed - // with root = "AAPL". + // with symbol = "AAPL". let json = fpss_event_to_ws_json(&event, peeked.as_deref()) .expect("serialization must succeed with peeked contract"); assert!( - json.contains("\"root\":\"AAPL\""), - "serialized JSON must retain the peeked root after map clear: {json}" + json.contains("\"symbol\":\"AAPL\""), + "serialized JSON must retain the peeked symbol after map clear: {json}" ); } diff --git a/tools/server/src/ws/subscribe.rs b/tools/server/src/ws/subscribe.rs index 4650575b..60f9bcf2 100644 --- a/tools/server/src/ws/subscribe.rs +++ b/tools/server/src/ws/subscribe.rs @@ -106,17 +106,25 @@ pub(super) async fn handle_client_message(state: &AppState, text: &str, socket: let req_type = req_type_val.as_str().unwrap_or("").to_uppercase(); let contract_obj = obj.get("contract").unwrap_or(&null_val); - let root_val = contract_obj.get("root").unwrap_or(&null_val); - let root = root_val.as_str().unwrap_or(""); + // Accept the v3 `"symbol"` key first, fall back to legacy `"root"` + // so existing consumers keep working without an envelope rewrite. + // The two keys are mutually exclusive in practice; downstream + // validation runs against whichever the caller sent. + let symbol_val = contract_obj + .get("symbol") + .or_else(|| contract_obj.get("root")) + .unwrap_or(&null_val); + let symbol = symbol_val.as_str().unwrap_or(""); - // Bound the client-supplied ticker root length BEFORE the string flows - // into `Contract::stock(root)` / `Contract::option_raw(root, ...)`. - // Without this a malicious client can send a multi-megabyte `"root"` - // value in the JSON subscribe envelope, triggering allocation inside - // the FPSS contract map keyed by that string. Mirrors the REST - // validation performed in `handler::build_endpoint_args`. - if let Err(e) = validation::validate_symbol(root, "root") { - tracing::warn!(error = %e, "WS subscribe: root failed length validation"); + // Bound the client-supplied ticker symbol length BEFORE the string + // flows into `Contract::stock(symbol)` / + // `Contract::option_raw(symbol, ...)`. Without this a malicious + // client can send a multi-megabyte `"symbol"` value in the JSON + // subscribe envelope, triggering allocation inside the FPSS + // contract map keyed by that string. Mirrors the REST validation + // performed in `handler::build_endpoint_args`. + if let Err(e) = validation::validate_symbol(symbol, "symbol") { + tracing::warn!(error = %e, "WS subscribe: symbol failed length validation"); let resp = sonic_rs::json!({ "header": { "type": "REQ_RESPONSE", @@ -134,7 +142,7 @@ pub(super) async fn handle_client_message(state: &AppState, text: &str, socket: sec_type = %sec_type, req_type = %req_type, req_id = req_id, - root = %root, + symbol = %symbol, add = is_add, "WebSocket subscription command" ); @@ -288,9 +296,9 @@ pub(super) async fn handle_client_message(state: &AppState, text: &str, socket: return; } }; - Contract::option_raw(root, exp, is_call, strike) + Contract::option_raw(symbol, exp, is_call, strike) } else { - Contract::stock(root) + Contract::stock(symbol) }; let tdx = state.tdx();