From 9f59f140e55d77319bf67feb1acd1da93349701e Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 20:49:38 +0800 Subject: [PATCH] feat(economics): make treasury tenors and calendar figures optional The live economics endpoints return `null` for treasury rate tenors, indicator values, and several calendar figures on upcoming or partial releases (issue #368). Decoding these rows previously failed the whole call because the members were required numeric types. **Response model changes:** * Made all twelve `TreasuryRate` tenors, `EconomicIndicatorObservation.value`, and the `previous`, `estimate`, `actual`, `change`, `changePercentage`, and `unit` members of `EconomicCalendarEvent` optional across Rust, Go, and Python. * Added a `required_option` deserializer in the Rust responses so a `null` decodes to `None` while a missing key still fails. * Reworked the Go shadow decoders to use `jsontext.Value` so a present-but-null member is distinguished from an absent one and mapped to a nil pointer. **Tests and docs:** * Updated fixtures and parity tests to assert null tenors, values, and calendar figures decode as `None`/`nil`, and extended the contract checks to accept null only for the newly optional members. * Documented the null-figure ambiguity for the economics calendar, treasury tenors, and indicator value in the contract-ambiguities table. BREAKING CHANGE: TreasuryRate tenors, EconomicIndicatorObservation.value and six EconomicCalendarEvent members are now optional (Option / pointer / Optional); handle None. Refs: #368 --- .../python/fmp/_native/economics/__init__.pyi | 80 ++--- crates/fmp-py/src/models/economics.rs | 76 ++--- crates/fmp-py/tests/test_economics.py | 17 +- crates/fmp-py/tests/typecheck_contract.py | 4 +- crates/libfmp/src/responses/economics.rs | 67 ++-- .../economics_calendar_risk_endpoints.rs | 2 +- .../economics_rates_indicators_endpoints.rs | 4 +- crates/libfmp/tests/economics_responses.rs | 80 +++-- .../tests/fixtures/economic_calendar.json | 4 +- .../libfmp/tests/fixtures/treasury_rates.json | 2 +- docs/contract-ambiguities.md | 2 +- sdk/go/economics_models.go | 314 +++++++++++++----- sdk/go/economics_parity_test.go | 45 ++- sdk/go/economics_test.go | 6 +- 14 files changed, 465 insertions(+), 238 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/economics/__init__.pyi b/crates/fmp-py/python/fmp/_native/economics/__init__.pyi index 275f6652..8fbbecd9 100644 --- a/crates/fmp-py/python/fmp/_native/economics/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/economics/__init__.pyi @@ -39,19 +39,19 @@ class EconomicCalendarEvent: @property def currency(self) -> builtins.str: ... @property - def previous(self) -> builtins.float: ... + def previous(self) -> typing.Optional[builtins.float]: ... @property - def estimate(self) -> builtins.float: ... + def estimate(self) -> typing.Optional[builtins.float]: ... @property - def actual(self) -> builtins.float: ... + def actual(self) -> typing.Optional[builtins.float]: ... @property - def change(self) -> builtins.float: ... + def change(self) -> typing.Optional[builtins.float]: ... @property def impact(self) -> builtins.str: ... @property - def change_percentage(self) -> builtins.float: ... + def change_percentage(self) -> typing.Optional[builtins.float]: ... @property - def unit(self) -> builtins.str: ... + def unit(self) -> typing.Optional[builtins.str]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, @@ -60,13 +60,13 @@ class EconomicCalendarEvent: country: builtins.str, event: builtins.str, currency: builtins.str, - previous: builtins.float, - estimate: builtins.float, - actual: builtins.float, - change: builtins.float, + previous: typing.Optional[builtins.float], + estimate: typing.Optional[builtins.float], + actual: typing.Optional[builtins.float], + change: typing.Optional[builtins.float], impact: builtins.str, - change_percentage: builtins.float, - unit: builtins.str, + change_percentage: typing.Optional[builtins.float], + unit: typing.Optional[builtins.str], ) -> EconomicCalendarEvent: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... @@ -89,10 +89,14 @@ class EconomicIndicatorObservation: @property def date(self) -> datetime.date: ... @property - def value(self) -> builtins.float: ... + def value(self) -> typing.Optional[builtins.float]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( - cls, *, name: builtins.str, date: datetime.date, value: builtins.float + cls, + *, + name: builtins.str, + date: datetime.date, + value: typing.Optional[builtins.float], ) -> EconomicIndicatorObservation: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... @@ -205,46 +209,46 @@ class TreasuryRate: @property def date(self) -> datetime.date: ... @property - def month_1(self) -> builtins.float: ... + def month_1(self) -> typing.Optional[builtins.float]: ... @property - def month_2(self) -> builtins.float: ... + def month_2(self) -> typing.Optional[builtins.float]: ... @property - def month_3(self) -> builtins.float: ... + def month_3(self) -> typing.Optional[builtins.float]: ... @property - def month_6(self) -> builtins.float: ... + def month_6(self) -> typing.Optional[builtins.float]: ... @property - def year_1(self) -> builtins.float: ... + def year_1(self) -> typing.Optional[builtins.float]: ... @property - def year_2(self) -> builtins.float: ... + def year_2(self) -> typing.Optional[builtins.float]: ... @property - def year_3(self) -> builtins.float: ... + def year_3(self) -> typing.Optional[builtins.float]: ... @property - def year_5(self) -> builtins.float: ... + def year_5(self) -> typing.Optional[builtins.float]: ... @property - def year_7(self) -> builtins.float: ... + def year_7(self) -> typing.Optional[builtins.float]: ... @property - def year_10(self) -> builtins.float: ... + def year_10(self) -> typing.Optional[builtins.float]: ... @property - def year_20(self) -> builtins.float: ... + def year_20(self) -> typing.Optional[builtins.float]: ... @property - def year_30(self) -> builtins.float: ... + def year_30(self) -> typing.Optional[builtins.float]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, *, date: datetime.date, - month_1: builtins.float, - month_2: builtins.float, - month_3: builtins.float, - month_6: builtins.float, - year_1: builtins.float, - year_2: builtins.float, - year_3: builtins.float, - year_5: builtins.float, - year_7: builtins.float, - year_10: builtins.float, - year_20: builtins.float, - year_30: builtins.float, + month_1: typing.Optional[builtins.float], + month_2: typing.Optional[builtins.float], + month_3: typing.Optional[builtins.float], + month_6: typing.Optional[builtins.float], + year_1: typing.Optional[builtins.float], + year_2: typing.Optional[builtins.float], + year_3: typing.Optional[builtins.float], + year_5: typing.Optional[builtins.float], + year_7: typing.Optional[builtins.float], + year_10: typing.Optional[builtins.float], + year_20: typing.Optional[builtins.float], + year_30: typing.Optional[builtins.float], ) -> TreasuryRate: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... diff --git a/crates/fmp-py/src/models/economics.rs b/crates/fmp-py/src/models/economics.rs index b846becf..937f614e 100644 --- a/crates/fmp-py/src/models/economics.rs +++ b/crates/fmp-py/src/models/economics.rs @@ -12,29 +12,29 @@ pub(crate) struct TreasuryRate { #[pyo3(get)] pub date: ::chrono::NaiveDate, #[pyo3(get)] - pub month_1: f64, + pub month_1: Option, #[pyo3(get)] - pub month_2: f64, + pub month_2: Option, #[pyo3(get)] - pub month_3: f64, + pub month_3: Option, #[pyo3(get)] - pub month_6: f64, + pub month_6: Option, #[pyo3(get)] - pub year_1: f64, + pub year_1: Option, #[pyo3(get)] - pub year_2: f64, + pub year_2: Option, #[pyo3(get)] - pub year_3: f64, + pub year_3: Option, #[pyo3(get)] - pub year_5: f64, + pub year_5: Option, #[pyo3(get)] - pub year_7: f64, + pub year_7: Option, #[pyo3(get)] - pub year_10: f64, + pub year_10: Option, #[pyo3(get)] - pub year_20: f64, + pub year_20: Option, #[pyo3(get)] - pub year_30: f64, + pub year_30: Option, } #[gen_stub_pymethods] @@ -46,18 +46,18 @@ impl TreasuryRate { #[pyo3(signature = (*, date, month_1, month_2, month_3, month_6, year_1, year_2, year_3, year_5, year_7, year_10, year_20, year_30))] fn new( date: ::chrono::NaiveDate, - month_1: f64, - month_2: f64, - month_3: f64, - month_6: f64, - year_1: f64, - year_2: f64, - year_3: f64, - year_5: f64, - year_7: f64, - year_10: f64, - year_20: f64, - year_30: f64, + month_1: Option, + month_2: Option, + month_3: Option, + month_6: Option, + year_1: Option, + year_2: Option, + year_3: Option, + year_5: Option, + year_7: Option, + year_10: Option, + year_20: Option, + year_30: Option, ) -> Self { Self { date, @@ -230,7 +230,7 @@ pub(crate) struct EconomicIndicatorObservation { #[pyo3(get)] pub date: ::chrono::NaiveDate, #[pyo3(get)] - pub value: f64, + pub value: Option, } #[gen_stub_pymethods] @@ -240,7 +240,7 @@ impl EconomicIndicatorObservation { #[allow(clippy::too_many_arguments)] #[allow(clippy::fn_params_excessive_bools)] #[pyo3(signature = (*, name, date, value))] - fn new(name: String, date: ::chrono::NaiveDate, value: f64) -> Self { + fn new(name: String, date: ::chrono::NaiveDate, value: Option) -> Self { Self { name, date, value } } @@ -328,19 +328,19 @@ pub(crate) struct EconomicCalendarEvent { #[pyo3(get)] pub currency: String, #[pyo3(get)] - pub previous: f64, + pub previous: Option, #[pyo3(get)] - pub estimate: f64, + pub estimate: Option, #[pyo3(get)] - pub actual: f64, + pub actual: Option, #[pyo3(get)] - pub change: f64, + pub change: Option, #[pyo3(get)] pub impact: String, #[pyo3(get)] - pub change_percentage: f64, + pub change_percentage: Option, #[pyo3(get)] - pub unit: String, + pub unit: Option, } #[gen_stub_pymethods] @@ -355,13 +355,13 @@ impl EconomicCalendarEvent { country: String, event: String, currency: String, - previous: f64, - estimate: f64, - actual: f64, - change: f64, + previous: Option, + estimate: Option, + actual: Option, + change: Option, impact: String, - change_percentage: f64, - unit: String, + change_percentage: Option, + unit: Option, ) -> Self { Self { date, diff --git a/crates/fmp-py/tests/test_economics.py b/crates/fmp-py/tests/test_economics.py index 0d893220..82e07778 100644 --- a/crates/fmp-py/tests/test_economics.py +++ b/crates/fmp-py/tests/test_economics.py @@ -35,7 +35,7 @@ def test_economics_namespace_is_the_generated_type(client: Any) -> None: def test_treasury_rates_with_no_arguments(client: Any, fixture_server: FixtureServer) -> None: - """``treasury_rates`` maps to ``/treasury-rates`` with no query string and decodes the curve row.""" + """``treasury_rates`` maps to ``/treasury-rates`` with no query string and decodes a null tenor as ``None``.""" fixture_server.route("/treasury-rates", load_fixture("treasury_rates.json")) rows = client.economics.treasury_rates() @@ -45,6 +45,8 @@ def test_treasury_rates_with_no_arguments(client: Any, fixture_server: FixtureSe assert isinstance(row, TreasuryRate) assert row.date == datetime.date(2026, 7, 29) assert row.month_1 == pytest.approx(3.73) + assert row.month_2 is None + assert row.month_3 == pytest.approx(3.83) assert row.year_10 == pytest.approx(4.67) assert row.year_30 == pytest.approx(5.2) @@ -91,6 +93,15 @@ def test_indicators_with_a_documented_name(client: Any, fixture_server: FixtureS assert row.value == pytest.approx(31_422.526) +def test_indicators_decode_a_null_value_as_none(client: Any, fixture_server: FixtureServer) -> None: + """A ``null`` indicator ``value`` decodes as ``None`` instead of failing the call.""" + body = load_fixture("economic_indicators.json") + body[0]["value"] = None + fixture_server.route("/economic-indicators", body) + + assert client.economics.indicators("GDP")[0].value is None + + def test_indicators_with_every_option(client: Any, fixture_server: FixtureServer) -> None: """``indicators`` encodes ``name``, ``from``, ``to`` in that order.""" fixture_server.route("/economic-indicators", load_fixture("economic_indicators.json")) @@ -147,11 +158,11 @@ def test_calendar_with_no_arguments(client: Any, fixture_server: FixtureServer) assert row.currency == "SGD" assert row.event == "Import Prices YoY (Jun)" assert row.actual == pytest.approx(13.6) - assert row.estimate == pytest.approx(21.0) + assert row.estimate is None assert row.change == pytest.approx(-4.9) assert row.change_percentage == pytest.approx(-26.486) assert row.impact == "Low" - assert row.unit == "%" + assert row.unit is None def test_calendar_with_every_option(client: Any, fixture_server: FixtureServer) -> None: diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 7f7db1d3..c44b2ef7 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -518,10 +518,10 @@ def check_economics_contract(client: FmpClient) -> None: economics: EconomicsNamespace = client.economics rates: list[TreasuryRate] = economics.treasury_rates(from_=datetime.date(2026, 1, 27), to="2026-04-27") observed_on: datetime.date = rates[0].date - year_30: float = rates[0].year_30 + year_30: float | None = rates[0].year_30 observations: list[EconomicIndicatorObservation] = client.economics.indicators("GDP", from_="2025-04-27") name: str = observations[0].name - value: float = observations[0].value + value: float | None = observations[0].value events: list[EconomicCalendarEvent] = client.economics.calendar( country="US", from_="2026-01-27", to=datetime.date(2026, 4, 27) ) diff --git a/crates/libfmp/src/responses/economics.rs b/crates/libfmp/src/responses/economics.rs index 258ad5af..d1f95367 100644 --- a/crates/libfmp/src/responses/economics.rs +++ b/crates/libfmp/src/responses/economics.rs @@ -1,30 +1,50 @@ //! Response rows returned by economics endpoints. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ query::EconomicIndicator, types::{ApiDateTime, Change, CountryCode, CurrencyCode, Date, Percentage}, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// Treasury rates across all twelve documented maturities for one date. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct TreasuryRate { pub date: Date, - pub month_1: Percentage, - pub month_2: Percentage, - pub month_3: Percentage, - pub month_6: Percentage, - pub year_1: Percentage, - pub year_2: Percentage, - pub year_3: Percentage, - pub year_5: Percentage, - pub year_7: Percentage, - pub year_10: Percentage, - pub year_20: Percentage, - pub year_30: Percentage, + #[serde(deserialize_with = "required_option")] + pub month_1: Option, + #[serde(deserialize_with = "required_option")] + pub month_2: Option, + #[serde(deserialize_with = "required_option")] + pub month_3: Option, + #[serde(deserialize_with = "required_option")] + pub month_6: Option, + #[serde(deserialize_with = "required_option")] + pub year_1: Option, + #[serde(deserialize_with = "required_option")] + pub year_2: Option, + #[serde(deserialize_with = "required_option")] + pub year_3: Option, + #[serde(deserialize_with = "required_option")] + pub year_5: Option, + #[serde(deserialize_with = "required_option")] + pub year_7: Option, + #[serde(deserialize_with = "required_option")] + pub year_10: Option, + #[serde(deserialize_with = "required_option")] + pub year_20: Option, + #[serde(deserialize_with = "required_option")] + pub year_30: Option, } /// One observation of a documented or validated open economic indicator. @@ -34,7 +54,8 @@ pub struct TreasuryRate { pub struct EconomicIndicatorObservation { pub name: EconomicIndicator, pub date: Date, - pub value: f64, + #[serde(deserialize_with = "required_option")] + pub value: Option, } /// One scheduled economic data release. @@ -46,13 +67,19 @@ pub struct EconomicCalendarEvent { pub country: CountryCode, pub event: String, pub currency: CurrencyCode, - pub previous: f64, - pub estimate: f64, - pub actual: f64, - pub change: Change, + #[serde(deserialize_with = "required_option")] + pub previous: Option, + #[serde(deserialize_with = "required_option")] + pub estimate: Option, + #[serde(deserialize_with = "required_option")] + pub actual: Option, + #[serde(deserialize_with = "required_option")] + pub change: Option, pub impact: String, - pub change_percentage: Percentage, - pub unit: String, + #[serde(deserialize_with = "required_option")] + pub change_percentage: Option, + #[serde(deserialize_with = "required_option")] + pub unit: Option, } /// One country's documented market and total-equity risk premiums. diff --git a/crates/libfmp/tests/economics_calendar_risk_endpoints.rs b/crates/libfmp/tests/economics_calendar_risk_endpoints.rs index 4e1f1dab..f6a8a2dc 100644 --- a/crates/libfmp/tests/economics_calendar_risk_endpoints.rs +++ b/crates/libfmp/tests/economics_calendar_risk_endpoints.rs @@ -97,7 +97,7 @@ async fn proxy_routing_preserves_calendar_query_order_custom_auth_and_bare_array let premiums = client.market_risk_premium().await.unwrap(); assert_eq!(events.len(), 1); - assert_eq!(events[0].actual, 13.6); + assert_eq!(events[0].actual, Some(13.6)); assert_eq!(premiums.len(), 1); assert_eq!(premiums[0].country, "Zimbabwe"); diff --git a/crates/libfmp/tests/economics_rates_indicators_endpoints.rs b/crates/libfmp/tests/economics_rates_indicators_endpoints.rs index 24f4d7b7..8a1b2803 100644 --- a/crates/libfmp/tests/economics_rates_indicators_endpoints.rs +++ b/crates/libfmp/tests/economics_rates_indicators_endpoints.rs @@ -103,9 +103,9 @@ async fn custom_proxy_preserves_exact_query_order_auth_headers_and_bare_arrays() .unwrap(); assert_eq!(rates.len(), 1); - assert_eq!(rates[0].year_30, 5.2); + assert_eq!(rates[0].year_30, Some(5.2)); assert_eq!(indicators.len(), 1); - assert_eq!(indicators[0].value, 31_422.526); + assert_eq!(indicators[0].value, Some(31_422.526)); let requests = executor.requests(); assert!( diff --git a/crates/libfmp/tests/economics_responses.rs b/crates/libfmp/tests/economics_responses.rs index 72ef2d63..8ef29ce0 100644 --- a/crates/libfmp/tests/economics_responses.rs +++ b/crates/libfmp/tests/economics_responses.rs @@ -19,7 +19,7 @@ const CALENDAR: &[u8] = include_bytes!("fixtures/economic_calendar.json"); const RISK_PREMIUM: &[u8] = include_bytes!("fixtures/market_risk_premium.json"); #[test] -fn treasury_fixture_decodes_all_13_exact_fields_without_scaling() { +fn treasury_fixture_decodes_all_13_exact_fields_with_a_null_tenor() { let value: serde_json::Value = serde_json::from_slice(TREASURY).unwrap(); assert_eq!(value[0].as_object().unwrap().len(), 13); @@ -28,18 +28,18 @@ fn treasury_fixture_decodes_all_13_exact_fields_without_scaling() { rows, [TreasuryRate { date: Date::from_str("2026-07-29").unwrap(), - month_1: 3.73, - month_2: 3.83, - month_3: 3.83, - month_6: 3.97, - year_1: 4.04, - year_2: 4.22, - year_3: 4.29, - year_5: 4.37, - year_7: 4.51, - year_10: 4.67, - year_20: 5.21, - year_30: 5.2, + month_1: Some(3.73), + month_2: None, + month_3: Some(3.83), + month_6: Some(3.97), + year_1: Some(4.04), + year_2: Some(4.22), + year_3: Some(4.29), + year_5: Some(4.37), + year_7: Some(4.51), + year_10: Some(4.67), + year_20: Some(5.21), + year_30: Some(5.2), }] ); } @@ -55,7 +55,7 @@ fn indicator_fixture_decodes_exact_three_field_contract() { [EconomicIndicatorObservation { name: EconomicIndicator::Gdp, date: Date::from_str("2025-10-01").unwrap(), - value: 31_422.526, + value: Some(31_422.526), }] ); @@ -63,10 +63,15 @@ fn indicator_fixture_decodes_exact_three_field_contract() { open[0]["name"] = serde_json::json!("futureProviderIndicator"); let rows: Vec = serde_json::from_value(open).unwrap(); assert_eq!(rows[0].name.as_str(), "futureProviderIndicator"); + + let mut null: serde_json::Value = serde_json::from_slice(INDICATORS).unwrap(); + null[0]["value"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(null).unwrap(); + assert_eq!(rows[0].value, None); } #[test] -fn calendar_fixture_decodes_all_11_fields_with_required_numeric_values() { +fn calendar_fixture_decodes_all_11_fields_with_null_values_as_none() { let value: serde_json::Value = serde_json::from_slice(CALENDAR).unwrap(); assert_eq!(value[0].as_object().unwrap().len(), 11); @@ -78,13 +83,13 @@ fn calendar_fixture_decodes_all_11_fields_with_required_numeric_values() { country: CountryCode::new("SG").unwrap(), event: "Import Prices YoY (Jun)".to_owned(), currency: CurrencyCode::new("SGD").unwrap(), - previous: 18.5, - estimate: 21.0, - actual: 13.6, - change: -4.9, + previous: Some(18.5), + estimate: None, + actual: Some(13.6), + change: Some(-4.9), impact: "Low".to_owned(), - change_percentage: -26.486, - unit: "%".to_owned(), + change_percentage: Some(-26.486), + unit: None, }] ); } @@ -117,13 +122,29 @@ fn market_risk_fixture_uses_full_country_name_and_raw_percentage_values() { #[test] fn every_economics_row_requires_documented_fields_and_accepts_unknown_fields() { - assert_contract::(TREASURY); - assert_contract::(INDICATORS); - assert_contract::(CALENDAR); - assert_contract::(RISK_PREMIUM); + assert_contract::( + TREASURY, + &[ + "month1", "month2", "month3", "month6", "year1", "year2", "year3", "year5", "year7", + "year10", "year20", "year30", + ], + ); + assert_contract::(INDICATORS, &["value"]); + assert_contract::( + CALENDAR, + &[ + "previous", + "estimate", + "actual", + "change", + "changePercentage", + "unit", + ], + ); + assert_contract::(RISK_PREMIUM, &[]); } -fn assert_contract(fixture: &[u8]) { +fn assert_contract(fixture: &[u8], nullable: &[&str]) { let source: serde_json::Value = serde_json::from_slice(fixture).unwrap(); let keys = source[0] .as_object() @@ -142,9 +163,10 @@ fn assert_contract(fixture: &[u8]) { let mut null = source.clone(); null[0][&key] = serde_json::Value::Null; - assert!( - serde_json::from_value::>(null).is_err(), - "accepted null {key}" + assert_eq!( + serde_json::from_value::>(null).is_ok(), + nullable.contains(&key.as_str()), + "null {key}" ); } diff --git a/crates/libfmp/tests/fixtures/economic_calendar.json b/crates/libfmp/tests/fixtures/economic_calendar.json index c6c6c47c..d863c89f 100644 --- a/crates/libfmp/tests/fixtures/economic_calendar.json +++ b/crates/libfmp/tests/fixtures/economic_calendar.json @@ -6,10 +6,10 @@ "country": "SG", "currency": "SGD", "date": "2026-07-29 03:30:00", - "estimate": 21, + "estimate": null, "event": "Import Prices YoY (Jun)", "impact": "Low", "previous": 18.5, - "unit": "%" + "unit": null } ] diff --git a/crates/libfmp/tests/fixtures/treasury_rates.json b/crates/libfmp/tests/fixtures/treasury_rates.json index 99699eb2..d6464b14 100644 --- a/crates/libfmp/tests/fixtures/treasury_rates.json +++ b/crates/libfmp/tests/fixtures/treasury_rates.json @@ -2,7 +2,7 @@ { "date": "2026-07-29", "month1": 3.73, - "month2": 3.83, + "month2": null, "month3": 3.83, "month6": 3.97, "year1": 4.04, diff --git a/docs/contract-ambiguities.md b/docs/contract-ambiguities.md index 8fa6d002..995da8a0 100644 --- a/docs/contract-ambiguities.md +++ b/docs/contract-ambiguities.md @@ -20,7 +20,6 @@ each row; `git log -S` on the quoted key finds the current location. | Area | Ambiguity | Current contract | Where | | --- | --- | --- | --- | -| Economics calendar | `previous`, `estimate`, `actual` are numeric in the only documented row; upcoming releases will carry null `actual` (and possibly null `previous`, `change`, `changePercentage`). | Required `f64`; a null is a decode error. Recipe in issue #40 (2026-09-21 comment): promote `required_option` from `responses/calendar.rs:10` into `codecs.rs`, mirror the ADR 0011 earnings `eps_actual` pattern. | `crates/libfmp/src/responses/economics.rs:46-48`; `tests/economics_responses.rs:139-144`; ADR 0010 | | Bulk ETF holder | Documented sample key is `lastUpdated"` with a trailing quote (outer.md 15134). A clean live `lastUpdated` would fail every row. | Exact `#[serde(rename = "lastUpdated\"")]`, no alias; value kept verbatim as `last_updated_raw`. | `crates/libfmp/src/responses/bulk/snapshots.rs:81`; ADR 0028 lines 58-60 | | SEC industry classification search | Only documented row is `{}`. | `Vec` (raw `dict` rows in Python). | `crates/libfmp/src/endpoints/sec_filings.rs:579-585`; ADR 0019 lines 27, 46 | | Whole-asset and exchange quotes | `short=false` response shapes are undocumented. | `ShortOnlyQuery` always emits `short=true`; `new(false)` is a `compile_fail` doctest. | `crates/libfmp/src/endpoints/quote.rs:174-183`; `forex.rs:40-43`; `commodities.rs:44`; `crypto.rs:38`; `indexes.rs:150`; ADR 0005 line 34 | @@ -51,3 +50,4 @@ it, so the history of each decision stays in one place. | Market cap, crypto supply, split terms | Every documented `marketCap`, `circulatingSupply`/`totalSupply`, and split `numerator`/`denominator` sample is a JSON integer, so the fields were `u64`; the same decoders reject a fractional (`1.5`), integral-float (`1.0`), or exponent-form (`1e9`) number, and one such row fails the whole call (issue #339, same class as #337). | `MarketCapitalization`, `TokenSupply`, and `SplitTerm` are `f64` aliases in Rust (`float64` in Go, `float` in Python) on all 16 response fields, each re-encoded through `codecs::integral_f64` like `Volume`; the screener `marketCapMoreThan`/`marketCapLowerThan` filters stay `u64`. Values above `2^53` round to the nearest `f64`. `totalSupply` stays required: the documentation only shows a number, and a `null` for an uncapped coin has not been observed. `tests/integral_f64_fields.rs` fails when a field typed with one of these aliases lacks the serializer. | `crates/libfmp/src/types.rs`; `crates/libfmp/src/codecs.rs` (`integral_f64`); `crates/libfmp/tests/integral_f64_responses.rs`; the three `*_fractional_synthetic.json` fixtures; ADR 0030 codec table | | Share quantities and money amounts | Share quantities (insider `securitiesOwned`/`securitiesTransacted`, fund holdings and disclosure `balance`, `floatShares`/`outstandingShares`, 13F `shares`/`sharesNumber`/`numberOf13Fshares`/`totalCalls`/`totalPuts`, DCF `dilutedSharesOutstanding`, statement weighted-average and summary share counts, aftermarket `tradeSize`/`bidSize`/`askSize`) and money amounts (ETF `assetsUnderManagement`, executive compensation, Regulation D offering amounts and fees, 13F market values, performance and `totalInvested`, `industryValue`, earnings-calendar revenue) were typed `Count`/`u64`/`i64` from integer-only samples; fund shorts carry a negative `balance`, and a fractional share amount fails the whole call in every decoder (issue #340, same class as #337). | Share quantities use the new `Quantity` alias and money amounts use `MarketValue`, both `f64` in Rust (`float64` in Go, `float` in Python) and re-encoded through `codecs::integral_f64`; `tests/integral_f64_fields.rs` now covers both aliases, so the pre-existing `MarketValue` fields gained the serializer too. The 13F average holding periods are plain `f64` with the same serializer. `Count` stays for true counts; the single-holding `holdingPeriod` (whole quarters), congressional disclosure ranges and totals (whole-dollar SEC buckets) and Commitment of Traders positions (whole contracts) stay integers. `StatementAmount` fields move separately (#341). | `crates/libfmp/src/types.rs` (`Quantity`, `MarketValue`); `crates/libfmp/tests/integral_f64_responses.rs`; the nine issue #340 `*_fractional_synthetic.json` fixtures; ADR 0030 codec table | | Statement amounts | The 276 `StatementAmount` fields (income, balance-sheet, cash-flow, key-metric, ratio and summary statements, analyst revenue/EBITDA/EBIT/net-income/SG&A estimates, DCF custom and levered outputs such as `terminalValue`, `enterpriseValue`, `equityValue`, `ufcf`, `ebiat`, `sumPvUfcf`, and crowdfunding offering and financial amounts) were `i64` from integer-only samples; a fractional or exponent-form amount fails the whole call in every decoder, and DCF outputs are computed values (issue #341, same class as #337). | Maintainer decision: `StatementAmount` is `f64` in Rust (`float64` in Go, `float` in Python), and every field re-encodes through `codecs::integral_f64`, so documented integer fixtures still round-trip as JSON integers; `tests/integral_f64_fields.rs` covers the alias. Values above `2^53` round to the nearest `f64` (the Rust tests use `-9e18`/`9e18` and `i64::MIN`, which are exact). DCF `taxRateCash` stays a `StatementAmount`: despite the name, the sample value (`16785417`) is a cash tax amount, not a rate. | `crates/libfmp/src/types.rs` (`StatementAmount`); `crates/libfmp/tests/integral_f64_responses.rs`; `income_statement_fractional_synthetic.json`; ADR 0030 codec table; ADR 0031 | +| Economics calendar | `previous`, `estimate`, `actual` were numeric in the only documented row; the live calendar (issue #368 audit, 2026-09-30) carries null `actual`, `estimate`, `previous`, `change`, `changePercentage`, and `unit` on upcoming and partial releases. | The six members are `Option` in Rust (pointer in Go, `Optional` in Python) with the local `required_option`: null decodes to `None`, a missing key still fails. The same issue made the 12 `TreasuryRate` tenors and `EconomicIndicatorObservation.value` optional. | `crates/libfmp/src/responses/economics.rs`; `tests/economics_responses.rs`; issue #368 | diff --git a/sdk/go/economics_models.go b/sdk/go/economics_models.go index 12189633..b2e175a5 100644 --- a/sdk/go/economics_models.go +++ b/sdk/go/economics_models.go @@ -13,26 +13,7 @@ import ( // TreasuryRate is treasury rates across all twelve documented maturities for // one date. type TreasuryRate struct { - Date Date `json:"date"` - Month1 float64 `json:"month1"` - Month2 float64 `json:"month2"` - Month3 float64 `json:"month3"` - Month6 float64 `json:"month6"` - Year1 float64 `json:"year1"` - Year2 float64 `json:"year2"` - Year3 float64 `json:"year3"` - Year5 float64 `json:"year5"` - Year7 float64 `json:"year7"` - Year10 float64 `json:"year10"` - Year20 float64 `json:"year20"` - Year30 float64 `json:"year30"` -} - -// treasuryRateShadow mirrors TreasuryRate with a pointer or raw value for -// every required member so a missing or null member is observable after -// decoding. -type treasuryRateShadow struct { - Date *Date `json:"date"` + Date Date `json:"date"` Month1 *float64 `json:"month1"` Month2 *float64 `json:"month2"` Month3 *float64 `json:"month3"` @@ -47,6 +28,25 @@ type treasuryRateShadow struct { Year30 *float64 `json:"year30"` } +// treasuryRateShadow mirrors TreasuryRate with a pointer or raw value for +// every required member so a missing or null member is observable after +// decoding. +type treasuryRateShadow struct { + Date *Date `json:"date"` + Month1 jsontext.Value `json:"month1"` + Month2 jsontext.Value `json:"month2"` + Month3 jsontext.Value `json:"month3"` + Month6 jsontext.Value `json:"month6"` + Year1 jsontext.Value `json:"year1"` + Year2 jsontext.Value `json:"year2"` + Year3 jsontext.Value `json:"year3"` + Year5 jsontext.Value `json:"year5"` + Year7 jsontext.Value `json:"year7"` + Year10 jsontext.Value `json:"year10"` + Year20 jsontext.Value `json:"year20"` + Year30 jsontext.Value `json:"year30"` +} + // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode // error naming the first required member that is missing or null, as the // Rust decoder does. Unknown members are ignored. @@ -58,45 +58,141 @@ func (m *TreasuryRate) UnmarshalJSONFrom(dec *jsontext.Decoder) error { switch { case shadow.Date == nil: return missingMemberError("TreasuryRate", "date") - case shadow.Month1 == nil: + case len(shadow.Month1) == 0: return missingMemberError("TreasuryRate", "month1") - case shadow.Month2 == nil: + case len(shadow.Month2) == 0: return missingMemberError("TreasuryRate", "month2") - case shadow.Month3 == nil: + case len(shadow.Month3) == 0: return missingMemberError("TreasuryRate", "month3") - case shadow.Month6 == nil: + case len(shadow.Month6) == 0: return missingMemberError("TreasuryRate", "month6") - case shadow.Year1 == nil: + case len(shadow.Year1) == 0: return missingMemberError("TreasuryRate", "year1") - case shadow.Year2 == nil: + case len(shadow.Year2) == 0: return missingMemberError("TreasuryRate", "year2") - case shadow.Year3 == nil: + case len(shadow.Year3) == 0: return missingMemberError("TreasuryRate", "year3") - case shadow.Year5 == nil: + case len(shadow.Year5) == 0: return missingMemberError("TreasuryRate", "year5") - case shadow.Year7 == nil: + case len(shadow.Year7) == 0: return missingMemberError("TreasuryRate", "year7") - case shadow.Year10 == nil: + case len(shadow.Year10) == 0: return missingMemberError("TreasuryRate", "year10") - case shadow.Year20 == nil: + case len(shadow.Year20) == 0: return missingMemberError("TreasuryRate", "year20") - case shadow.Year30 == nil: + case len(shadow.Year30) == 0: return missingMemberError("TreasuryRate", "year30") } + var month1 *float64 + if shadow.Month1.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Month1, &value); err != nil { + return err + } + month1 = &value + } + var month2 *float64 + if shadow.Month2.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Month2, &value); err != nil { + return err + } + month2 = &value + } + var month3 *float64 + if shadow.Month3.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Month3, &value); err != nil { + return err + } + month3 = &value + } + var month6 *float64 + if shadow.Month6.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Month6, &value); err != nil { + return err + } + month6 = &value + } + var year1 *float64 + if shadow.Year1.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Year1, &value); err != nil { + return err + } + year1 = &value + } + var year2 *float64 + if shadow.Year2.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Year2, &value); err != nil { + return err + } + year2 = &value + } + var year3 *float64 + if shadow.Year3.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Year3, &value); err != nil { + return err + } + year3 = &value + } + var year5 *float64 + if shadow.Year5.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Year5, &value); err != nil { + return err + } + year5 = &value + } + var year7 *float64 + if shadow.Year7.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Year7, &value); err != nil { + return err + } + year7 = &value + } + var year10 *float64 + if shadow.Year10.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Year10, &value); err != nil { + return err + } + year10 = &value + } + var year20 *float64 + if shadow.Year20.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Year20, &value); err != nil { + return err + } + year20 = &value + } + var year30 *float64 + if shadow.Year30.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Year30, &value); err != nil { + return err + } + year30 = &value + } *m = TreasuryRate{ Date: *shadow.Date, - Month1: *shadow.Month1, - Month2: *shadow.Month2, - Month3: *shadow.Month3, - Month6: *shadow.Month6, - Year1: *shadow.Year1, - Year2: *shadow.Year2, - Year3: *shadow.Year3, - Year5: *shadow.Year5, - Year7: *shadow.Year7, - Year10: *shadow.Year10, - Year20: *shadow.Year20, - Year30: *shadow.Year30, + Month1: month1, + Month2: month2, + Month3: month3, + Month6: month6, + Year1: year1, + Year2: year2, + Year3: year3, + Year5: year5, + Year7: year7, + Year10: year10, + Year20: year20, + Year30: year30, } return nil } @@ -104,18 +200,18 @@ func (m *TreasuryRate) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // EconomicIndicatorObservation is one observation of a documented or validated // open economic indicator. type EconomicIndicatorObservation struct { - Name string `json:"name"` - Date Date `json:"date"` - Value float64 `json:"value"` + Name string `json:"name"` + Date Date `json:"date"` + Value *float64 `json:"value"` } // economicIndicatorObservationShadow mirrors EconomicIndicatorObservation with // a pointer or raw value for every required member so a missing or null member // is observable after decoding. type economicIndicatorObservationShadow struct { - Name *string `json:"name"` - Date *Date `json:"date"` - Value *float64 `json:"value"` + Name *string `json:"name"` + Date *Date `json:"date"` + Value jsontext.Value `json:"value"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -131,13 +227,21 @@ func (m *EconomicIndicatorObservation) UnmarshalJSONFrom(dec *jsontext.Decoder) return missingMemberError("EconomicIndicatorObservation", "name") case shadow.Date == nil: return missingMemberError("EconomicIndicatorObservation", "date") - case shadow.Value == nil: + case len(shadow.Value) == 0: return missingMemberError("EconomicIndicatorObservation", "value") } + var valueMember *float64 + if shadow.Value.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Value, &value); err != nil { + return err + } + valueMember = &value + } *m = EconomicIndicatorObservation{ Name: *shadow.Name, Date: *shadow.Date, - Value: *shadow.Value, + Value: valueMember, } return nil } @@ -148,30 +252,30 @@ type EconomicCalendarEvent struct { Country string `json:"country"` Event string `json:"event"` Currency string `json:"currency"` - Previous float64 `json:"previous"` - Estimate float64 `json:"estimate"` - Actual float64 `json:"actual"` - Change float64 `json:"change"` + Previous *float64 `json:"previous"` + Estimate *float64 `json:"estimate"` + Actual *float64 `json:"actual"` + Change *float64 `json:"change"` Impact string `json:"impact"` - ChangePercentage float64 `json:"changePercentage"` - Unit string `json:"unit"` + ChangePercentage *float64 `json:"changePercentage"` + Unit *string `json:"unit"` } // economicCalendarEventShadow mirrors EconomicCalendarEvent with a pointer or // raw value for every required member so a missing or null member is // observable after decoding. type economicCalendarEventShadow struct { - Date *DateTime `json:"date"` - Country *string `json:"country"` - Event *string `json:"event"` - Currency *string `json:"currency"` - Previous *float64 `json:"previous"` - Estimate *float64 `json:"estimate"` - Actual *float64 `json:"actual"` - Change *float64 `json:"change"` - Impact *string `json:"impact"` - ChangePercentage *float64 `json:"changePercentage"` - Unit *string `json:"unit"` + Date *DateTime `json:"date"` + Country *string `json:"country"` + Event *string `json:"event"` + Currency *string `json:"currency"` + Previous jsontext.Value `json:"previous"` + Estimate jsontext.Value `json:"estimate"` + Actual jsontext.Value `json:"actual"` + Change jsontext.Value `json:"change"` + Impact *string `json:"impact"` + ChangePercentage jsontext.Value `json:"changePercentage"` + Unit jsontext.Value `json:"unit"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -191,33 +295,81 @@ func (m *EconomicCalendarEvent) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("EconomicCalendarEvent", "event") case shadow.Currency == nil: return missingMemberError("EconomicCalendarEvent", "currency") - case shadow.Previous == nil: + case len(shadow.Previous) == 0: return missingMemberError("EconomicCalendarEvent", "previous") - case shadow.Estimate == nil: + case len(shadow.Estimate) == 0: return missingMemberError("EconomicCalendarEvent", "estimate") - case shadow.Actual == nil: + case len(shadow.Actual) == 0: return missingMemberError("EconomicCalendarEvent", "actual") - case shadow.Change == nil: + case len(shadow.Change) == 0: return missingMemberError("EconomicCalendarEvent", "change") case shadow.Impact == nil: return missingMemberError("EconomicCalendarEvent", "impact") - case shadow.ChangePercentage == nil: + case len(shadow.ChangePercentage) == 0: return missingMemberError("EconomicCalendarEvent", "changePercentage") - case shadow.Unit == nil: + case len(shadow.Unit) == 0: return missingMemberError("EconomicCalendarEvent", "unit") } + var previous *float64 + if shadow.Previous.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Previous, &value); err != nil { + return err + } + previous = &value + } + var estimate *float64 + if shadow.Estimate.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Estimate, &value); err != nil { + return err + } + estimate = &value + } + var actual *float64 + if shadow.Actual.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Actual, &value); err != nil { + return err + } + actual = &value + } + var change *float64 + if shadow.Change.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Change, &value); err != nil { + return err + } + change = &value + } + var changePercentage *float64 + if shadow.ChangePercentage.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.ChangePercentage, &value); err != nil { + return err + } + changePercentage = &value + } + var unit *string + if shadow.Unit.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Unit, &value); err != nil { + return err + } + unit = &value + } *m = EconomicCalendarEvent{ Date: *shadow.Date, Country: *shadow.Country, Event: *shadow.Event, Currency: *shadow.Currency, - Previous: *shadow.Previous, - Estimate: *shadow.Estimate, - Actual: *shadow.Actual, - Change: *shadow.Change, + Previous: previous, + Estimate: estimate, + Actual: actual, + Change: change, Impact: *shadow.Impact, - ChangePercentage: *shadow.ChangePercentage, - Unit: *shadow.Unit, + ChangePercentage: changePercentage, + Unit: unit, } return nil } diff --git a/sdk/go/economics_parity_test.go b/sdk/go/economics_parity_test.go index 49a59891..05d03460 100644 --- a/sdk/go/economics_parity_test.go +++ b/sdk/go/economics_parity_test.go @@ -1,8 +1,10 @@ package fmp import ( + "encoding/json/jsontext" "encoding/json/v2" "errors" + "reflect" "strings" "testing" ) @@ -16,14 +18,15 @@ func TestEconomicsFixturesDecodeAndReencodeToTheSameMemberSet(t *testing.T) { } // Exact values copied from crates/libfmp/tests/economics_responses.rs. -func TestTreasuryFixtureDecodesAll13ExactFieldsWithoutScaling(t *testing.T) { +func TestTreasuryFixtureDecodesAll13ExactFieldsWithANullTenor(t *testing.T) { t.Parallel() rows := assertFixtureParity[TreasuryRate](t, "treasury_rates.json") want := TreasuryRate{ - Date: mustParseDate(t, "2026-07-29"), Month1: 3.73, Month2: 3.83, Month3: 3.83, Month6: 3.97, - Year1: 4.04, Year2: 4.22, Year3: 4.29, Year5: 4.37, Year7: 4.51, Year10: 4.67, Year20: 5.21, Year30: 5.2, + Date: mustParseDate(t, "2026-07-29"), Month1: new(3.73), Month2: nil, Month3: new(3.83), Month6: new(3.97), + Year1: new(4.04), Year2: new(4.22), Year3: new(4.29), Year5: new(4.37), Year7: new(4.51), Year10: new(4.67), + Year20: new(5.21), Year30: new(5.2), } - if len(rows) != 1 || rows[0] != want { + if len(rows) != 1 || !reflect.DeepEqual(rows[0], want) { t.Fatalf("treasury_rates = %+v, want %+v", rows, want) } if got := memberSet(t, rows[0]); len(got) != 13 { @@ -36,8 +39,8 @@ func TestTreasuryFixtureDecodesAll13ExactFieldsWithoutScaling(t *testing.T) { func TestIndicatorFixtureDecodesExactThreeFieldContract(t *testing.T) { t.Parallel() rows := assertFixtureParity[EconomicIndicatorObservation](t, "economic_indicators.json") - want := EconomicIndicatorObservation{Name: "GDP", Date: mustParseDate(t, "2025-10-01"), Value: 31_422.526} - if len(rows) != 1 || rows[0] != want { + want := EconomicIndicatorObservation{Name: "GDP", Date: mustParseDate(t, "2025-10-01"), Value: new(31_422.526)} + if len(rows) != 1 || !reflect.DeepEqual(rows[0], want) { t.Fatalf("economic_indicators = %+v, want %+v", rows, want) } if EconomicIndicator(rows[0].Name) != EconomicIndicatorGdp { @@ -47,17 +50,23 @@ func TestIndicatorFixtureDecodesExactThreeFieldContract(t *testing.T) { if err := json.Unmarshal([]byte(open), &rows); err != nil || rows[0].Name != "futureProviderIndicator" { t.Fatalf("open indicator name = %+v, %v", rows, err) } + null := patchedFixture(t, "economic_indicators.json", func(row map[string]jsontext.Value) { + row["value"] = jsontext.Value("null") + }) + if err := json.Unmarshal(null, &rows); err != nil || rows[0].Value != nil { + t.Fatalf("null value = %+v, %v, want nil", rows, err) + } } -func TestCalendarFixtureDecodesAll11FieldsWithRequiredNumericValues(t *testing.T) { +func TestCalendarFixtureDecodesAll11FieldsWithNullValuesAsNil(t *testing.T) { t.Parallel() rows := assertFixtureParity[EconomicCalendarEvent](t, "economic_calendar.json") want := EconomicCalendarEvent{ Date: mustParseDateTime(t, "2026-07-29 03:30:00"), Country: "SG", Event: "Import Prices YoY (Jun)", - Currency: "SGD", Previous: 18.5, Estimate: 21.0, Actual: 13.6, Change: -4.9, Impact: "Low", - ChangePercentage: -26.486, Unit: "%", + Currency: "SGD", Previous: new(18.5), Estimate: nil, Actual: new(13.6), Change: new(-4.9), Impact: "Low", + ChangePercentage: new(-26.486), Unit: nil, } - if len(rows) != 1 || rows[0] != want { + if len(rows) != 1 || !reflect.DeepEqual(rows[0], want) { t.Fatalf("economic_calendar = %+v, want %+v", rows, want) } if got := memberSet(t, rows[0]); len(got) != 11 { @@ -90,14 +99,17 @@ func TestMarketRiskFixtureUsesFullCountryNameAndRawPercentageValues(t *testing.T } } -// Mirrors every_economics_row_requires_documented_fields_and_accepts_unknown_fields -// for the documented-null ambiguity of the economics calendar -// (docs/contract-ambiguities.md, ADR 0010): the Rust model keeps previous, -// estimate, and actual as required f64, so a null actual is a decode error -// here too, and the missing-required-member path is covered once for this -// domain. +// Mirrors every_economics_row_requires_documented_fields_and_accepts_unknown_fields: +// every member stays required, and null is accepted only for the optional +// tenors, indicator value, and calendar figures (#368). func TestEconomicsRequiredMembersAreEnforcedLikeSerde(t *testing.T) { t.Parallel() + assertMemberContract[TreasuryRate](t, "treasury_rates.json", "month1", "month2", "month3", "month6", + "year1", "year2", "year3", "year5", "year7", "year10", "year20", "year30") + assertMemberContract[EconomicIndicatorObservation](t, "economic_indicators.json", "value") + assertMemberContract[EconomicCalendarEvent](t, "economic_calendar.json", "previous", "estimate", "actual", + "change", "changePercentage", "unit") + assertMemberContract[MarketRiskPremium](t, "market_risk_premium.json") head := `[{"date":"2026-07-29 03:30:00","country":"SG","event":"Import Prices YoY (Jun)","currency":"SGD",` + `"previous":18.5,"estimate":21,` tail := `"change":-4.9,"impact":"Low","changePercentage":-26.486,"unit":"%"}]` @@ -106,7 +118,6 @@ func TestEconomicsRequiredMembersAreEnforcedLikeSerde(t *testing.T) { wire string member string }{ - {"null actual", head + `"actual":null,` + tail, "actual"}, {"missing actual", head + tail, "actual"}, {"empty object", `[{}]`, "date"}, } diff --git a/sdk/go/economics_test.go b/sdk/go/economics_test.go index eb8b1f31..655ef327 100644 --- a/sdk/go/economics_test.go +++ b/sdk/go/economics_test.go @@ -51,7 +51,7 @@ func TestEconomicsMethodsUseExactPathsAndWireOrder(t *testing.T) { from, to := mustParseDate(t, "2026-01-27"), mustParseDate(t, "2026-04-27") rates, err := client.Economics.TreasuryRates(ctx, NewTreasuryRatesQuery().WithFrom(from).WithTo(to)) - if err != nil || len(rates) != 1 || rates[0].Year30 != 5.2 { + if err != nil || len(rates) != 1 || rates[0].Year30 == nil || *rates[0].Year30 != 5.2 { t.Fatalf("TreasuryRates = %+v, %v", rates, err) } if rates, err = client.Economics.TreasuryRates(ctx, NewTreasuryRatesQuery().WithTo(to)); err != nil || len(rates) != 1 { @@ -59,7 +59,7 @@ func TestEconomicsMethodsUseExactPathsAndWireOrder(t *testing.T) { } indicators, err := client.Economics.Indicators(ctx, NewEconomicIndicatorsQuery(EconomicIndicatorGdp).WithFrom(from).WithTo(to)) - if err != nil || len(indicators) != 1 || indicators[0].Value != 31_422.526 { + if err != nil || len(indicators) != 1 || indicators[0].Value == nil || *indicators[0].Value != 31_422.526 { t.Fatalf("Indicators = %+v, %v", indicators, err) } if indicators, err = client.Economics.Indicators(ctx, @@ -67,7 +67,7 @@ func TestEconomicsMethodsUseExactPathsAndWireOrder(t *testing.T) { t.Fatalf("Indicators with an open name = %+v, %v", indicators, err) } events, err := client.Economics.Calendar(ctx, NewEconomicCalendarQuery().WithCountry("US").WithFrom(from).WithTo(to)) - if err != nil || len(events) != 1 || events[0].Actual != 13.6 { + if err != nil || len(events) != 1 || events[0].Actual == nil || *events[0].Actual != 13.6 { t.Fatalf("Calendar = %+v, %v", events, err) } if events, err = client.Economics.Calendar(ctx, NewEconomicCalendarQuery().WithCountry("US")); err != nil || len(events) != 1 {