From 73240e2471ba85ea86844186cab1f6b97b098afb Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 21:30:27 +0800 Subject: [PATCH 1/2] feat(directory)!: make omittable directory and index members optional AvailableExchange, FinancialStatementSymbol, and HistoricalIndexConstituent now type the members FMP can omit as optional, and a blank value on a typed code decodes as absent so consumers observe absence directly. **Response model changes:** * `AvailableExchange.country_code` and `delay`, `FinancialStatementSymbol.reporting_currency`, and `HistoricalIndexConstituent.added_security` and `reason` are now optional across the Rust models, the Python bindings and stubs, and the Go SDK. * Blank typed codes (`countryCode`, `reportingCurrency`, `removedTicker`) now decode to `None`/`nil`, while `delay`, `added_security`, and `reason` accept null but preserve an empty string. * A missing member is still rejected: these members remain required on the wire and only null or blank maps to absence. **Test and fixture changes:** * Added Rust, Python, and Go parity tests covering null and empty decoding, wire round-trips, and the still-required missing-member path. * Updated the Dow Jones fixture to carry a null `reason` and adjusted existing assertions to unwrap the new optional members. BREAKING CHANGE: AvailableExchange, FinancialStatementSymbol and HistoricalIndexConstituent members FMP can omit are now optional, and "" on their typed codes decodes as None (Option / pointer / Optional); handle None. --- .../python/fmp/_native/directory/__init__.pyi | 12 +-- .../python/fmp/_native/indexes/__init__.pyi | 8 +- crates/fmp-py/src/models/directory.rs | 16 ++-- crates/fmp-py/src/models/indexes.rs | 8 +- crates/fmp-py/tests/test_directory.py | 38 +++++++++ crates/fmp-py/tests/test_indexes_b.py | 24 +++++- crates/fmp-py/tests/typecheck_contract.py | 3 +- crates/libfmp/src/responses/directory.rs | 19 ++++- crates/libfmp/src/responses/indexes.rs | 6 +- crates/libfmp/tests/directory_responses.rs | 28 ++++++- .../tests/directory_taxonomy_responses.rs | 40 +++++++++- ...xes_historical_dow_jones_constituents.json | 2 +- .../tests/indexes_constituent_responses.rs | 25 +++++- sdk/go/directory_models.go | 80 +++++++++++++------ sdk/go/directory_parity_test.go | 31 ++++++- sdk/go/indexes_models.go | 36 ++++++--- sdk/go/indexes_parity_test.go | 13 ++- 17 files changed, 312 insertions(+), 77 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/directory/__init__.pyi b/crates/fmp-py/python/fmp/_native/directory/__init__.pyi index 941cdd14..23e0b435 100644 --- a/crates/fmp-py/python/fmp/_native/directory/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/directory/__init__.pyi @@ -79,11 +79,11 @@ class AvailableExchange: @property def country_name(self) -> builtins.str: ... @property - def country_code(self) -> builtins.str: ... + def country_code(self) -> typing.Optional[builtins.str]: ... @property def symbol_suffix(self) -> builtins.str: ... @property - def delay(self) -> builtins.str: ... + def delay(self) -> typing.Optional[builtins.str]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, @@ -91,9 +91,9 @@ class AvailableExchange: exchange: builtins.str, name: builtins.str, country_name: builtins.str, - country_code: builtins.str, + country_code: typing.Optional[builtins.str], symbol_suffix: builtins.str, - delay: builtins.str, + delay: typing.Optional[builtins.str], ) -> AvailableExchange: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... @@ -327,7 +327,7 @@ class FinancialStatementSymbol: @property def trading_currency(self) -> builtins.str: ... @property - def reporting_currency(self) -> builtins.str: ... + def reporting_currency(self) -> typing.Optional[builtins.str]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, @@ -335,7 +335,7 @@ class FinancialStatementSymbol: symbol: builtins.str, company_name: builtins.str, trading_currency: builtins.str, - reporting_currency: builtins.str, + reporting_currency: typing.Optional[builtins.str], ) -> FinancialStatementSymbol: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... diff --git a/crates/fmp-py/python/fmp/_native/indexes/__init__.pyi b/crates/fmp-py/python/fmp/_native/indexes/__init__.pyi index be8ed26e..01c31d1b 100644 --- a/crates/fmp-py/python/fmp/_native/indexes/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/indexes/__init__.pyi @@ -32,7 +32,7 @@ class HistoricalIndexConstituent: @property def date_added(self) -> builtins.str: ... @property - def added_security(self) -> builtins.str: ... + def added_security(self) -> typing.Optional[builtins.str]: ... @property def removed_ticker(self) -> typing.Optional[builtins.str]: ... @property @@ -42,18 +42,18 @@ class HistoricalIndexConstituent: @property def symbol(self) -> builtins.str: ... @property - def reason(self) -> builtins.str: ... + def reason(self) -> typing.Optional[builtins.str]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, *, date_added: builtins.str, - added_security: builtins.str, + added_security: typing.Optional[builtins.str], removed_ticker: typing.Optional[builtins.str], removed_security: typing.Optional[builtins.str], date: datetime.date, symbol: builtins.str, - reason: builtins.str, + reason: typing.Optional[builtins.str], ) -> HistoricalIndexConstituent: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... diff --git a/crates/fmp-py/src/models/directory.rs b/crates/fmp-py/src/models/directory.rs index b7c925a0..77dae0eb 100644 --- a/crates/fmp-py/src/models/directory.rs +++ b/crates/fmp-py/src/models/directory.rs @@ -108,7 +108,7 @@ pub(crate) struct FinancialStatementSymbol { #[pyo3(get)] pub trading_currency: String, #[pyo3(get)] - pub reporting_currency: String, + pub reporting_currency: Option, } #[gen_stub_pymethods] @@ -122,7 +122,7 @@ impl FinancialStatementSymbol { symbol: String, company_name: String, trading_currency: String, - reporting_currency: String, + reporting_currency: Option, ) -> Self { Self { symbol, @@ -223,7 +223,7 @@ impl From for FinancialS symbol: value.symbol.into_inner(), company_name: value.company_name, trading_currency: value.trading_currency.into_inner(), - reporting_currency: value.reporting_currency.into_inner(), + reporting_currency: value.reporting_currency.map(|value| value.into_inner()), } } } @@ -727,11 +727,11 @@ pub(crate) struct AvailableExchange { #[pyo3(get)] pub country_name: String, #[pyo3(get)] - pub country_code: String, + pub country_code: Option, #[pyo3(get)] pub symbol_suffix: String, #[pyo3(get)] - pub delay: String, + pub delay: Option, } #[gen_stub_pymethods] @@ -745,9 +745,9 @@ impl AvailableExchange { exchange: String, name: String, country_name: String, - country_code: String, + country_code: Option, symbol_suffix: String, - delay: String, + delay: Option, ) -> Self { Self { exchange, @@ -864,7 +864,7 @@ impl From for AvailableExchange exchange: value.exchange.into_inner(), name: value.name, country_name: value.country_name, - country_code: value.country_code.into_inner(), + country_code: value.country_code.map(|value| value.into_inner()), symbol_suffix: value.symbol_suffix, delay: value.delay, } diff --git a/crates/fmp-py/src/models/indexes.rs b/crates/fmp-py/src/models/indexes.rs index 76085b8e..68c1a529 100644 --- a/crates/fmp-py/src/models/indexes.rs +++ b/crates/fmp-py/src/models/indexes.rs @@ -297,7 +297,7 @@ pub(crate) struct HistoricalIndexConstituent { #[pyo3(get)] pub date_added: String, #[pyo3(get)] - pub added_security: String, + pub added_security: Option, #[pyo3(get)] pub removed_ticker: Option, #[pyo3(get)] @@ -307,7 +307,7 @@ pub(crate) struct HistoricalIndexConstituent { #[pyo3(get)] pub symbol: String, #[pyo3(get)] - pub reason: String, + pub reason: Option, } #[gen_stub_pymethods] @@ -319,12 +319,12 @@ impl HistoricalIndexConstituent { #[pyo3(signature = (*, date_added, added_security, removed_ticker, removed_security, date, symbol, reason))] fn new( date_added: String, - added_security: String, + added_security: Option, removed_ticker: Option, removed_security: Option, date: ::chrono::NaiveDate, symbol: String, - reason: String, + reason: Option, ) -> Self { Self { date_added, diff --git a/crates/fmp-py/tests/test_directory.py b/crates/fmp-py/tests/test_directory.py index e5ee8c66..759d9d23 100644 --- a/crates/fmp-py/tests/test_directory.py +++ b/crates/fmp-py/tests/test_directory.py @@ -221,6 +221,44 @@ def test_available_exchanges_preserves_omitted_false_and_true( assert row.delay == "Real-time" +def test_available_exchanges_decodes_blank_country_code_and_null_delay_as_none( + client: Any, fixture_server: FixtureServer +) -> None: + """A blank ``countryCode`` and a null ``delay`` decode as ``None``; ``countryName`` stays ``""``.""" + row = { + "exchange": "CRYPTO", + "name": "Cryptocurrency", + "countryName": "", + "countryCode": "", + "symbolSuffix": "N/A", + "delay": None, + } + fixture_server.route("/available-exchanges", [row]) + decoded = client.directory.available_exchanges()[0] + + assert decoded.country_name == "" + assert decoded.country_code is None + assert decoded.delay is None + + +@pytest.mark.parametrize("reporting_currency", [None, ""], ids=["null", "empty"]) +def test_financial_statement_symbols_decode_missing_reporting_currency_as_none( + client: Any, fixture_server: FixtureServer, reporting_currency: str | None +) -> None: + """A null or blank ``reportingCurrency`` decodes as ``None``.""" + row = { + "symbol": "1609.HK", + "companyName": "Chong Kin Group Holdings Limited", + "tradingCurrency": "HKD", + "reportingCurrency": reporting_currency, + } + fixture_server.route("/financial-statement-symbol-list", [row]) + decoded = client.directory.financial_statement_symbols()[0] + + assert decoded.trading_currency == "HKD" + assert decoded.reporting_currency is None + + def test_available_sectors_decodes_the_open_sector_value(client: Any, fixture_server: FixtureServer) -> None: """``available_sectors`` maps to ``/available-sectors``.""" fixture_server.route("/available-sectors", load_fixture("directory_available_sectors.json")) diff --git a/crates/fmp-py/tests/test_indexes_b.py b/crates/fmp-py/tests/test_indexes_b.py index 5f309762..4f297719 100644 --- a/crates/fmp-py/tests/test_indexes_b.py +++ b/crates/fmp-py/tests/test_indexes_b.py @@ -126,7 +126,29 @@ def test_historical_constituents_take_no_arguments( assert row.removed_ticker == removed_ticker assert row.date == date assert isinstance(row.date_added, str) - assert row.reason + assert (row.reason is None) == (symbol == "GOOGL") + + +def test_historical_constituents_decode_null_members_and_blank_removed_ticker_as_none( + client: Any, fixture_server: FixtureServer +) -> None: + """Null ``addedSecurity`` and ``reason`` and a blank ``removedTicker`` decode as ``None``.""" + row = { + "addedSecurity": None, + "date": "2026-07-06", + "dateAdded": "July 7, 2026", + "reason": None, + "removedSecurity": "", + "removedTicker": "", + "symbol": "SPCX", + } + fixture_server.route("/historical-nasdaq-constituent", [row]) + decoded = client.indexes.historical_nasdaq_constituents()[0] + + assert decoded.added_security is None + assert decoded.reason is None + assert decoded.removed_ticker is None + assert decoded.removed_security == "" @pytest.mark.parametrize( diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 170edb5c..527cebf3 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -180,11 +180,12 @@ def check_directory_contract(client: FmpClient) -> None: changed_on: datetime.date = changes[0].date exchanges: list[AvailableExchange] = client.directory.available_exchanges(extended=True) suffix: str = exchanges[0].symbol_suffix + delay: str | None = exchanges[0].delay entries: list[CikListing] = client.directory.cik_list(page=0, limit=1000) cik: str = entries[0].cik transcripts: list[EarningsTranscriptAvailability] = client.directory.earnings_transcript_list() count: str = transcripts[0].no_of_transcripts - _ = (changed_on, suffix, cik, count) + _ = (changed_on, suffix, delay, cik, count) def check_congressional_contract(client: FmpClient) -> None: diff --git a/crates/libfmp/src/responses/directory.rs b/crates/libfmp/src/responses/directory.rs index 3abb7fe3..483e66e6 100644 --- a/crates/libfmp/src/responses/directory.rs +++ b/crates/libfmp/src/responses/directory.rs @@ -2,13 +2,21 @@ //! //! The Python binding exposes these models under `fmp.directory`. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ codecs::NumericString, types::{Cik, CountryCode, CurrencyCode, Date, ExchangeCode, Industry, Sector, Ticker}, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One worldwide company or instrument in the company-symbol directory. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] @@ -26,7 +34,8 @@ pub struct FinancialStatementSymbol { pub symbol: Ticker, pub company_name: String, pub trading_currency: CurrencyCode, - pub reporting_currency: CurrencyCode, + #[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")] + pub reporting_currency: Option, } /// One US SEC entity in the CIK directory. @@ -85,9 +94,11 @@ pub struct AvailableExchange { pub exchange: ExchangeCode, pub name: String, pub country_name: String, - pub country_code: CountryCode, + #[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")] + pub country_code: Option, pub symbol_suffix: String, - pub delay: String, + #[serde(deserialize_with = "required_option")] + pub delay: Option, } /// One sector accepted by provider sector filters. diff --git a/crates/libfmp/src/responses/indexes.rs b/crates/libfmp/src/responses/indexes.rs index d1085f36..20b6d9ca 100644 --- a/crates/libfmp/src/responses/indexes.rs +++ b/crates/libfmp/src/responses/indexes.rs @@ -51,12 +51,14 @@ pub struct IndexConstituent { #[serde(rename_all = "camelCase")] pub struct HistoricalIndexConstituent { pub date_added: OpaqueDateText, - pub added_security: String, #[serde(deserialize_with = "required_option")] + pub added_security: Option, + #[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")] pub removed_ticker: Option, #[serde(deserialize_with = "required_option")] pub removed_security: Option, pub date: Date, pub symbol: Ticker, - pub reason: String, + #[serde(deserialize_with = "required_option")] + pub reason: Option, } diff --git a/crates/libfmp/tests/directory_responses.rs b/crates/libfmp/tests/directory_responses.rs index d383e6a9..81287aa7 100644 --- a/crates/libfmp/tests/directory_responses.rs +++ b/crates/libfmp/tests/directory_responses.rs @@ -2,6 +2,7 @@ use libfmp::responses::directory::{ ActivelyTradingSymbol, CikListing, CompanySymbol, EarningsTranscriptAvailability, EtfSymbol, FinancialStatementSymbol, SymbolChange, }; +use serde_json::{Value, json}; #[test] fn documented_directory_rows_decode_exact_field_names_and_wire_types() { @@ -30,7 +31,10 @@ fn documented_directory_rows_decode_exact_field_names_and_wire_types() { assert_eq!(financials[0].symbol.as_str(), "RMES.CN"); assert_eq!(financials[0].company_name, "Red Metal Resources Ltd."); assert_eq!(financials[0].trading_currency.as_str(), "CAD"); - assert_eq!(financials[0].reporting_currency.as_str(), "USD"); + assert_eq!( + financials[0].reporting_currency.as_ref().unwrap().as_str(), + "USD" + ); assert_eq!(ciks[0].cik.as_str(), "0002137358"); assert_eq!(ciks[0].company_name, "Osotspa Public Co Limited/ADR"); @@ -69,6 +73,28 @@ fn documented_directory_rows_decode_exact_field_names_and_wire_types() { assert!(transcript_wire["noOfTranscripts"].is_string()); } +#[test] +fn financial_statement_symbol_reporting_currency_decodes_null_and_empty_as_none() { + for reporting_currency in [Value::Null, json!("")] { + let row = json!({ + "symbol": "1609.HK", + "companyName": "Chong Kin Group Holdings Limited", + "tradingCurrency": "HKD", + "reportingCurrency": reporting_currency, + }); + let decoded: FinancialStatementSymbol = serde_json::from_value(row).unwrap(); + assert_eq!(decoded.reporting_currency, None); + assert!(serde_json::to_value(&decoded).unwrap()["reportingCurrency"].is_null()); + } + + let missing = json!({ + "symbol": "1609.HK", + "companyName": "Chong Kin Group Holdings Limited", + "tradingCurrency": "HKD", + }); + assert!(serde_json::from_value::(missing).is_err()); +} + #[test] fn every_directory_response_contract_preserves_empty_arrays() { let empty = include_str!("fixtures/directory_empty.json"); diff --git a/crates/libfmp/tests/directory_taxonomy_responses.rs b/crates/libfmp/tests/directory_taxonomy_responses.rs index 88098d0a..c394a984 100644 --- a/crates/libfmp/tests/directory_taxonomy_responses.rs +++ b/crates/libfmp/tests/directory_taxonomy_responses.rs @@ -1,6 +1,7 @@ use libfmp::responses::directory::{ AvailableCountry, AvailableExchange, AvailableIndustry, AvailableSector, }; +use serde_json::{Value, json}; #[test] fn documented_taxonomy_rows_decode_exact_field_names_and_open_values() { @@ -17,9 +18,9 @@ fn documented_taxonomy_rows_decode_exact_field_names_and_open_values() { assert_eq!(exchange.exchange.as_str(), "AMEX"); assert_eq!(exchange.name, "New York Stock Exchange Arca"); assert_eq!(exchange.country_name, "United States of America"); - assert_eq!(exchange.country_code.as_str(), "US"); + assert_eq!(exchange.country_code.as_ref().unwrap().as_str(), "US"); assert_eq!(exchange.symbol_suffix, "N/A"); - assert_eq!(exchange.delay, "Real-time"); + assert_eq!(exchange.delay.as_deref(), Some("Real-time")); assert_eq!(sectors[0].sector.as_str(), "Basic Materials"); assert_eq!(industries[0].industry.as_str(), "Steel"); assert_eq!(countries[0].country.as_str(), "FK"); @@ -31,6 +32,41 @@ fn documented_taxonomy_rows_decode_exact_field_names_and_open_values() { assert_eq!(wire["delay"], "Real-time"); } +#[test] +fn available_exchange_blank_country_code_and_null_delay_decode_as_none() { + let row = json!({ + "exchange": "CRYPTO", + "name": "Cryptocurrency", + "countryName": "", + "countryCode": "", + "symbolSuffix": "N/A", + "delay": null, + }); + let decoded: AvailableExchange = serde_json::from_value(row.clone()).unwrap(); + assert_eq!(decoded.country_name, ""); + assert_eq!(decoded.country_code, None); + assert_eq!(decoded.delay, None); + let wire = serde_json::to_value(&decoded).unwrap(); + assert_eq!(wire["countryName"], ""); + assert!(wire["countryCode"].is_null()); + assert!(wire["delay"].is_null()); + + let mut null_code = row.clone(); + null_code["countryCode"] = Value::Null; + assert_eq!( + serde_json::from_value::(null_code) + .unwrap() + .country_code, + None + ); + + for field in ["countryCode", "delay"] { + let mut missing = row.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(missing).is_err()); + } +} + #[test] fn every_taxonomy_response_contract_preserves_empty_arrays() { let empty = include_str!("fixtures/directory_empty.json"); diff --git a/crates/libfmp/tests/fixtures/indexes_historical_dow_jones_constituents.json b/crates/libfmp/tests/fixtures/indexes_historical_dow_jones_constituents.json index 1f069f22..3439e581 100644 --- a/crates/libfmp/tests/fixtures/indexes_historical_dow_jones_constituents.json +++ b/crates/libfmp/tests/fixtures/indexes_historical_dow_jones_constituents.json @@ -3,7 +3,7 @@ "addedSecurity": "Alphabet Inc.", "date": "2026-06-29", "dateAdded": "June 29, 2026", - "reason": "To better reflect today's U.S. economy, where AI, cloud computing, and digital services play a much larger role than traditional telecommunications.", + "reason": null, "removedSecurity": "Verizon Communications Inc.", "removedTicker": "VZ", "symbol": "GOOGL" diff --git a/crates/libfmp/tests/indexes_constituent_responses.rs b/crates/libfmp/tests/indexes_constituent_responses.rs index 7d7da6b0..5e066f1e 100644 --- a/crates/libfmp/tests/indexes_constituent_responses.rs +++ b/crates/libfmp/tests/indexes_constituent_responses.rs @@ -39,6 +39,14 @@ fn all_six_documented_constituent_fixtures_match_the_typed_wire_contract_exactly assert_eq!(history[0].date_added.0, "July 7, 2026"); assert_eq!(history[0].removed_ticker, None); assert_eq!(history[0].removed_security, None); + + let dow_history: Vec = + serde_json::from_slice(HISTORICAL_DOW_JONES).unwrap(); + assert_eq!(dow_history[0].reason, None); + assert_eq!( + dow_history[0].added_security.as_deref(), + Some("Alphabet Inc.") + ); } fn assert_exact(fixture: &[u8]) @@ -91,7 +99,7 @@ fn current_constituent_fields_are_required_and_nullable_date_stays_required() { } #[test] -fn historical_fields_are_required_while_documented_removals_accept_null() { +fn historical_fields_are_required_while_omittable_members_accept_null_or_empty() { let source: Value = serde_json::from_slice(HISTORICAL_NASDAQ).unwrap(); let row = source[0].clone(); @@ -109,12 +117,25 @@ fn historical_fields_are_required_while_documented_removals_accept_null() { assert!(serde_json::from_value::(missing).is_err()); } - for field in ["dateAdded", "addedSecurity", "date", "symbol", "reason"] { + for field in ["dateAdded", "date", "symbol"] { let mut null = row.clone(); null[field] = Value::Null; assert!(serde_json::from_value::(null).is_err()); } + let mut omitted = row.clone(); + omitted["addedSecurity"] = Value::Null; + omitted["reason"] = Value::Null; + omitted["removedTicker"] = json!(""); + let decoded: HistoricalIndexConstituent = serde_json::from_value(omitted).unwrap(); + assert_eq!(decoded.added_security, None); + assert_eq!(decoded.reason, None); + assert_eq!(decoded.removed_ticker, None); + let wire = serde_json::to_value(&decoded).unwrap(); + assert!(wire["addedSecurity"].is_null()); + assert!(wire["reason"].is_null()); + assert!(wire["removedTicker"].is_null()); + let mut future = row; future["futureField"] = json!([1, 2, 3]); let decoded: HistoricalIndexConstituent = serde_json::from_value(future).unwrap(); diff --git a/sdk/go/directory_models.go b/sdk/go/directory_models.go index 14025ba6..d5d6dc66 100644 --- a/sdk/go/directory_models.go +++ b/sdk/go/directory_models.go @@ -49,20 +49,20 @@ func (m *CompanySymbol) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // FinancialStatementSymbol is one worldwide company with financial statements // available from FMP. type FinancialStatementSymbol struct { - Symbol string `json:"symbol"` - CompanyName string `json:"companyName"` - TradingCurrency string `json:"tradingCurrency"` - ReportingCurrency string `json:"reportingCurrency"` + Symbol string `json:"symbol"` + CompanyName string `json:"companyName"` + TradingCurrency string `json:"tradingCurrency"` + ReportingCurrency *string `json:"reportingCurrency"` } // financialStatementSymbolShadow mirrors FinancialStatementSymbol with a // pointer or raw value for every required member so a missing or null member // is observable after decoding. type financialStatementSymbolShadow struct { - Symbol *string `json:"symbol"` - CompanyName *string `json:"companyName"` - TradingCurrency *string `json:"tradingCurrency"` - ReportingCurrency *string `json:"reportingCurrency"` + Symbol *string `json:"symbol"` + CompanyName *string `json:"companyName"` + TradingCurrency *string `json:"tradingCurrency"` + ReportingCurrency jsontext.Value `json:"reportingCurrency"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -80,14 +80,24 @@ func (m *FinancialStatementSymbol) UnmarshalJSONFrom(dec *jsontext.Decoder) erro return missingMemberError("FinancialStatementSymbol", "companyName") case shadow.TradingCurrency == nil: return missingMemberError("FinancialStatementSymbol", "tradingCurrency") - case shadow.ReportingCurrency == nil: + case len(shadow.ReportingCurrency) == 0: return missingMemberError("FinancialStatementSymbol", "reportingCurrency") } + var reportingCurrency *string + if shadow.ReportingCurrency.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.ReportingCurrency, &value); err != nil { + return err + } + if value != "" { + reportingCurrency = &value + } + } *m = FinancialStatementSymbol{ Symbol: *shadow.Symbol, CompanyName: *shadow.CompanyName, TradingCurrency: *shadow.TradingCurrency, - ReportingCurrency: *shadow.ReportingCurrency, + ReportingCurrency: reportingCurrency, } return nil } @@ -284,24 +294,24 @@ func (m *EarningsTranscriptAvailability) UnmarshalJSONFrom(dec *jsontext.Decoder // AvailableExchange is one stock exchange supported by FMP. type AvailableExchange struct { - Exchange string `json:"exchange"` - Name string `json:"name"` - CountryName string `json:"countryName"` - CountryCode string `json:"countryCode"` - SymbolSuffix string `json:"symbolSuffix"` - Delay string `json:"delay"` + Exchange string `json:"exchange"` + Name string `json:"name"` + CountryName string `json:"countryName"` + CountryCode *string `json:"countryCode"` + SymbolSuffix string `json:"symbolSuffix"` + Delay *string `json:"delay"` } // availableExchangeShadow mirrors AvailableExchange with a pointer or raw // value for every required member so a missing or null member is observable // after decoding. type availableExchangeShadow struct { - Exchange *string `json:"exchange"` - Name *string `json:"name"` - CountryName *string `json:"countryName"` - CountryCode *string `json:"countryCode"` - SymbolSuffix *string `json:"symbolSuffix"` - Delay *string `json:"delay"` + Exchange *string `json:"exchange"` + Name *string `json:"name"` + CountryName *string `json:"countryName"` + CountryCode jsontext.Value `json:"countryCode"` + SymbolSuffix *string `json:"symbolSuffix"` + Delay jsontext.Value `json:"delay"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -319,20 +329,38 @@ func (m *AvailableExchange) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("AvailableExchange", "name") case shadow.CountryName == nil: return missingMemberError("AvailableExchange", "countryName") - case shadow.CountryCode == nil: + case len(shadow.CountryCode) == 0: return missingMemberError("AvailableExchange", "countryCode") case shadow.SymbolSuffix == nil: return missingMemberError("AvailableExchange", "symbolSuffix") - case shadow.Delay == nil: + case len(shadow.Delay) == 0: return missingMemberError("AvailableExchange", "delay") } + var countryCode *string + if shadow.CountryCode.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.CountryCode, &value); err != nil { + return err + } + if value != "" { + countryCode = &value + } + } + var delay *string + if shadow.Delay.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Delay, &value); err != nil { + return err + } + delay = &value + } *m = AvailableExchange{ Exchange: *shadow.Exchange, Name: *shadow.Name, CountryName: *shadow.CountryName, - CountryCode: *shadow.CountryCode, + CountryCode: countryCode, SymbolSuffix: *shadow.SymbolSuffix, - Delay: *shadow.Delay, + Delay: delay, } return nil } diff --git a/sdk/go/directory_parity_test.go b/sdk/go/directory_parity_test.go index bd1d01c4..d1f34a47 100644 --- a/sdk/go/directory_parity_test.go +++ b/sdk/go/directory_parity_test.go @@ -3,6 +3,7 @@ package fmp import ( "encoding/json/v2" "errors" + "reflect" "strings" "testing" ) @@ -60,7 +61,7 @@ func TestDocumentedDirectoryRowsDecodeExactFieldNamesAndWireTypes(t *testing.T) } financials := assertFixtureParity[FinancialStatementSymbol](t, "directory_financial_statement_symbols.json") if want := (FinancialStatementSymbol{Symbol: "RMES.CN", CompanyName: "Red Metal Resources Ltd.", - TradingCurrency: "CAD", ReportingCurrency: "USD"}); len(financials) != 1 || financials[0] != want { + TradingCurrency: "CAD", ReportingCurrency: new("USD")}); len(financials) != 1 || !reflect.DeepEqual(financials[0], want) { t.Fatalf("directory_financial_statement_symbols = %+v, want %+v", financials, want) } ciks := assertFixtureParity[CIKListing](t, "directory_cik_list.json") @@ -109,8 +110,8 @@ func TestDocumentedTaxonomyRowsDecodeExactFieldNamesAndOpenValues(t *testing.T) t.Parallel() exchanges := assertFixtureParity[AvailableExchange](t, "directory_available_exchanges.json") want := AvailableExchange{Exchange: "AMEX", Name: "New York Stock Exchange Arca", - CountryName: "United States of America", CountryCode: "US", SymbolSuffix: "N/A", Delay: "Real-time"} - if len(exchanges) != 1 || exchanges[0] != want { + CountryName: "United States of America", CountryCode: new("US"), SymbolSuffix: "N/A", Delay: new("Real-time")} + if len(exchanges) != 1 || !reflect.DeepEqual(exchanges[0], want) { t.Fatalf("directory_available_exchanges = %+v, want %+v", exchanges, want) } sectors := assertFixtureParity[AvailableSector](t, "directory_available_sectors.json") @@ -140,6 +141,30 @@ func TestDocumentedTaxonomyRowsDecodeExactFieldNamesAndOpenValues(t *testing.T) // The missing-required-member path of the generated decoders, once for this // domain: serde rejects a missing and a null member alike, and a malformed // date never decodes into a Date member. +func TestDirectoryOmittableMembersDecodeNullAndEmptyAsNil(t *testing.T) { + t.Parallel() + for _, code := range []string{`null`, `""`} { + var financials []FinancialStatementSymbol + wire := `[{"symbol":"1609.HK","companyName":"Chong Kin","tradingCurrency":"HKD","reportingCurrency":` + code + `}]` + if err := json.Unmarshal([]byte(wire), &financials); err != nil || len(financials) != 1 || + financials[0].ReportingCurrency != nil { + t.Fatalf("reportingCurrency %s = %+v, %v, want nil", code, financials, err) + } + var exchanges []AvailableExchange + wire = `[{"exchange":"CRYPTO","name":"Cryptocurrency","countryName":"","countryCode":` + code + + `,"symbolSuffix":"N/A","delay":null}]` + want := AvailableExchange{Exchange: "CRYPTO", Name: "Cryptocurrency", SymbolSuffix: "N/A"} + if err := json.Unmarshal([]byte(wire), &exchanges); err != nil || len(exchanges) != 1 || + !reflect.DeepEqual(exchanges[0], want) { + t.Fatalf("countryCode %s = %+v, %v, want %+v", code, exchanges, err, want) + } + } + if err := json.Unmarshal([]byte(`[{"symbol":"1609.HK","companyName":"Chong Kin","tradingCurrency":"HKD"}]`), + new([]FinancialStatementSymbol)); err == nil { + t.Fatal("a missing reportingCurrency decoded") + } +} + func TestDirectoryRequiredMembersAreEnforcedLikeSerde(t *testing.T) { t.Parallel() cases := []struct { diff --git a/sdk/go/indexes_models.go b/sdk/go/indexes_models.go index 6d5d96ab..a9ebcdd0 100644 --- a/sdk/go/indexes_models.go +++ b/sdk/go/indexes_models.go @@ -134,12 +134,12 @@ func (m *IndexConstituent) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // removal from a major US index. type HistoricalIndexConstituent struct { DateAdded string `json:"dateAdded"` - AddedSecurity string `json:"addedSecurity"` + AddedSecurity *string `json:"addedSecurity"` RemovedTicker *string `json:"removedTicker"` RemovedSecurity *string `json:"removedSecurity"` Date Date `json:"date"` Symbol string `json:"symbol"` - Reason string `json:"reason"` + Reason *string `json:"reason"` } // historicalIndexConstituentShadow mirrors HistoricalIndexConstituent with a @@ -147,12 +147,12 @@ type HistoricalIndexConstituent struct { // is observable after decoding. type historicalIndexConstituentShadow struct { DateAdded *string `json:"dateAdded"` - AddedSecurity *string `json:"addedSecurity"` + AddedSecurity jsontext.Value `json:"addedSecurity"` RemovedTicker jsontext.Value `json:"removedTicker"` RemovedSecurity jsontext.Value `json:"removedSecurity"` Date *Date `json:"date"` Symbol *string `json:"symbol"` - Reason *string `json:"reason"` + Reason jsontext.Value `json:"reason"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -166,7 +166,7 @@ func (m *HistoricalIndexConstituent) UnmarshalJSONFrom(dec *jsontext.Decoder) er switch { case shadow.DateAdded == nil: return missingMemberError("HistoricalIndexConstituent", "dateAdded") - case shadow.AddedSecurity == nil: + case len(shadow.AddedSecurity) == 0: return missingMemberError("HistoricalIndexConstituent", "addedSecurity") case len(shadow.RemovedTicker) == 0: return missingMemberError("HistoricalIndexConstituent", "removedTicker") @@ -176,16 +176,26 @@ func (m *HistoricalIndexConstituent) UnmarshalJSONFrom(dec *jsontext.Decoder) er return missingMemberError("HistoricalIndexConstituent", "date") case shadow.Symbol == nil: return missingMemberError("HistoricalIndexConstituent", "symbol") - case shadow.Reason == nil: + case len(shadow.Reason) == 0: return missingMemberError("HistoricalIndexConstituent", "reason") } + var addedSecurity *string + if shadow.AddedSecurity.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.AddedSecurity, &value); err != nil { + return err + } + addedSecurity = &value + } var removedTicker *string if shadow.RemovedTicker.Kind() != 'n' { var value string if err := json.Unmarshal(shadow.RemovedTicker, &value); err != nil { return err } - removedTicker = &value + if value != "" { + removedTicker = &value + } } var removedSecurity *string if shadow.RemovedSecurity.Kind() != 'n' { @@ -195,14 +205,22 @@ func (m *HistoricalIndexConstituent) UnmarshalJSONFrom(dec *jsontext.Decoder) er } removedSecurity = &value } + var reason *string + if shadow.Reason.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Reason, &value); err != nil { + return err + } + reason = &value + } *m = HistoricalIndexConstituent{ DateAdded: *shadow.DateAdded, - AddedSecurity: *shadow.AddedSecurity, + AddedSecurity: addedSecurity, RemovedTicker: removedTicker, RemovedSecurity: removedSecurity, Date: *shadow.Date, Symbol: *shadow.Symbol, - Reason: *shadow.Reason, + Reason: reason, } return nil } diff --git a/sdk/go/indexes_parity_test.go b/sdk/go/indexes_parity_test.go index 81534f24..c83868b5 100644 --- a/sdk/go/indexes_parity_test.go +++ b/sdk/go/indexes_parity_test.go @@ -130,7 +130,7 @@ func TestDocumentedIndexConstituentFixturesDecodeExactValues(t *testing.T) { history := assertFixtureParity[HistoricalIndexConstituent](t, "indexes_historical_nasdaq_constituents.json") if len(history) != 1 || history[0].DateAdded != "July 7, 2026" || history[0].Symbol != "SPCX" || - history[0].AddedSecurity != "Space Exploration Technologies Corp." || + history[0].AddedSecurity == nil || *history[0].AddedSecurity != "Space Exploration Technologies Corp." || history[0].Date != mustParseDate(t, "2026-07-06") { t.Fatalf("indexes_historical_nasdaq_constituents = %+v", history) } @@ -141,12 +141,12 @@ func TestDocumentedIndexConstituentFixturesDecodeExactValues(t *testing.T) { removal := assertFixtureParity[HistoricalIndexConstituent](t, "indexes_historical_sp500_constituents.json") if len(removal) != 1 || removal[0].RemovedTicker == nil || *removal[0].RemovedTicker != "CAG" || removal[0].RemovedSecurity == nil || *removal[0].RemovedSecurity != "Conagra Brands" || - removal[0].Reason != "Market Capitalization Changes" { + removal[0].Reason == nil || *removal[0].Reason != "Market Capitalization Changes" { t.Fatalf("indexes_historical_sp500_constituents = %+v", removal) } dowHistory := assertFixtureParity[HistoricalIndexConstituent](t, "indexes_historical_dow_jones_constituents.json") if len(dowHistory) != 1 || dowHistory[0].DateAdded != "June 29, 2026" || dowHistory[0].RemovedTicker == nil || - *dowHistory[0].RemovedTicker != "VZ" { + *dowHistory[0].RemovedTicker != "VZ" || dowHistory[0].Reason != nil { t.Fatalf("indexes_historical_dow_jones_constituents = %+v", dowHistory) } } @@ -178,6 +178,13 @@ func TestIndexesRequiredMembersAreEnforcedLikeSerde(t *testing.T) { changes[0].RemovedTicker != nil || changes[0].RemovedSecurity != nil { t.Fatalf("null removals with unknown member: %+v, %v", changes, err) } + wire = `[{"addedSecurity":null,"date":"2026-07-06","dateAdded":"July 7, 2026","reason":null,` + + `"removedSecurity":"","removedTicker":"","symbol":"SPCX"}]` + if err := json.Unmarshal([]byte(wire), &changes); err != nil || len(changes) != 1 || + changes[0].AddedSecurity != nil || changes[0].Reason != nil || changes[0].RemovedTicker != nil || + changes[0].RemovedSecurity == nil || *changes[0].RemovedSecurity != "" { + t.Fatalf("null added/reason and empty removedTicker: %+v, %v", changes, err) + } }) rejected := []struct { From 79819aea4c46693ece040e08620fa56ff5c64b3a Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 21:35:39 +0800 Subject: [PATCH 2/2] test(sdk): cover AvailableExchange omittable member decoding Extend the directory parity tests to verify that AvailableExchange members omitted by FMP decode null and empty typed codes as nil while the key itself stays required. Update the accompanying comments to describe the omittable-member decoding path more accurately. --- sdk/go/directory_parity_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/sdk/go/directory_parity_test.go b/sdk/go/directory_parity_test.go index d1f34a47..611a6e37 100644 --- a/sdk/go/directory_parity_test.go +++ b/sdk/go/directory_parity_test.go @@ -138,9 +138,8 @@ func TestDocumentedTaxonomyRowsDecodeExactFieldNamesAndOpenValues(t *testing.T) } } -// The missing-required-member path of the generated decoders, once for this -// domain: serde rejects a missing and a null member alike, and a malformed -// date never decodes into a Date member. +// Members FMP omits decode null, and "" on a typed code, as nil while the +// key itself stays required. func TestDirectoryOmittableMembersDecodeNullAndEmptyAsNil(t *testing.T) { t.Parallel() for _, code := range []string{`null`, `""`} { @@ -163,8 +162,19 @@ func TestDirectoryOmittableMembersDecodeNullAndEmptyAsNil(t *testing.T) { new([]FinancialStatementSymbol)); err == nil { t.Fatal("a missing reportingCurrency decoded") } + for _, wire := range []string{ + `[{"exchange":"CRYPTO","name":"Cryptocurrency","countryName":"","symbolSuffix":"N/A","delay":null}]`, + `[{"exchange":"CRYPTO","name":"Cryptocurrency","countryName":"","countryCode":null,"symbolSuffix":"N/A"}]`, + } { + if err := json.Unmarshal([]byte(wire), new([]AvailableExchange)); err == nil { + t.Fatalf("a missing AvailableExchange member decoded: %s", wire) + } + } } +// The missing-required-member path of the generated decoders, once for this +// domain: serde rejects a missing and a null member alike, and a malformed +// date never decodes into a Date member. func TestDirectoryRequiredMembersAreEnforcedLikeSerde(t *testing.T) { t.Parallel() cases := []struct {