diff --git a/crates/fmp-py/python/fmp/_native/funds/__init__.pyi b/crates/fmp-py/python/fmp/_native/funds/__init__.pyi index 17616275..0d1733d3 100644 --- a/crates/fmp-py/python/fmp/_native/funds/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/funds/__init__.pyi @@ -102,13 +102,13 @@ class EtfFundHolding: @property def symbol(self) -> builtins.str: ... @property - def asset(self) -> builtins.str: ... + def asset(self) -> typing.Optional[builtins.str]: ... @property def name(self) -> builtins.str: ... @property - def isin(self) -> builtins.str: ... + def isin(self) -> typing.Optional[builtins.str]: ... @property - def security_cusip(self) -> builtins.str: ... + def security_cusip(self) -> typing.Optional[builtins.str]: ... @property def shares_number(self) -> builtins.float: ... @property @@ -122,10 +122,10 @@ class EtfFundHolding: cls, *, symbol: builtins.str, - asset: builtins.str, + asset: typing.Optional[builtins.str], name: builtins.str, - isin: builtins.str, - security_cusip: builtins.str, + isin: typing.Optional[builtins.str], + security_cusip: typing.Optional[builtins.str], shares_number: builtins.float, weight_percentage: builtins.float, market_value: builtins.float, @@ -326,7 +326,7 @@ class FundDisclosure: @property def accepted_date(self) -> datetime.datetime: ... @property - def symbol(self) -> builtins.str: ... + def symbol(self) -> typing.Optional[builtins.str]: ... @property def name(self) -> builtins.str: ... @property @@ -336,7 +336,7 @@ class FundDisclosure: @property def cusip(self) -> builtins.str: ... @property - def isin(self) -> builtins.str: ... + def isin(self) -> typing.Optional[builtins.str]: ... @property def balance(self) -> builtins.float: ... @property @@ -372,12 +372,12 @@ class FundDisclosure: cik: builtins.str, date: datetime.date, accepted_date: datetime.datetime, - symbol: builtins.str, + symbol: typing.Optional[builtins.str], name: builtins.str, lei: builtins.str, title: builtins.str, cusip: builtins.str, - isin: builtins.str, + isin: typing.Optional[builtins.str], balance: builtins.float, units: builtins.str, currency_code: builtins.str, @@ -488,7 +488,7 @@ class FundDisclosureSearchResult: @property def reporting_file_number(self) -> builtins.str: ... @property - def address(self) -> builtins.str: ... + def address(self) -> typing.Optional[builtins.str]: ... @property def city(self) -> builtins.str: ... @property @@ -508,7 +508,7 @@ class FundDisclosureSearchResult: series_name: builtins.str, class_name: builtins.str, reporting_file_number: builtins.str, - address: builtins.str, + address: typing.Optional[builtins.str], city: builtins.str, zip_code: builtins.str, state: builtins.str, diff --git a/crates/fmp-py/src/models/funds.rs b/crates/fmp-py/src/models/funds.rs index e792dd38..6280169b 100644 --- a/crates/fmp-py/src/models/funds.rs +++ b/crates/fmp-py/src/models/funds.rs @@ -12,13 +12,13 @@ pub(crate) struct EtfFundHolding { #[pyo3(get)] pub symbol: String, #[pyo3(get)] - pub asset: String, + pub asset: Option, #[pyo3(get)] pub name: String, #[pyo3(get)] - pub isin: String, + pub isin: Option, #[pyo3(get)] - pub security_cusip: String, + pub security_cusip: Option, #[pyo3(get)] pub shares_number: f64, #[pyo3(get)] @@ -38,10 +38,10 @@ impl EtfFundHolding { #[pyo3(signature = (*, symbol, asset, name, isin, security_cusip, shares_number, weight_percentage, market_value, updated_at))] fn new( symbol: String, - asset: String, + asset: Option, name: String, - isin: String, - security_cusip: String, + isin: Option, + security_cusip: Option, shares_number: f64, weight_percentage: f64, market_value: f64, @@ -190,10 +190,10 @@ impl From for EtfFundHolding { fn from(value: libfmp::responses::funds::EtfFundHolding) -> Self { Self { symbol: value.symbol.into_inner(), - asset: value.asset.into_inner(), + asset: value.asset.map(|value| value.into_inner()), name: value.name, - isin: value.isin.into_inner(), - security_cusip: value.security_cusip.into_inner(), + isin: value.isin.map(|value| value.into_inner()), + security_cusip: value.security_cusip.map(|value| value.into_inner()), shares_number: value.shares_number, weight_percentage: value.weight_percentage, market_value: value.market_value, @@ -1187,7 +1187,7 @@ pub(crate) struct FundDisclosure { #[pyo3(get)] pub accepted_date: ::chrono::NaiveDateTime, #[pyo3(get)] - pub symbol: String, + pub symbol: Option, #[pyo3(get)] pub name: String, #[pyo3(get)] @@ -1197,7 +1197,7 @@ pub(crate) struct FundDisclosure { #[pyo3(get)] pub cusip: String, #[pyo3(get)] - pub isin: String, + pub isin: Option, #[pyo3(get)] pub balance: f64, #[pyo3(get)] @@ -1235,12 +1235,12 @@ impl FundDisclosure { cik: String, date: ::chrono::NaiveDate, accepted_date: ::chrono::NaiveDateTime, - symbol: String, + symbol: Option, name: String, lei: String, title: String, cusip: String, - isin: String, + isin: Option, balance: f64, units: String, currency_code: String, @@ -1571,12 +1571,12 @@ impl From for FundDisclosure { cik: value.cik.into_inner(), date: value.date.into_inner(), accepted_date: value.accepted_date.into_inner(), - symbol: value.symbol.into_inner(), + symbol: value.symbol.map(|value| value.into_inner()), name: value.name, lei: value.lei.into_inner(), title: value.title, cusip: value.cusip.into_inner(), - isin: value.isin.into_inner(), + isin: value.isin.map(|value| value.into_inner()), balance: value.balance, units: value.units, currency_code: value.currency_code.into_inner(), @@ -1624,7 +1624,7 @@ pub(crate) struct FundDisclosureSearchResult { #[pyo3(get)] pub reporting_file_number: String, #[pyo3(get)] - pub address: String, + pub address: Option, #[pyo3(get)] pub city: String, #[pyo3(get)] @@ -1650,7 +1650,7 @@ impl FundDisclosureSearchResult { series_name: String, class_name: String, reporting_file_number: String, - address: String, + address: Option, city: String, zip_code: String, state: String, diff --git a/crates/fmp-py/tests/test_funds_a.py b/crates/fmp-py/tests/test_funds_a.py index c8878ca6..6bb6927b 100644 --- a/crates/fmp-py/tests/test_funds_a.py +++ b/crates/fmp-py/tests/test_funds_a.py @@ -54,6 +54,21 @@ def test_etf_holdings_decodes_the_space_timestamp(client: Any, fixture_server: F assert row.updated_at == datetime.datetime(2026, 7, 30, 8, 7, 21) +@pytest.mark.parametrize("wire", [None, ""]) +def test_etf_holdings_decodes_null_and_empty_codes_as_none( + client: Any, fixture_server: FixtureServer, wire: str | None +) -> None: + """``etf_holdings`` surfaces a null or empty ``asset``, ``isin``, and ``securityCusip`` as ``None``.""" + body = load_fixture("etf_fund_holdings.json") + body[0].update(asset=wire, isin=wire, securityCusip=wire) + fixture_server.route("/etf/holdings", body) + row = client.funds.etf_holdings("SPY")[0] + + assert row.asset is None + assert row.isin is None + assert row.security_cusip is None + + def test_etf_holdings_encodes_a_spaced_symbol(client: Any, fixture_server: FixtureServer) -> None: """``etf_holdings`` form-encodes spaces and slashes in the ticker exactly as libfmp does.""" fixture_server.route("/etf/holdings", load_fixture("etf_fund_holdings.json")) diff --git a/crates/fmp-py/tests/test_funds_b.py b/crates/fmp-py/tests/test_funds_b.py index 012790d8..98e796ef 100644 --- a/crates/fmp-py/tests/test_funds_b.py +++ b/crates/fmp-py/tests/test_funds_b.py @@ -87,10 +87,26 @@ def test_search_fund_disclosure_holders_encodes_punctuation(client: Any, fixture assert row.entity_org_type == "30" assert row.class_name == "Class A Shares" assert row.reporting_file_number == "811-03266" + assert row.address == "4000 ERICSSON DRIVE" assert row.zip_code == "15086-7561" assert row.state == "PA" +def test_disclosure_rows_decode_omitted_members_as_none(client: Any, fixture_server: FixtureServer) -> None: + """A null ``symbol``, an empty ``isin``, and a null ``address`` decode as ``None``.""" + disclosure = load_fixture("fund_disclosures.json") + disclosure[0].update(symbol=None, isin="") + fixture_server.route("/funds/disclosure", disclosure) + search = load_fixture("fund_disclosure_holder_search.json") + search[0]["address"] = None + fixture_server.route("/funds/disclosure-holders-search", search) + + position = client.funds.disclosures("VWO", 2023, 4)[0] + assert position.symbol is None + assert position.isin is None + assert client.funds.search_disclosure_holders(FEDERATED)[0].address is None + + def test_search_fund_disclosure_holders_with_a_plain_name(client: Any, fixture_server: FixtureServer) -> None: """``search_disclosure_holders`` sends a space-separated name as ``+`` and nothing else.""" fixture_server.route("/funds/disclosure-holders-search", load_fixture("fund_disclosure_holder_search.json")) diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 8502290c..2b012d1c 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -211,14 +211,16 @@ def check_funds_contract(client: FmpClient) -> None: funds: FundsNamespace = client.funds holdings: list[EtfFundHolding] = funds.etf_holdings("SPY") updated: datetime.datetime = holdings[0].updated_at + asset: str | None = holdings[0].asset info: list[EtfFundInfo] = client.funds.etf_info("SPY") inception: datetime.date = info[0].inception_date exposure: float = info[0].sectors_list[0].exposure positions: list[FundDisclosure] = client.funds.disclosures("VWO", 2023, 4, cik="0000857489") accepted: datetime.datetime = positions[0].accepted_date + position_symbol: str | None = positions[0].symbol dates: list[Form13fFilingDate] = client.funds.disclosure_dates("VWO", cik="0000036405") quarter: int = dates[0].quarter - _ = (updated, inception, exposure, accepted, quarter) + _ = (updated, asset, inception, exposure, accepted, position_symbol, quarter) def check_indexes_contract(client: FmpClient) -> None: diff --git a/crates/libfmp/src/responses/funds.rs b/crates/libfmp/src/responses/funds.rs index ffb2e8d9..e2b34d74 100644 --- a/crates/libfmp/src/responses/funds.rs +++ b/crates/libfmp/src/responses/funds.rs @@ -1,6 +1,6 @@ //! Response rows returned by ETF and mutual-fund endpoints. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ codecs::{IsoTimestamp, NumericString, PercentString, YnFlag}, @@ -10,16 +10,27 @@ use crate::{ }, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One asset held by an ETF or mutual fund. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct EtfFundHolding { pub symbol: Ticker, - pub asset: Ticker, + #[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")] + pub asset: Option, pub name: String, - pub isin: Isin, - pub security_cusip: Cusip, + #[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")] + pub isin: Option, + #[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")] + pub security_cusip: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub shares_number: Quantity, pub weight_percentage: Percentage, @@ -123,12 +134,14 @@ pub struct FundDisclosure { pub cik: Cik, pub date: Date, pub accepted_date: ApiDateTime, - pub symbol: Ticker, + #[serde(deserialize_with = "required_option")] + pub symbol: Option, pub name: String, pub lei: Lei, pub title: String, pub cusip: Cusip, - pub isin: Isin, + #[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")] + pub isin: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub balance: Quantity, pub units: String, @@ -162,7 +175,8 @@ pub struct FundDisclosureSearchResult { pub series_name: String, pub class_name: String, pub reporting_file_number: String, - pub address: String, + #[serde(deserialize_with = "required_option")] + pub address: Option, pub city: String, pub zip_code: String, pub state: String, diff --git a/crates/libfmp/tests/fund_disclosure_endpoints.rs b/crates/libfmp/tests/fund_disclosure_endpoints.rs index 7ced86f2..3e33f491 100644 --- a/crates/libfmp/tests/fund_disclosure_endpoints.rs +++ b/crates/libfmp/tests/fund_disclosure_endpoints.rs @@ -125,7 +125,10 @@ async fn custom_proxy_preserves_exact_query_order_encoding_headers_and_fixture_i assert_eq!(latest[0].cik.as_str(), "0000866256"); assert_eq!(latest[0].security_cusip.as_str(), "037833100"); assert_eq!(latest[0].change, -316_881.0); - assert_eq!(disclosures[0].symbol.as_str(), "000089.SZ"); + assert_eq!( + disclosures[0].symbol.as_ref().unwrap().as_str(), + "000089.SZ" + ); assert_eq!(disclosures[0].cusip.as_str(), "N/A"); assert_eq!(disclosures[0].currency_code.as_str(), "CNY"); assert_eq!(disclosures[0].fair_val_level.as_str(), "2"); diff --git a/crates/libfmp/tests/funds_responses.rs b/crates/libfmp/tests/funds_responses.rs index c9499e62..3670ce79 100644 --- a/crates/libfmp/tests/funds_responses.rs +++ b/crates/libfmp/tests/funds_responses.rs @@ -37,17 +37,20 @@ fn exact_holding_fixture_decodes_all_nine_fields_and_space_timestamp() { rows, [EtfFundHolding { symbol: Ticker::new("SPY").unwrap(), - asset: Ticker::new("AAPL").unwrap(), + asset: Some(Ticker::new("AAPL").unwrap()), name: "APPLE INC".to_owned(), - isin: Isin::new("US0378331005").unwrap(), - security_cusip: Cusip::new("037833100").unwrap(), + isin: Some(Isin::new("US0378331005").unwrap()), + security_cusip: Some(Cusip::new("037833100").unwrap()), shares_number: 181_418_073.0, weight_percentage: 7.79997012, market_value: 61_679_458_958.0, updated_at: ApiDateTime::parse("2026-07-30 08:07:21").unwrap(), }] ); - assert_eq!(rows[0].security_cusip.as_str(), "037833100"); + assert_eq!( + rows[0].security_cusip.as_ref().unwrap().as_str(), + "037833100" + ); } #[test] @@ -171,12 +174,12 @@ fn exact_disclosure_fixture_decodes_all_twenty_three_wire_fields() { cik: Cik::new("0000857489").unwrap(), date: Date::from_str("2023-10-31").unwrap(), accepted_date: ApiDateTime::parse("2023-12-28 09:26:13").unwrap(), - symbol: Ticker::new("000089.SZ").unwrap(), + symbol: Some(Ticker::new("000089.SZ").unwrap()), name: "Shenzhen Airport Co Ltd".to_owned(), lei: Lei::new("3003009W045RIKRBZI44").unwrap(), title: "SHENZ AIRPORT-A".to_owned(), cusip: Cusip::new("N/A").unwrap(), - isin: Isin::new("CNE000000VK1").unwrap(), + isin: Some(Isin::new("CNE000000VK1").unwrap()), balance: 2_438_784.0, units: "NS".to_owned(), currency_code: CurrencyCode::new("CNY").unwrap(), @@ -193,7 +196,7 @@ fn exact_disclosure_fixture_decodes_all_twenty_three_wire_fields() { is_loan_by_fund: YnFlag::False, }] ); - assert_eq!(rows[0].symbol.as_str(), "000089.SZ"); + assert_eq!(rows[0].symbol.as_ref().unwrap().as_str(), "000089.SZ"); assert_eq!(rows[0].cusip.as_str(), "N/A"); assert_eq!(rows[0].fair_val_level.as_str(), "2"); assert_eq!( @@ -222,7 +225,7 @@ fn exact_search_fixture_preserves_all_thirteen_string_fields() { ); assert_eq!(row.class_name, "Class A Shares"); assert_eq!(row.reporting_file_number, "811-03266"); - assert_eq!(row.address, "4000 ERICSSON DRIVE"); + assert_eq!(row.address.as_deref(), Some("4000 ERICSSON DRIVE")); assert_eq!(row.city, "WARRENDALE"); assert_eq!(row.zip_code, "15086-7561"); assert_eq!(row.state, "PA"); @@ -299,7 +302,7 @@ fn identifiers_flags_and_temporal_wire_kinds_are_not_coerced() { disclosure[0]["cusip"] = serde_json::json!("001234567"); let rows: Vec = serde_json::from_value(disclosure).unwrap(); assert_eq!(rows[0].cik.as_str(), "0000000001"); - assert_eq!(rows[0].symbol.as_str(), "000001.SZ"); + assert_eq!(rows[0].symbol.as_ref().unwrap().as_str(), "000001.SZ"); assert_eq!(rows[0].cusip.as_str(), "001234567"); let mut info: serde_json::Value = serde_json::from_slice(INFO).unwrap(); @@ -336,15 +339,15 @@ fn identifiers_flags_and_temporal_wire_kinds_are_not_coerced() { #[test] fn all_fields_and_nested_sector_fields_are_required_non_null_and_forward_tolerant() { - assert_contract::(HOLDINGS); - assert_contract::(INFO); - assert_contract::(COUNTRY); - assert_contract::(ASSET); - assert_contract::(SECTOR); - assert_contract::(LATEST_HOLDERS); - assert_contract::(DISCLOSURES); - assert_contract::(SEARCH); - assert_contract::(DATES); + assert_contract::(HOLDINGS, &["asset", "isin", "securityCusip"]); + assert_contract::(INFO, &[]); + assert_contract::(COUNTRY, &[]); + assert_contract::(ASSET, &[]); + assert_contract::(SECTOR, &[]); + assert_contract::(LATEST_HOLDERS, &[]); + assert_contract::(DISCLOSURES, &["symbol", "isin"]); + assert_contract::(SEARCH, &["address"]); + assert_contract::(DATES, &[]); for field in ["industry", "exposure"] { let mut missing: serde_json::Value = serde_json::from_slice(INFO).unwrap(); @@ -384,11 +387,58 @@ fn all_nine_contracts_are_bare_arrays_preserving_empty_and_multiple_rows() { ); } +#[test] +fn omittable_members_decode_null_and_empty_as_none() { + for (field, wire) in [ + ("asset", serde_json::Value::Null), + ("asset", serde_json::json!("")), + ("isin", serde_json::Value::Null), + ("isin", serde_json::json!("")), + ("securityCusip", serde_json::Value::Null), + ("securityCusip", serde_json::json!("")), + ] { + let mut value: serde_json::Value = serde_json::from_slice(HOLDINGS).unwrap(); + value[0][field] = wire; + let rows: Vec = serde_json::from_value(value).unwrap(); + let decoded = match field { + "asset" => rows[0].asset.is_none(), + "isin" => rows[0].isin.is_none(), + _ => rows[0].security_cusip.is_none(), + }; + assert!(decoded, "{field}"); + assert!(serde_json::to_value(&rows).unwrap()[0][field].is_null()); + } + + for wire in [serde_json::Value::Null, serde_json::json!("")] { + let mut value: serde_json::Value = serde_json::from_slice(DISCLOSURES).unwrap(); + value[0]["isin"] = wire; + let rows: Vec = serde_json::from_value(value).unwrap(); + assert_eq!(rows[0].isin, None); + } + let mut disclosure: serde_json::Value = serde_json::from_slice(DISCLOSURES).unwrap(); + disclosure[0]["symbol"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(disclosure).unwrap(); + assert_eq!(rows[0].symbol, None); + assert!(serde_json::to_value(&rows).unwrap()[0]["symbol"].is_null()); + let mut disclosure: serde_json::Value = serde_json::from_slice(DISCLOSURES).unwrap(); + disclosure[0]["symbol"] = serde_json::json!(""); + assert!(serde_json::from_value::>(disclosure).is_err()); + + let mut search: serde_json::Value = serde_json::from_slice(SEARCH).unwrap(); + search[0]["address"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(search).unwrap(); + assert_eq!(rows[0].address, None); + let mut search: serde_json::Value = serde_json::from_slice(SEARCH).unwrap(); + search[0]["address"] = serde_json::json!(""); + let rows: Vec = serde_json::from_value(search).unwrap(); + assert_eq!(rows[0].address.as_deref(), Some("")); +} + fn source_row(fixture: &[u8]) -> serde_json::Value { serde_json::from_slice::(fixture).unwrap()[0].clone() } -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() @@ -407,9 +457,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/sdk/go/funds_models.go b/sdk/go/funds_models.go index 61f3f656..cab38d97 100644 --- a/sdk/go/funds_models.go +++ b/sdk/go/funds_models.go @@ -13,10 +13,10 @@ import ( // ETFFundHolding is one asset held by an ETF or mutual fund. type ETFFundHolding struct { Symbol string `json:"symbol"` - Asset string `json:"asset"` + Asset *string `json:"asset"` Name string `json:"name"` - ISIN string `json:"isin"` - SecurityCUSIP string `json:"securityCusip"` + ISIN *string `json:"isin"` + SecurityCUSIP *string `json:"securityCusip"` SharesNumber float64 `json:"sharesNumber"` WeightPercentage float64 `json:"weightPercentage"` MarketValue float64 `json:"marketValue"` @@ -27,15 +27,15 @@ type ETFFundHolding struct { // every required member so a missing or null member is observable after // decoding. type etfFundHoldingShadow struct { - Symbol *string `json:"symbol"` - Asset *string `json:"asset"` - Name *string `json:"name"` - ISIN *string `json:"isin"` - SecurityCUSIP *string `json:"securityCusip"` - SharesNumber *float64 `json:"sharesNumber"` - WeightPercentage *float64 `json:"weightPercentage"` - MarketValue *float64 `json:"marketValue"` - UpdatedAt *DateTime `json:"updatedAt"` + Symbol *string `json:"symbol"` + Asset jsontext.Value `json:"asset"` + Name *string `json:"name"` + ISIN jsontext.Value `json:"isin"` + SecurityCUSIP jsontext.Value `json:"securityCusip"` + SharesNumber *float64 `json:"sharesNumber"` + WeightPercentage *float64 `json:"weightPercentage"` + MarketValue *float64 `json:"marketValue"` + UpdatedAt *DateTime `json:"updatedAt"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -49,13 +49,13 @@ func (m *ETFFundHolding) UnmarshalJSONFrom(dec *jsontext.Decoder) error { switch { case shadow.Symbol == nil: return missingMemberError("ETFFundHolding", "symbol") - case shadow.Asset == nil: + case len(shadow.Asset) == 0: return missingMemberError("ETFFundHolding", "asset") case shadow.Name == nil: return missingMemberError("ETFFundHolding", "name") - case shadow.ISIN == nil: + case len(shadow.ISIN) == 0: return missingMemberError("ETFFundHolding", "isin") - case shadow.SecurityCUSIP == nil: + case len(shadow.SecurityCUSIP) == 0: return missingMemberError("ETFFundHolding", "securityCusip") case shadow.SharesNumber == nil: return missingMemberError("ETFFundHolding", "sharesNumber") @@ -66,12 +66,42 @@ func (m *ETFFundHolding) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.UpdatedAt == nil: return missingMemberError("ETFFundHolding", "updatedAt") } + var asset *string + if shadow.Asset.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Asset, &value); err != nil { + return err + } + if value != "" { + asset = &value + } + } + var isin *string + if shadow.ISIN.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.ISIN, &value); err != nil { + return err + } + if value != "" { + isin = &value + } + } + var securityCUSIP *string + if shadow.SecurityCUSIP.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.SecurityCUSIP, &value); err != nil { + return err + } + if value != "" { + securityCUSIP = &value + } + } *m = ETFFundHolding{ Symbol: *shadow.Symbol, - Asset: *shadow.Asset, + Asset: asset, Name: *shadow.Name, - ISIN: *shadow.ISIN, - SecurityCUSIP: *shadow.SecurityCUSIP, + ISIN: isin, + SecurityCUSIP: securityCUSIP, SharesNumber: *shadow.SharesNumber, WeightPercentage: *shadow.WeightPercentage, MarketValue: *shadow.MarketValue, @@ -433,12 +463,12 @@ type FundDisclosure struct { CIK string `json:"cik"` Date Date `json:"date"` AcceptedDate DateTime `json:"acceptedDate"` - Symbol string `json:"symbol"` + Symbol *string `json:"symbol"` Name string `json:"name"` Lei string `json:"lei"` Title string `json:"title"` CUSIP string `json:"cusip"` - ISIN string `json:"isin"` + ISIN *string `json:"isin"` Balance float64 `json:"balance"` Units string `json:"units"` CurrencyCode string `json:"cur_cd"` @@ -459,29 +489,29 @@ type FundDisclosure struct { // every required member so a missing or null member is observable after // decoding. type fundDisclosureShadow struct { - CIK *string `json:"cik"` - Date *Date `json:"date"` - AcceptedDate *DateTime `json:"acceptedDate"` - Symbol *string `json:"symbol"` - Name *string `json:"name"` - Lei *string `json:"lei"` - Title *string `json:"title"` - CUSIP *string `json:"cusip"` - ISIN *string `json:"isin"` - Balance *float64 `json:"balance"` - Units *string `json:"units"` - CurrencyCode *string `json:"cur_cd"` - ValUsd *float64 `json:"valUsd"` - PctVal *float64 `json:"pctVal"` - PayoffProfile *string `json:"payoffProfile"` - AssetCat *string `json:"assetCat"` - IssuerCat *string `json:"issuerCat"` - InvCountry *string `json:"invCountry"` - IsRestrictedSEC *string `json:"isRestrictedSec"` - FairValLevel *string `json:"fairValLevel"` - IsCashCollateral *string `json:"isCashCollateral"` - IsNonCashCollateral *string `json:"isNonCashCollateral"` - IsLoanByFund *string `json:"isLoanByFund"` + CIK *string `json:"cik"` + Date *Date `json:"date"` + AcceptedDate *DateTime `json:"acceptedDate"` + Symbol jsontext.Value `json:"symbol"` + Name *string `json:"name"` + Lei *string `json:"lei"` + Title *string `json:"title"` + CUSIP *string `json:"cusip"` + ISIN jsontext.Value `json:"isin"` + Balance *float64 `json:"balance"` + Units *string `json:"units"` + CurrencyCode *string `json:"cur_cd"` + ValUsd *float64 `json:"valUsd"` + PctVal *float64 `json:"pctVal"` + PayoffProfile *string `json:"payoffProfile"` + AssetCat *string `json:"assetCat"` + IssuerCat *string `json:"issuerCat"` + InvCountry *string `json:"invCountry"` + IsRestrictedSEC *string `json:"isRestrictedSec"` + FairValLevel *string `json:"fairValLevel"` + IsCashCollateral *string `json:"isCashCollateral"` + IsNonCashCollateral *string `json:"isNonCashCollateral"` + IsLoanByFund *string `json:"isLoanByFund"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -499,7 +529,7 @@ func (m *FundDisclosure) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("FundDisclosure", "date") case shadow.AcceptedDate == nil: return missingMemberError("FundDisclosure", "acceptedDate") - case shadow.Symbol == nil: + case len(shadow.Symbol) == 0: return missingMemberError("FundDisclosure", "symbol") case shadow.Name == nil: return missingMemberError("FundDisclosure", "name") @@ -509,7 +539,7 @@ func (m *FundDisclosure) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("FundDisclosure", "title") case shadow.CUSIP == nil: return missingMemberError("FundDisclosure", "cusip") - case shadow.ISIN == nil: + case len(shadow.ISIN) == 0: return missingMemberError("FundDisclosure", "isin") case shadow.Balance == nil: return missingMemberError("FundDisclosure", "balance") @@ -540,16 +570,34 @@ func (m *FundDisclosure) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.IsLoanByFund == nil: return missingMemberError("FundDisclosure", "isLoanByFund") } + var symbol *string + if shadow.Symbol.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Symbol, &value); err != nil { + return err + } + symbol = &value + } + var isin *string + if shadow.ISIN.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.ISIN, &value); err != nil { + return err + } + if value != "" { + isin = &value + } + } *m = FundDisclosure{ CIK: *shadow.CIK, Date: *shadow.Date, AcceptedDate: *shadow.AcceptedDate, - Symbol: *shadow.Symbol, + Symbol: symbol, Name: *shadow.Name, Lei: *shadow.Lei, Title: *shadow.Title, CUSIP: *shadow.CUSIP, - ISIN: *shadow.ISIN, + ISIN: isin, Balance: *shadow.Balance, Units: *shadow.Units, CurrencyCode: *shadow.CurrencyCode, @@ -571,38 +619,38 @@ func (m *FundDisclosure) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // FundDisclosureSearchResult is one result from searching disclosure holders // by fund or ETF name. type FundDisclosureSearchResult struct { - Symbol string `json:"symbol"` - CIK string `json:"cik"` - ClassID string `json:"classId"` - SeriesID string `json:"seriesId"` - EntityName string `json:"entityName"` - EntityOrgType string `json:"entityOrgType"` - SeriesName string `json:"seriesName"` - ClassName string `json:"className"` - ReportingFileNumber string `json:"reportingFileNumber"` - Address string `json:"address"` - City string `json:"city"` - ZipCode string `json:"zipCode"` - State string `json:"state"` + Symbol string `json:"symbol"` + CIK string `json:"cik"` + ClassID string `json:"classId"` + SeriesID string `json:"seriesId"` + EntityName string `json:"entityName"` + EntityOrgType string `json:"entityOrgType"` + SeriesName string `json:"seriesName"` + ClassName string `json:"className"` + ReportingFileNumber string `json:"reportingFileNumber"` + Address *string `json:"address"` + City string `json:"city"` + ZipCode string `json:"zipCode"` + State string `json:"state"` } // fundDisclosureSearchResultShadow mirrors FundDisclosureSearchResult with a // pointer or raw value for every required member so a missing or null member // is observable after decoding. type fundDisclosureSearchResultShadow struct { - Symbol *string `json:"symbol"` - CIK *string `json:"cik"` - ClassID *string `json:"classId"` - SeriesID *string `json:"seriesId"` - EntityName *string `json:"entityName"` - EntityOrgType *string `json:"entityOrgType"` - SeriesName *string `json:"seriesName"` - ClassName *string `json:"className"` - ReportingFileNumber *string `json:"reportingFileNumber"` - Address *string `json:"address"` - City *string `json:"city"` - ZipCode *string `json:"zipCode"` - State *string `json:"state"` + Symbol *string `json:"symbol"` + CIK *string `json:"cik"` + ClassID *string `json:"classId"` + SeriesID *string `json:"seriesId"` + EntityName *string `json:"entityName"` + EntityOrgType *string `json:"entityOrgType"` + SeriesName *string `json:"seriesName"` + ClassName *string `json:"className"` + ReportingFileNumber *string `json:"reportingFileNumber"` + Address jsontext.Value `json:"address"` + City *string `json:"city"` + ZipCode *string `json:"zipCode"` + State *string `json:"state"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -632,7 +680,7 @@ func (m *FundDisclosureSearchResult) UnmarshalJSONFrom(dec *jsontext.Decoder) er return missingMemberError("FundDisclosureSearchResult", "className") case shadow.ReportingFileNumber == nil: return missingMemberError("FundDisclosureSearchResult", "reportingFileNumber") - case shadow.Address == nil: + case len(shadow.Address) == 0: return missingMemberError("FundDisclosureSearchResult", "address") case shadow.City == nil: return missingMemberError("FundDisclosureSearchResult", "city") @@ -641,6 +689,14 @@ func (m *FundDisclosureSearchResult) UnmarshalJSONFrom(dec *jsontext.Decoder) er case shadow.State == nil: return missingMemberError("FundDisclosureSearchResult", "state") } + var address *string + if shadow.Address.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Address, &value); err != nil { + return err + } + address = &value + } *m = FundDisclosureSearchResult{ Symbol: *shadow.Symbol, CIK: *shadow.CIK, @@ -651,7 +707,7 @@ func (m *FundDisclosureSearchResult) UnmarshalJSONFrom(dec *jsontext.Decoder) er SeriesName: *shadow.SeriesName, ClassName: *shadow.ClassName, ReportingFileNumber: *shadow.ReportingFileNumber, - Address: *shadow.Address, + Address: address, City: *shadow.City, ZipCode: *shadow.ZipCode, State: *shadow.State, diff --git a/sdk/go/funds_parity_test.go b/sdk/go/funds_parity_test.go index 54b55c09..95e691eb 100644 --- a/sdk/go/funds_parity_test.go +++ b/sdk/go/funds_parity_test.go @@ -5,14 +5,14 @@ import ( "encoding/json/v2" "errors" "math" + "reflect" "slices" "strings" "testing" ) // The nine funds fixtures, one per registry method. Every member is required -// and non-null in the Rust models, so no fixture carries an intentionally -// unknown member. fund_disclosure_dates.json decodes into the reused +// in the Rust models, so no fixture carries an intentionally unknown member. fund_disclosure_dates.json decodes into the reused // institutional_ownership Form13FFilingDate model. func TestFundsFixturesDecodeAndReencodeToTheSameMemberSet(t *testing.T) { t.Parallel() @@ -33,11 +33,11 @@ func TestDocumentedEtfFixturesDecodeExactly(t *testing.T) { t.Parallel() holdings := assertFixtureParity[ETFFundHolding](t, "etf_fund_holdings.json") wantHolding := ETFFundHolding{ - Symbol: "SPY", Asset: "AAPL", Name: "APPLE INC", ISIN: "US0378331005", SecurityCUSIP: "037833100", + Symbol: "SPY", Asset: new("AAPL"), Name: "APPLE INC", ISIN: new("US0378331005"), SecurityCUSIP: new("037833100"), SharesNumber: 181_418_073, WeightPercentage: 7.79997012, MarketValue: 61_679_458_958.0, UpdatedAt: mustParseDateTime(t, "2026-07-30 08:07:21"), } - if len(holdings) != 1 || holdings[0] != wantHolding { + if len(holdings) != 1 || !reflect.DeepEqual(holdings[0], wantHolding) { t.Fatalf("etf_fund_holdings = %+v, want %+v", holdings, wantHolding) } if got := memberSet(t, holdings[0]); len(got) != 9 { @@ -116,13 +116,13 @@ func TestDocumentedFundDisclosureFixturesDecodeExactly(t *testing.T) { disclosures := assertFixtureParity[FundDisclosure](t, "fund_disclosures.json") wantDisclosure := FundDisclosure{ CIK: "0000857489", Date: mustParseDate(t, "2023-10-31"), AcceptedDate: mustParseDateTime(t, "2023-12-28 09:26:13"), - Symbol: "000089.SZ", Name: "Shenzhen Airport Co Ltd", Lei: "3003009W045RIKRBZI44", Title: "SHENZ AIRPORT-A", - CUSIP: "N/A", ISIN: "CNE000000VK1", Balance: 2_438_784, Units: "NS", CurrencyCode: "CNY", + Symbol: new("000089.SZ"), Name: "Shenzhen Airport Co Ltd", Lei: "3003009W045RIKRBZI44", Title: "SHENZ AIRPORT-A", + CUSIP: "N/A", ISIN: new("CNE000000VK1"), Balance: 2_438_784, Units: "NS", CurrencyCode: "CNY", ValUsd: 2_255_873.6, PctVal: 0.0023838966190458206, PayoffProfile: "Long", AssetCat: "EC", IssuerCat: "CORP", InvCountry: "CN", IsRestrictedSEC: "N", FairValLevel: "2", IsCashCollateral: "N", IsNonCashCollateral: "N", IsLoanByFund: "N", } - if len(disclosures) != 1 || disclosures[0] != wantDisclosure { + if len(disclosures) != 1 || !reflect.DeepEqual(disclosures[0], wantDisclosure) { t.Fatalf("fund_disclosures = %+v, want %+v", disclosures, wantDisclosure) } members := memberValues(t, disclosures[0]) @@ -135,10 +135,10 @@ func TestDocumentedFundDisclosureFixturesDecodeExactly(t *testing.T) { Symbol: "FGOAX", CIK: "0000355691", ClassID: "C000024574", SeriesID: "S000009042", EntityName: "Federated Hermes Government Income Securities, Inc.", EntityOrgType: "30", SeriesName: "Federated Hermes Government Income Securities, Inc.", ClassName: "Class A Shares", - ReportingFileNumber: "811-03266", Address: "4000 ERICSSON DRIVE", City: "WARRENDALE", ZipCode: "15086-7561", + ReportingFileNumber: "811-03266", Address: new("4000 ERICSSON DRIVE"), City: "WARRENDALE", ZipCode: "15086-7561", State: "PA", } - if len(results) != 1 || results[0] != wantResult { + if len(results) != 1 || !reflect.DeepEqual(results[0], wantResult) { t.Fatalf("fund_disclosure_holder_search = %+v, want %+v", results, wantResult) } if got := memberSet(t, results[0]); len(got) != 13 { @@ -169,6 +169,47 @@ func fundsRewrite(t *testing.T, fixture string, members map[string]string) []byt return encoded } +// Mirrors omittable_members_decode_null_and_empty_as_none: the typed-code +// members decode null and "" to nil, symbol and address decode null to nil, +// and every nil member re-encodes as null. +func TestFundsOmittableMembersDecodeNullAndEmptyAsNil(t *testing.T) { + t.Parallel() + for _, wire := range []string{"null", `""`} { + var holdings []ETFFundHolding + if err := json.Unmarshal(fundsRewrite(t, "etf_fund_holdings.json", + map[string]string{"asset": wire, "isin": wire, "securityCusip": wire}), &holdings); err != nil || + holdings[0].Asset != nil || holdings[0].ISIN != nil || holdings[0].SecurityCUSIP != nil { + t.Fatalf("holding %s = %+v, %v", wire, holdings, err) + } + members := memberValues(t, holdings[0]) + for _, member := range []string{"asset", "isin", "securityCusip"} { + if string(members[member]) != "null" { + t.Fatalf("holding %s re-encoded %s = %s, want null", wire, member, members[member]) + } + } + var disclosures []FundDisclosure + if err := json.Unmarshal(fundsRewrite(t, "fund_disclosures.json", + map[string]string{"isin": wire}), &disclosures); err != nil || disclosures[0].ISIN != nil { + t.Fatalf("disclosure isin %s = %+v, %v", wire, disclosures, err) + } + } + var disclosures []FundDisclosure + if err := json.Unmarshal(fundsRewrite(t, "fund_disclosures.json", + map[string]string{"symbol": "null"}), &disclosures); err != nil || disclosures[0].Symbol != nil || + string(memberValues(t, disclosures[0])["symbol"]) != "null" { + t.Fatalf("disclosure null symbol = %+v, %v", disclosures, err) + } + var results []FundDisclosureSearchResult + if err := json.Unmarshal(fundsRewrite(t, "fund_disclosure_holder_search.json", + map[string]string{"address": "null"}), &results); err != nil || results[0].Address != nil { + t.Fatalf("search null address = %+v, %v", results, err) + } + if err := json.Unmarshal(fundsRewrite(t, "fund_disclosure_holder_search.json", + map[string]string{"address": `""`}), &results); err != nil || !reflect.DeepEqual(results[0].Address, new("")) { + t.Fatalf("search empty address = %+v, %v", results, err) + } +} + // Mirrors integer_widths_signed_change_and_decimal_market_values_are_preserved. func TestFundsExtremeIntegersDecodeIntoQuantityAmountAndCountMembers(t *testing.T) { t.Parallel()