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..611a6e37 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") @@ -137,6 +138,40 @@ func TestDocumentedTaxonomyRowsDecodeExactFieldNamesAndOpenValues(t *testing.T) } } +// 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`, `""`} { + 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") + } + 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. 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 {