From 408d2aae6ef1bbeca4be274a538188fee6ca8332 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 21:26:08 +0800 Subject: [PATCH] feat(insider-trading)!: make FMP-omittable members optional Members that FMP can omit from the insider-trading and institutional-ownership feeds are now optional across the Rust models, Python bindings, and Go SDK, so callers must handle their absence. * InsiderTrade `transaction_type`, `securities_owned`, and `direct_or_indirect` now decode a null or empty value to None; a whitespace-only transaction type is still rejected. * BeneficialOwnershipAcquisition `cusip`, `citizenship_or_place_of_organization`, and `shared_voting_power` decode null to None. * InstitutionalHolding `symbol` decodes null to None. * HolderIndustryBreakdown `industry_title`, `change_in_weight_percentage`, and `performance_percentage` decode null to None, while an empty industry title stays "". * Contract tests now take an explicit nullable-member list rather than treating every member as required and non-null, and re-encode these members as null. BREAKING CHANGE: InsiderTrade, BeneficialOwnershipAcquisition, InstitutionalHolding and HolderIndustryBreakdown members FMP can omit are now optional (Option / pointer / Optional); handle None. --- .../fmp/_native/insider_trading/__init__.pyi | 24 +-- .../institutional_ownership/__init__.pyi | 16 +- crates/fmp-py/src/models/insider_trading.rs | 30 +-- .../src/models/institutional_ownership.rs | 18 +- crates/fmp-py/tests/test_insider_trading_a.py | 13 ++ crates/fmp-py/tests/test_insider_trading_b.py | 15 ++ .../tests/test_institutional_ownership_a.py | 11 + .../tests/test_institutional_ownership_b.py | 14 ++ crates/fmp-py/tests/typecheck_contract.py | 4 +- .../libfmp/src/responses/insider_trading.rs | 30 ++- .../src/responses/institutional_ownership.rs | 22 +- ...r_trading_beneficial_ownership_endpoint.rs | 4 +- ...insider_trading_latest_search_endpoints.rs | 5 +- .../libfmp/tests/insider_trading_responses.rs | 80 +++++-- ...nstitutional_holder_summaries_endpoints.rs | 5 +- ...nstitutional_holder_summaries_responses.rs | 49 ++++- .../institutional_ownership_responses.rs | 23 +- crates/libfmp/tests/integral_f64_responses.rs | 2 +- sdk/go/insider_trading_models.go | 198 +++++++++++------- sdk/go/insider_trading_parity_test.go | 69 ++++-- sdk/go/insider_trading_test.go | 2 +- sdk/go/institutional_ownership_models.go | 126 ++++++----- sdk/go/institutional_ownership_parity_test.go | 42 +++- sdk/go/institutional_ownership_test.go | 2 +- sdk/go/integral_f64_parity_test.go | 2 +- 25 files changed, 567 insertions(+), 239 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/insider_trading/__init__.pyi b/crates/fmp-py/python/fmp/_native/insider_trading/__init__.pyi index 6662dd59..7ddc59ea 100644 --- a/crates/fmp-py/python/fmp/_native/insider_trading/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/insider_trading/__init__.pyi @@ -44,15 +44,15 @@ class BeneficialOwnershipAcquisition: @property def accepted_date(self) -> datetime.date: ... @property - def cusip(self) -> builtins.str: ... + def cusip(self) -> typing.Optional[builtins.str]: ... @property def name_of_reporting_person(self) -> builtins.str: ... @property - def citizenship_or_place_of_organization(self) -> builtins.str: ... + def citizenship_or_place_of_organization(self) -> typing.Optional[builtins.str]: ... @property def sole_voting_power(self) -> builtins.str: ... @property - def shared_voting_power(self) -> builtins.str: ... + def shared_voting_power(self) -> typing.Optional[builtins.str]: ... @property def sole_dispositive_power(self) -> builtins.str: ... @property @@ -73,11 +73,11 @@ class BeneficialOwnershipAcquisition: symbol: builtins.str, filing_date: datetime.date, accepted_date: datetime.date, - cusip: builtins.str, + cusip: typing.Optional[builtins.str], name_of_reporting_person: builtins.str, - citizenship_or_place_of_organization: builtins.str, + citizenship_or_place_of_organization: typing.Optional[builtins.str], sole_voting_power: builtins.str, - shared_voting_power: builtins.str, + shared_voting_power: typing.Optional[builtins.str], sole_dispositive_power: builtins.str, shared_dispositive_power: builtins.str, amount_beneficially_owned: builtins.str, @@ -151,9 +151,9 @@ class InsiderTrade: @property def company_cik(self) -> builtins.str: ... @property - def transaction_type(self) -> builtins.str: ... + def transaction_type(self) -> typing.Optional[builtins.str]: ... @property - def securities_owned(self) -> builtins.float: ... + def securities_owned(self) -> typing.Optional[builtins.float]: ... @property def reporting_name(self) -> builtins.str: ... @property @@ -161,7 +161,7 @@ class InsiderTrade: @property def acquisition_or_disposition(self) -> builtins.str: ... @property - def direct_or_indirect(self) -> builtins.str: ... + def direct_or_indirect(self) -> typing.Optional[builtins.str]: ... @property def form_type(self) -> builtins.str: ... @property @@ -181,12 +181,12 @@ class InsiderTrade: transaction_date: datetime.date, reporting_cik: builtins.str, company_cik: builtins.str, - transaction_type: builtins.str, - securities_owned: builtins.float, + transaction_type: typing.Optional[builtins.str], + securities_owned: typing.Optional[builtins.float], reporting_name: builtins.str, type_of_owner: builtins.str, acquisition_or_disposition: builtins.str, - direct_or_indirect: builtins.str, + direct_or_indirect: typing.Optional[builtins.str], form_type: builtins.str, securities_transacted: builtins.float, price: builtins.float, diff --git a/crates/fmp-py/python/fmp/_native/institutional_ownership/__init__.pyi b/crates/fmp-py/python/fmp/_native/institutional_ownership/__init__.pyi index 6ff6d228..1a43806c 100644 --- a/crates/fmp-py/python/fmp/_native/institutional_ownership/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/institutional_ownership/__init__.pyi @@ -68,7 +68,7 @@ class HolderIndustryBreakdown: @property def investor_name(self) -> builtins.str: ... @property - def industry_title(self) -> builtins.str: ... + def industry_title(self) -> typing.Optional[builtins.str]: ... @property def weight(self) -> builtins.float: ... @property @@ -76,11 +76,11 @@ class HolderIndustryBreakdown: @property def change_in_weight(self) -> builtins.float: ... @property - def change_in_weight_percentage(self) -> builtins.float: ... + def change_in_weight_percentage(self) -> typing.Optional[builtins.float]: ... @property def performance(self) -> builtins.float: ... @property - def performance_percentage(self) -> builtins.float: ... + def performance_percentage(self) -> typing.Optional[builtins.float]: ... @property def last_performance(self) -> builtins.float: ... @property @@ -92,13 +92,13 @@ class HolderIndustryBreakdown: date: datetime.date, cik: builtins.str, investor_name: builtins.str, - industry_title: builtins.str, + industry_title: typing.Optional[builtins.str], weight: builtins.float, last_weight: builtins.float, change_in_weight: builtins.float, - change_in_weight_percentage: builtins.float, + change_in_weight_percentage: typing.Optional[builtins.float], performance: builtins.float, - performance_percentage: builtins.float, + performance_percentage: typing.Optional[builtins.float], last_performance: builtins.float, change_in_performance: builtins.float, ) -> HolderIndustryBreakdown: ... @@ -472,7 +472,7 @@ class InstitutionalHolding: @property def security_cusip(self) -> builtins.str: ... @property - def symbol(self) -> builtins.str: ... + def symbol(self) -> typing.Optional[builtins.str]: ... @property def name_of_issuer(self) -> builtins.str: ... @property @@ -498,7 +498,7 @@ class InstitutionalHolding: accepted_date: datetime.date, cik: builtins.str, security_cusip: builtins.str, - symbol: builtins.str, + symbol: typing.Optional[builtins.str], name_of_issuer: builtins.str, shares: builtins.float, title_of_class: builtins.str, diff --git a/crates/fmp-py/src/models/insider_trading.rs b/crates/fmp-py/src/models/insider_trading.rs index 13dd218b..af6170b0 100644 --- a/crates/fmp-py/src/models/insider_trading.rs +++ b/crates/fmp-py/src/models/insider_trading.rs @@ -20,9 +20,9 @@ pub(crate) struct InsiderTrade { #[pyo3(get)] pub company_cik: String, #[pyo3(get)] - pub transaction_type: String, + pub transaction_type: Option, #[pyo3(get)] - pub securities_owned: f64, + pub securities_owned: Option, #[pyo3(get)] pub reporting_name: String, #[pyo3(get)] @@ -30,7 +30,7 @@ pub(crate) struct InsiderTrade { #[pyo3(get)] pub acquisition_or_disposition: String, #[pyo3(get)] - pub direct_or_indirect: String, + pub direct_or_indirect: Option, #[pyo3(get)] pub form_type: String, #[pyo3(get)] @@ -56,12 +56,12 @@ impl InsiderTrade { transaction_date: ::chrono::NaiveDate, reporting_cik: String, company_cik: String, - transaction_type: String, - securities_owned: f64, + transaction_type: Option, + securities_owned: Option, reporting_name: String, type_of_owner: String, acquisition_or_disposition: String, - direct_or_indirect: String, + direct_or_indirect: Option, form_type: String, securities_transacted: f64, price: f64, @@ -323,7 +323,7 @@ impl From for InsiderTrade { transaction_date: value.transaction_date.into_inner(), reporting_cik: value.reporting_cik.into_inner(), company_cik: value.company_cik.into_inner(), - transaction_type: value.transaction_type.into_inner(), + transaction_type: value.transaction_type.map(|value| value.into_inner()), securities_owned: value.securities_owned, reporting_name: value.reporting_name, type_of_owner: value.type_of_owner, @@ -814,15 +814,15 @@ pub(crate) struct BeneficialOwnershipAcquisition { #[pyo3(get)] pub accepted_date: ::chrono::NaiveDate, #[pyo3(get)] - pub cusip: String, + pub cusip: Option, #[pyo3(get)] pub name_of_reporting_person: String, #[pyo3(get)] - pub citizenship_or_place_of_organization: String, + pub citizenship_or_place_of_organization: Option, #[pyo3(get)] pub sole_voting_power: String, #[pyo3(get)] - pub shared_voting_power: String, + pub shared_voting_power: Option, #[pyo3(get)] pub sole_dispositive_power: String, #[pyo3(get)] @@ -849,11 +849,11 @@ impl BeneficialOwnershipAcquisition { symbol: String, filing_date: ::chrono::NaiveDate, accepted_date: ::chrono::NaiveDate, - cusip: String, + cusip: Option, name_of_reporting_person: String, - citizenship_or_place_of_organization: String, + citizenship_or_place_of_organization: Option, sole_voting_power: String, - shared_voting_power: String, + shared_voting_power: Option, sole_dispositive_power: String, shared_dispositive_power: String, amount_beneficially_owned: String, @@ -1122,11 +1122,11 @@ impl From symbol: value.symbol.into_inner(), filing_date: value.filing_date.into_inner(), accepted_date: value.accepted_date.into_inner(), - cusip: value.cusip.into_inner(), + cusip: value.cusip.map(|value| value.into_inner()), name_of_reporting_person: value.name_of_reporting_person, citizenship_or_place_of_organization: value.citizenship_or_place_of_organization, sole_voting_power: value.sole_voting_power.into_inner(), - shared_voting_power: value.shared_voting_power.into_inner(), + shared_voting_power: value.shared_voting_power.map(|value| value.into_inner()), sole_dispositive_power: value.sole_dispositive_power.into_inner(), shared_dispositive_power: value.shared_dispositive_power.into_inner(), amount_beneficially_owned: value.amount_beneficially_owned.into_inner(), diff --git a/crates/fmp-py/src/models/institutional_ownership.rs b/crates/fmp-py/src/models/institutional_ownership.rs index 41c05148..320f4d7b 100644 --- a/crates/fmp-py/src/models/institutional_ownership.rs +++ b/crates/fmp-py/src/models/institutional_ownership.rs @@ -213,7 +213,7 @@ pub(crate) struct InstitutionalHolding { #[pyo3(get)] pub security_cusip: String, #[pyo3(get)] - pub symbol: String, + pub symbol: Option, #[pyo3(get)] pub name_of_issuer: String, #[pyo3(get)] @@ -245,7 +245,7 @@ impl InstitutionalHolding { accepted_date: ::chrono::NaiveDate, cik: String, security_cusip: String, - symbol: String, + symbol: Option, name_of_issuer: String, shares: f64, title_of_class: String, @@ -462,7 +462,7 @@ impl From accepted_date: value.accepted_date.into_inner(), cik: value.cik.into_inner(), security_cusip: value.security_cusip.into_inner(), - symbol: value.symbol.into_inner(), + symbol: value.symbol.map(|value| value.into_inner()), name_of_issuer: value.name_of_issuer, shares: value.shares, title_of_class: value.title_of_class, @@ -2105,7 +2105,7 @@ pub(crate) struct HolderIndustryBreakdown { #[pyo3(get)] pub investor_name: String, #[pyo3(get)] - pub industry_title: String, + pub industry_title: Option, #[pyo3(get)] pub weight: f64, #[pyo3(get)] @@ -2113,11 +2113,11 @@ pub(crate) struct HolderIndustryBreakdown { #[pyo3(get)] pub change_in_weight: f64, #[pyo3(get)] - pub change_in_weight_percentage: f64, + pub change_in_weight_percentage: Option, #[pyo3(get)] pub performance: f64, #[pyo3(get)] - pub performance_percentage: f64, + pub performance_percentage: Option, #[pyo3(get)] pub last_performance: f64, #[pyo3(get)] @@ -2135,13 +2135,13 @@ impl HolderIndustryBreakdown { date: ::chrono::NaiveDate, cik: String, investor_name: String, - industry_title: String, + industry_title: Option, weight: f64, last_weight: f64, change_in_weight: f64, - change_in_weight_percentage: f64, + change_in_weight_percentage: Option, performance: f64, - performance_percentage: f64, + performance_percentage: Option, last_performance: f64, change_in_performance: f64, ) -> Self { diff --git a/crates/fmp-py/tests/test_insider_trading_a.py b/crates/fmp-py/tests/test_insider_trading_a.py index 7b8d7383..46748153 100644 --- a/crates/fmp-py/tests/test_insider_trading_a.py +++ b/crates/fmp-py/tests/test_insider_trading_a.py @@ -61,6 +61,19 @@ def test_latest_trades_with_every_filter(client: Any, fixture_server: FixtureSer assert_trmk_award(rows[0]) +def test_latest_trades_decode_omitted_members_as_none(client: Any, fixture_server: FixtureServer) -> None: + """Issue #368: a null or empty ``transactionType`` and null owned or direct members decode as ``None``.""" + body = load_fixture("latest_insider_trades.json") + body[0].update(transactionType="", securitiesOwned=None, directOrIndirect=None) + fixture_server.route("/insider-trading/latest", body) + rows = client.insider_trading.latest_trades() + + assert rows[0].transaction_type is None + assert rows[0].securities_owned is None + assert rows[0].direct_or_indirect is None + assert rows[0].symbol == "TRMK" + + def test_latest_trades_accepts_a_datetime_date(client: Any, fixture_server: FixtureServer) -> None: """``latest_trades`` formats a ``datetime.date`` as ``YYYY-MM-DD`` on the wire.""" fixture_server.route("/insider-trading/latest", load_fixture("latest_insider_trades.json")) diff --git a/crates/fmp-py/tests/test_insider_trading_b.py b/crates/fmp-py/tests/test_insider_trading_b.py index f884b9d7..3a3a38c9 100644 --- a/crates/fmp-py/tests/test_insider_trading_b.py +++ b/crates/fmp-py/tests/test_insider_trading_b.py @@ -130,6 +130,21 @@ def test_beneficial_ownership_acquisitions_with_limit(client: Any, fixture_serve assert row.url.startswith("https://www.sec.gov/Archives/edgar/data/320193/") +def test_beneficial_ownership_acquisitions_decode_null_members_as_none( + client: Any, fixture_server: FixtureServer +) -> None: + """Issue #368: a null ``cusip``, citizenship, or shared voting power decodes as ``None``.""" + body = load_fixture("beneficial_ownership_acquisitions.json") + body[0].update(cusip=None, citizenshipOrPlaceOfOrganization=None, sharedVotingPower=None) + fixture_server.route("/acquisition-of-beneficial-ownership", body) + rows = client.insider_trading.beneficial_ownership_acquisitions("AAPL") + + assert rows[0].cusip is None + assert rows[0].citizenship_or_place_of_organization is None + assert rows[0].shared_voting_power is None + assert rows[0].sole_voting_power == "0" + + def test_beneficial_ownership_acquisitions_with_a_spaced_symbol_and_the_u32_limit( client: Any, fixture_server: FixtureServer ) -> None: diff --git a/crates/fmp-py/tests/test_institutional_ownership_a.py b/crates/fmp-py/tests/test_institutional_ownership_a.py index 421134cb..8f3f0e64 100644 --- a/crates/fmp-py/tests/test_institutional_ownership_a.py +++ b/crates/fmp-py/tests/test_institutional_ownership_a.py @@ -101,6 +101,17 @@ def test_extract_encodes_cik_year_then_quarter(client: Any, fixture_server: Fixt assert row.accepted_date == datetime.date(2023, 11, 13) +def test_extract_decodes_a_null_symbol_as_none(client: Any, fixture_server: FixtureServer) -> None: + """Issue #368: a holding whose ``symbol`` is null decodes as ``None``.""" + body = load_fixture("institutional_ownership_extract.json") + body[0]["symbol"] = None + fixture_server.route("/institutional-ownership/extract", body) + rows = client.institutional_ownership.extract(HOLDER_CIK, YEAR_2023, Q3) + + assert rows[0].symbol is None + assert rows[0].security_cusip == "674215207" + + def test_extract_accepts_keyword_arguments(client: Any, fixture_server: FixtureServer) -> None: """``extract`` takes its required arguments by keyword as well as by position.""" fixture_server.route("/institutional-ownership/extract", load_fixture("institutional_ownership_extract.json")) diff --git a/crates/fmp-py/tests/test_institutional_ownership_b.py b/crates/fmp-py/tests/test_institutional_ownership_b.py index 1b90357b..9d8fe829 100644 --- a/crates/fmp-py/tests/test_institutional_ownership_b.py +++ b/crates/fmp-py/tests/test_institutional_ownership_b.py @@ -93,6 +93,20 @@ def test_holder_industry_breakdown_encodes_cik_year_then_quarter(client: Any, fi assert row.performance_percentage == pytest.approx(-178.2938) +def test_holder_industry_breakdown_decodes_null_members_as_none(client: Any, fixture_server: FixtureServer) -> None: + """Issue #368: null industry title and percentages decode as ``None``; an empty title stays ``""``.""" + body = load_fixture("holder_industry_breakdown.json") + body[0].update(industryTitle=None, changeInWeightPercentage=None, performancePercentage=None) + body.append({**body[0], "industryTitle": ""}) + fixture_server.route("/institutional-ownership/holder-industry-breakdown", body) + rows = client.institutional_ownership.holder_industry_breakdown(BERKSHIRE_CIK, YEAR_2023, Q3) + + assert rows[0].industry_title is None + assert rows[0].change_in_weight_percentage is None + assert rows[0].performance_percentage is None + assert rows[1].industry_title == "" + + def test_positions_summary_encodes_a_spaced_symbol(client: Any, fixture_server: FixtureServer) -> None: """``positions_summary`` form-encodes the ticker before the year and quarter and decodes the row.""" fixture_server.route( diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 65591d65..ceadcb0b 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -478,6 +478,7 @@ def check_insider_trading_contract(client: FmpClient) -> None: ) filed_on: datetime.date = trades[0].filing_date price: float = trades[0].price + owned: float | None = trades[0].securities_owned latest: list[InsiderTrade] = client.insider_trading.latest_trades(date=datetime.date(2026, 1, 27), page=0) traded_on: datetime.date = latest[0].transaction_date types: list[InsiderTransactionType] = client.insider_trading.transaction_types() @@ -489,7 +490,8 @@ def check_insider_trading_contract(client: FmpClient) -> None: ) accepted_on: datetime.date = filings[0].accepted_date percent: str = filings[0].percent_of_class - _ = (filed_on, price, traded_on, code, ratio, accepted_on, percent) + cusip: str | None = filings[0].cusip + _ = (filed_on, price, owned, traded_on, code, ratio, accepted_on, percent, cusip) def check_fundraising_contract(client: FmpClient) -> None: diff --git a/crates/libfmp/src/responses/insider_trading.rs b/crates/libfmp/src/responses/insider_trading.rs index 9b6a386a..65617bb7 100644 --- a/crates/libfmp/src/responses/insider_trading.rs +++ b/crates/libfmp/src/responses/insider_trading.rs @@ -4,7 +4,7 @@ //! US-based companies only. The Python binding exposes these models under //! `fmp.insider_trading`. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ codecs::NumericString, @@ -14,6 +14,14 @@ use crate::{ }, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One insider trade shared by the latest and search feeds. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] @@ -24,13 +32,16 @@ pub struct InsiderTrade { pub transaction_date: Date, pub reporting_cik: Cik, pub company_cik: Cik, - pub transaction_type: TransactionTypeCode, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub securities_owned: Quantity, + #[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")] + pub transaction_type: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub securities_owned: Option, pub reporting_name: String, pub type_of_owner: String, pub acquisition_or_disposition: String, - pub direct_or_indirect: String, + #[serde(deserialize_with = "required_option")] + pub direct_or_indirect: Option, pub form_type: FormType, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub securities_transacted: Quantity, @@ -91,11 +102,14 @@ pub struct BeneficialOwnershipAcquisition { pub symbol: Ticker, pub filing_date: Date, pub accepted_date: Date, - pub cusip: Cusip, + #[serde(deserialize_with = "required_option")] + pub cusip: Option, pub name_of_reporting_person: String, - pub citizenship_or_place_of_organization: String, + #[serde(deserialize_with = "required_option")] + pub citizenship_or_place_of_organization: Option, pub sole_voting_power: NumericString, - pub shared_voting_power: NumericString, + #[serde(deserialize_with = "required_option")] + pub shared_voting_power: Option, pub sole_dispositive_power: NumericString, pub shared_dispositive_power: NumericString, pub amount_beneficially_owned: NumericString, diff --git a/crates/libfmp/src/responses/institutional_ownership.rs b/crates/libfmp/src/responses/institutional_ownership.rs index dff6c74f..e82d73e2 100644 --- a/crates/libfmp/src/responses/institutional_ownership.rs +++ b/crates/libfmp/src/responses/institutional_ownership.rs @@ -1,12 +1,20 @@ //! Response rows returned by institutional-ownership filing endpoints. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::types::{ ApiDateTime, CalendarQuarter, CalendarYear, Cik, Cusip, Date, MarketValue, Price, Quantity, Ticker, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One recent institutional-ownership filing. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] @@ -32,7 +40,8 @@ pub struct InstitutionalHolding { pub accepted_date: Date, pub cik: Cik, pub security_cusip: Cusip, - pub symbol: Ticker, + #[serde(deserialize_with = "required_option")] + pub symbol: Option, pub name_of_issuer: String, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub shares: Quantity, @@ -182,14 +191,17 @@ pub struct HolderIndustryBreakdown { pub date: Date, pub cik: Cik, pub investor_name: String, - pub industry_title: String, + #[serde(deserialize_with = "required_option")] + pub industry_title: Option, pub weight: f64, pub last_weight: f64, pub change_in_weight: f64, - pub change_in_weight_percentage: f64, + #[serde(deserialize_with = "required_option")] + pub change_in_weight_percentage: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub performance: MarketValue, - pub performance_percentage: f64, + #[serde(deserialize_with = "required_option")] + pub performance_percentage: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub last_performance: MarketValue, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] diff --git a/crates/libfmp/tests/insider_trading_beneficial_ownership_endpoint.rs b/crates/libfmp/tests/insider_trading_beneficial_ownership_endpoint.rs index a42470a5..a4a13bf1 100644 --- a/crates/libfmp/tests/insider_trading_beneficial_ownership_endpoint.rs +++ b/crates/libfmp/tests/insider_trading_beneficial_ownership_endpoint.rs @@ -75,11 +75,11 @@ async fn custom_proxy_preserves_order_encoding_headers_and_exact_fixture_identit assert_eq!(rows.len(), 1); let row = &rows[0]; assert_eq!(row.cik.as_str(), "0000320193"); - assert_eq!(row.cusip.as_str(), "037833100"); + assert_eq!(row.cusip.as_ref().unwrap().as_str(), "037833100"); assert_eq!(row.filing_date, Date::parse("2026-04-29").unwrap()); assert_eq!(row.accepted_date, Date::parse("2026-04-29").unwrap()); assert_eq!(row.sole_voting_power.as_str(), "0"); - assert_eq!(row.shared_voting_power.as_str(), "0"); + assert_eq!(row.shared_voting_power.as_ref().unwrap().as_str(), "0"); assert_eq!(row.sole_dispositive_power.as_str(), "0"); assert_eq!(row.shared_dispositive_power.as_str(), "0"); assert_eq!(row.amount_beneficially_owned.as_str(), "1099168953"); diff --git a/crates/libfmp/tests/insider_trading_latest_search_endpoints.rs b/crates/libfmp/tests/insider_trading_latest_search_endpoints.rs index e9fa8f51..024392e8 100644 --- a/crates/libfmp/tests/insider_trading_latest_search_endpoints.rs +++ b/crates/libfmp/tests/insider_trading_latest_search_endpoints.rs @@ -105,7 +105,10 @@ async fn custom_proxy_preserves_exact_order_encoding_headers_and_fixture_identit assert_eq!(latest[0].symbol.as_str(), "TRMK"); assert_eq!(latest[0].reporting_cik.as_str(), "0001661867"); assert_eq!(latest[0].company_cik.as_str(), "0000036146"); - assert_eq!(latest[0].transaction_type.as_str(), "A-Award"); + assert_eq!( + latest[0].transaction_type.as_ref().unwrap().as_str(), + "A-Award" + ); assert_eq!(latest[0].price, 0.0); let requests = executor.requests(); diff --git a/crates/libfmp/tests/insider_trading_responses.rs b/crates/libfmp/tests/insider_trading_responses.rs index 5be004d7..1d32c660 100644 --- a/crates/libfmp/tests/insider_trading_responses.rs +++ b/crates/libfmp/tests/insider_trading_responses.rs @@ -33,8 +33,11 @@ fn exact_latest_and_search_fixtures_share_the_16_field_trade_row() { ); assert_eq!(latest[0].reporting_cik.as_str(), "0001661867"); assert_eq!(latest[0].company_cik.as_str(), "0000036146"); - assert_eq!(latest[0].transaction_type.as_str(), "A-Award"); - assert_eq!(latest[0].securities_owned, 62_959.0); + assert_eq!( + latest[0].transaction_type.as_ref().unwrap().as_str(), + "A-Award" + ); + assert_eq!(latest[0].securities_owned, Some(62_959.0)); assert_eq!(latest[0].securities_transacted, 1_608.0); assert_eq!(latest[0].price, 0.0); assert_eq!(latest[0].form_type.as_str(), "4"); @@ -79,7 +82,7 @@ fn exact_ownership_fixture_preserves_dates_identifiers_and_quoted_numbers() { let rows: Vec = serde_json::from_value(source.clone()).unwrap(); let row = &rows[0]; assert_eq!(row.cik.as_str(), "0000320193"); - assert_eq!(row.cusip.as_str(), "037833100"); + assert_eq!(row.cusip.as_ref().unwrap().as_str(), "037833100"); assert_eq!(row.accepted_date, Date::from_str("2026-04-29").unwrap()); assert_eq!(row.sole_voting_power.as_str(), "0"); assert_eq!(row.amount_beneficially_owned.as_str(), "1099168953"); @@ -94,7 +97,7 @@ fn counts_preserve_u64_quantities_decode_as_f64_and_integer_prices_decode_as_pri trade[0]["securitiesTransacted"] = serde_json::json!(u64::MAX); trade[0]["price"] = serde_json::json!(225); let rows: Vec = serde_json::from_value(trade).unwrap(); - assert_eq!(rows[0].securities_owned, u64::MAX as f64); + assert_eq!(rows[0].securities_owned, Some(u64::MAX as f64)); assert_eq!(rows[0].securities_transacted, u64::MAX as f64); assert_eq!(rows[0].price, 225.0); @@ -118,6 +121,46 @@ fn counts_preserve_u64_quantities_decode_as_f64_and_integer_prices_decode_as_pri } } +#[test] +fn nullable_trade_members_decode_null_and_empty_type_as_none() { + let mut source: serde_json::Value = serde_json::from_slice(LATEST).unwrap(); + source[0]["transactionType"] = serde_json::json!(""); + source[0]["securitiesOwned"] = serde_json::Value::Null; + source[0]["directOrIndirect"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(source.clone()).unwrap(); + assert_eq!(rows[0].transaction_type, None); + assert_eq!(rows[0].securities_owned, None); + assert_eq!(rows[0].direct_or_indirect, None); + let wire = serde_json::to_value(&rows).unwrap(); + assert!(wire[0]["transactionType"].is_null()); + assert!(wire[0]["securitiesOwned"].is_null()); + assert!(wire[0]["directOrIndirect"].is_null()); + + source[0]["transactionType"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(source.clone()).unwrap(); + assert_eq!(rows[0].transaction_type, None); + + source[0]["transactionType"] = serde_json::json!(" "); + assert!(serde_json::from_value::>(source).is_err()); +} + +#[test] +fn nullable_ownership_members_decode_null_as_none() { + let mut source: serde_json::Value = serde_json::from_slice(OWNERSHIP).unwrap(); + for field in [ + "cusip", + "citizenshipOrPlaceOfOrganization", + "sharedVotingPower", + ] { + source[0][field] = serde_json::Value::Null; + } + let rows: Vec = serde_json::from_value(source.clone()).unwrap(); + assert_eq!(rows[0].cusip, None); + assert_eq!(rows[0].citizenship_or_place_of_organization, None); + assert_eq!(rows[0].shared_voting_power, None); + assert_eq!(serde_json::to_value(rows).unwrap(), source); +} + #[test] fn quoted_numeric_ownership_fields_preserve_exact_decimal_text() { let fields = [ @@ -143,12 +186,20 @@ fn quoted_numeric_ownership_fields_preserve_exact_decimal_text() { #[test] fn all_fields_are_required_non_null_and_unknowns_are_accepted() { - assert_contract::(LATEST); - assert_contract::(SEARCH); - assert_contract::(REPORTING_NAMES); - assert_contract::(TRANSACTION_TYPES); - assert_contract::(STATISTICS); - assert_contract::(OWNERSHIP); + let trade_nullable = ["transactionType", "securitiesOwned", "directOrIndirect"]; + assert_contract::(LATEST, &trade_nullable); + assert_contract::(SEARCH, &trade_nullable); + assert_contract::(REPORTING_NAMES, &[]); + assert_contract::(TRANSACTION_TYPES, &[]); + assert_contract::(STATISTICS, &[]); + assert_contract::( + OWNERSHIP, + &[ + "cusip", + "citizenshipOrPlaceOfOrganization", + "sharedVotingPower", + ], + ); } #[test] @@ -164,7 +215,7 @@ fn all_six_contracts_are_bare_arrays_preserving_empty_and_multiple_rows() { ); } -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() @@ -182,9 +233,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(), - "{key} unexpectedly accepted as null" + assert_eq!( + serde_json::from_value::>(null).is_ok(), + nullable.contains(&key.as_str()), + "{key} null acceptance differs from the nullable list" ); } diff --git a/crates/libfmp/tests/institutional_holder_summaries_endpoints.rs b/crates/libfmp/tests/institutional_holder_summaries_endpoints.rs index 9b524be1..d69b2a75 100644 --- a/crates/libfmp/tests/institutional_holder_summaries_endpoints.rs +++ b/crates/libfmp/tests/institutional_holder_summaries_endpoints.rs @@ -101,7 +101,10 @@ async fn custom_proxy_preserves_exact_queries_auth_headers_and_source_arrays() { assert_eq!(performance[0].cik.as_str(), "0001067983"); assert_eq!(performance[0].change_in_performance, -14_398_745_159.0); assert_eq!(industry.len(), 1); - assert_eq!(industry[0].industry_title, "ELECTRONIC COMPUTERS"); + assert_eq!( + industry[0].industry_title.as_deref(), + Some("ELECTRONIC COMPUTERS") + ); assert_eq!(industry[0].change_in_performance, -47_453_494_598.0); let requests = executor.requests(); diff --git a/crates/libfmp/tests/institutional_holder_summaries_responses.rs b/crates/libfmp/tests/institutional_holder_summaries_responses.rs index 5c7d8482..e4ee0fca 100644 --- a/crates/libfmp/tests/institutional_holder_summaries_responses.rs +++ b/crates/libfmp/tests/institutional_holder_summaries_responses.rs @@ -71,13 +71,13 @@ fn exact_industry_fixture_decodes_all_12_fields() { date: Date::from_str("2023-09-30").unwrap(), cik: Cik::new("0001067983").unwrap(), investor_name: "BERKSHIRE HATHAWAY INC".to_owned(), - industry_title: "ELECTRONIC COMPUTERS".to_owned(), + industry_title: Some("ELECTRONIC COMPUTERS".to_owned()), weight: 49.7704, last_weight: 51.0035, change_in_weight: -1.2332, - change_in_weight_percentage: -2.4178, + change_in_weight_percentage: Some(-2.4178), performance: -20_838_154_294.0, - performance_percentage: -178.2938, + performance_percentage: Some(-178.2938), last_performance: 26_615_340_304.0, change_in_performance: -47_453_494_598.0, }] @@ -198,13 +198,41 @@ fn turnover_percentage_relative_and_weight_values_are_raw_f64() { let rows: Vec = serde_json::from_value(industry).unwrap(); assert_eq!(rows[0].weight, 250.5); assert_eq!(rows[0].last_weight, -1.25); - assert_eq!(rows[0].performance_percentage, -500.5); + assert_eq!(rows[0].performance_percentage, Some(-500.5)); +} + +#[test] +fn nullable_industry_members_decode_null_as_none_and_keep_empty_titles() { + let mut source: serde_json::Value = serde_json::from_slice(INDUSTRY).unwrap(); + for field in [ + "industryTitle", + "changeInWeightPercentage", + "performancePercentage", + ] { + source[0][field] = serde_json::Value::Null; + } + let rows: Vec = serde_json::from_value(source.clone()).unwrap(); + assert_eq!(rows[0].industry_title, None); + assert_eq!(rows[0].change_in_weight_percentage, None); + assert_eq!(rows[0].performance_percentage, None); + assert_eq!(serde_json::to_value(rows).unwrap(), source); + + source[0]["industryTitle"] = serde_json::json!(""); + let rows: Vec = serde_json::from_value(source).unwrap(); + assert_eq!(rows[0].industry_title.as_deref(), Some("")); } #[test] fn every_documented_field_is_required_non_null_and_unknowns_are_accepted() { - assert_contract::(PERFORMANCE); - assert_contract::(INDUSTRY); + assert_contract::(PERFORMANCE, &[]); + assert_contract::( + INDUSTRY, + &[ + "industryTitle", + "changeInWeightPercentage", + "performancePercentage", + ], + ); let mut performance_timestamp: serde_json::Value = serde_json::from_slice(PERFORMANCE).unwrap(); performance_timestamp[0]["date"] = serde_json::json!("2026-03-31 00:00:00"); @@ -239,7 +267,7 @@ fn both_contracts_are_bare_vecs_preserving_empty_and_multiple_rows() { assert_multiple::(INDUSTRY); } -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() @@ -256,9 +284,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} acceptance differs from the nullable list" ); } let mut forward = source; diff --git a/crates/libfmp/tests/institutional_ownership_responses.rs b/crates/libfmp/tests/institutional_ownership_responses.rs index 091f4d09..0e3f51a3 100644 --- a/crates/libfmp/tests/institutional_ownership_responses.rs +++ b/crates/libfmp/tests/institutional_ownership_responses.rs @@ -48,7 +48,7 @@ fn exact_extract_fixture_decodes_fourteen_fields_and_required_empty_put_call() { accepted_date: Date::from_str("2023-11-13").unwrap(), cik: Cik::new("0001388838").unwrap(), security_cusip: Cusip::new("674215207").unwrap(), - symbol: Ticker::new("CHRD").unwrap(), + symbol: Some(Ticker::new("CHRD").unwrap()), name_of_issuer: "CHORD ENERGY CORPORATION".to_owned(), shares: 13_280.0, title_of_class: "COM NEW".to_owned(), @@ -63,6 +63,12 @@ fn exact_extract_fixture_decodes_fourteen_fields_and_required_empty_put_call() { assert_eq!(rows[0].security_cusip.as_str(), "674215207"); assert!(rows[0].put_call_share.is_empty()); + let mut null_symbol: serde_json::Value = serde_json::from_slice(EXTRACT).unwrap(); + null_symbol[0]["symbol"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(null_symbol).unwrap(); + assert_eq!(rows[0].symbol, None); + assert!(serde_json::to_value(&rows[0]).unwrap()["symbol"].is_null()); + let mut leading_zero: serde_json::Value = serde_json::from_slice(EXTRACT).unwrap(); leading_zero[0]["securityCusip"] = serde_json::json!("001234567"); let rows: Vec = serde_json::from_value(leading_zero).unwrap(); @@ -137,9 +143,9 @@ fn shares_and_value_decode_large_fractional_and_negative_numbers_but_reject_text #[test] fn all_fields_are_required_non_null_and_unknowns_are_accepted() { - assert_contract::(LATEST); - assert_contract::(EXTRACT); - assert_contract::(DATES); + assert_contract::(LATEST, &[]); + assert_contract::(EXTRACT, &["symbol"]); + assert_contract::(DATES, &[]); } #[test] @@ -171,7 +177,7 @@ fn all_three_contracts_are_bare_arrays_preserving_empty_and_multiple_rows() { assert_multiple::(DATES); } -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() @@ -190,9 +196,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} acceptance differs from the nullable list" ); } diff --git a/crates/libfmp/tests/integral_f64_responses.rs b/crates/libfmp/tests/integral_f64_responses.rs index 0693c480..1e8a12aa 100644 --- a/crates/libfmp/tests/integral_f64_responses.rs +++ b/crates/libfmp/tests/integral_f64_responses.rs @@ -75,7 +75,7 @@ fn split_terms_decode_fractional_and_integral_float_forms() { fn insider_share_quantities_decode_fractional_and_integral_float_forms() { let rows: Vec = serde_json::from_slice(INSIDER).unwrap(); - assert_eq!(rows[0].securities_owned, 62_959.5); + assert_eq!(rows[0].securities_owned, Some(62_959.5)); assert_eq!(rows[0].securities_transacted, 1.0); let wire = serde_json::to_value(&rows).unwrap(); assert_eq!(wire[0]["securitiesOwned"], json!(62_959.5)); diff --git a/sdk/go/insider_trading_models.go b/sdk/go/insider_trading_models.go index c47d46c6..3b3d5956 100644 --- a/sdk/go/insider_trading_models.go +++ b/sdk/go/insider_trading_models.go @@ -12,44 +12,44 @@ import ( // InsiderTrade is one insider trade shared by the latest and search feeds. type InsiderTrade struct { - Symbol string `json:"symbol"` - FilingDate Date `json:"filingDate"` - TransactionDate Date `json:"transactionDate"` - ReportingCIK string `json:"reportingCik"` - CompanyCIK string `json:"companyCik"` - TransactionType string `json:"transactionType"` - SecuritiesOwned float64 `json:"securitiesOwned"` - ReportingName string `json:"reportingName"` - TypeOfOwner string `json:"typeOfOwner"` - AcquisitionOrDisposition string `json:"acquisitionOrDisposition"` - DirectOrIndirect string `json:"directOrIndirect"` - FormType string `json:"formType"` - SecuritiesTransacted float64 `json:"securitiesTransacted"` - Price float64 `json:"price"` - SecurityName string `json:"securityName"` - URL string `json:"url"` + Symbol string `json:"symbol"` + FilingDate Date `json:"filingDate"` + TransactionDate Date `json:"transactionDate"` + ReportingCIK string `json:"reportingCik"` + CompanyCIK string `json:"companyCik"` + TransactionType *string `json:"transactionType"` + SecuritiesOwned *float64 `json:"securitiesOwned"` + ReportingName string `json:"reportingName"` + TypeOfOwner string `json:"typeOfOwner"` + AcquisitionOrDisposition string `json:"acquisitionOrDisposition"` + DirectOrIndirect *string `json:"directOrIndirect"` + FormType string `json:"formType"` + SecuritiesTransacted float64 `json:"securitiesTransacted"` + Price float64 `json:"price"` + SecurityName string `json:"securityName"` + URL string `json:"url"` } // insiderTradeShadow mirrors InsiderTrade with a pointer or raw value for // every required member so a missing or null member is observable after // decoding. type insiderTradeShadow struct { - Symbol *string `json:"symbol"` - FilingDate *Date `json:"filingDate"` - TransactionDate *Date `json:"transactionDate"` - ReportingCIK *string `json:"reportingCik"` - CompanyCIK *string `json:"companyCik"` - TransactionType *string `json:"transactionType"` - SecuritiesOwned *float64 `json:"securitiesOwned"` - ReportingName *string `json:"reportingName"` - TypeOfOwner *string `json:"typeOfOwner"` - AcquisitionOrDisposition *string `json:"acquisitionOrDisposition"` - DirectOrIndirect *string `json:"directOrIndirect"` - FormType *string `json:"formType"` - SecuritiesTransacted *float64 `json:"securitiesTransacted"` - Price *float64 `json:"price"` - SecurityName *string `json:"securityName"` - URL *string `json:"url"` + Symbol *string `json:"symbol"` + FilingDate *Date `json:"filingDate"` + TransactionDate *Date `json:"transactionDate"` + ReportingCIK *string `json:"reportingCik"` + CompanyCIK *string `json:"companyCik"` + TransactionType jsontext.Value `json:"transactionType"` + SecuritiesOwned jsontext.Value `json:"securitiesOwned"` + ReportingName *string `json:"reportingName"` + TypeOfOwner *string `json:"typeOfOwner"` + AcquisitionOrDisposition *string `json:"acquisitionOrDisposition"` + DirectOrIndirect jsontext.Value `json:"directOrIndirect"` + FormType *string `json:"formType"` + SecuritiesTransacted *float64 `json:"securitiesTransacted"` + Price *float64 `json:"price"` + SecurityName *string `json:"securityName"` + URL *string `json:"url"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -71,9 +71,9 @@ func (m *InsiderTrade) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("InsiderTrade", "reportingCik") case shadow.CompanyCIK == nil: return missingMemberError("InsiderTrade", "companyCik") - case shadow.TransactionType == nil: + case len(shadow.TransactionType) == 0: return missingMemberError("InsiderTrade", "transactionType") - case shadow.SecuritiesOwned == nil: + case len(shadow.SecuritiesOwned) == 0: return missingMemberError("InsiderTrade", "securitiesOwned") case shadow.ReportingName == nil: return missingMemberError("InsiderTrade", "reportingName") @@ -81,7 +81,7 @@ func (m *InsiderTrade) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("InsiderTrade", "typeOfOwner") case shadow.AcquisitionOrDisposition == nil: return missingMemberError("InsiderTrade", "acquisitionOrDisposition") - case shadow.DirectOrIndirect == nil: + case len(shadow.DirectOrIndirect) == 0: return missingMemberError("InsiderTrade", "directOrIndirect") case shadow.FormType == nil: return missingMemberError("InsiderTrade", "formType") @@ -94,18 +94,44 @@ func (m *InsiderTrade) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.URL == nil: return missingMemberError("InsiderTrade", "url") } + var transactionType *string + if shadow.TransactionType.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.TransactionType, &value); err != nil { + return err + } + if value != "" { + transactionType = &value + } + } + var securitiesOwned *float64 + if shadow.SecuritiesOwned.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.SecuritiesOwned, &value); err != nil { + return err + } + securitiesOwned = &value + } + var directOrIndirect *string + if shadow.DirectOrIndirect.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.DirectOrIndirect, &value); err != nil { + return err + } + directOrIndirect = &value + } *m = InsiderTrade{ Symbol: *shadow.Symbol, FilingDate: *shadow.FilingDate, TransactionDate: *shadow.TransactionDate, ReportingCIK: *shadow.ReportingCIK, CompanyCIK: *shadow.CompanyCIK, - TransactionType: *shadow.TransactionType, - SecuritiesOwned: *shadow.SecuritiesOwned, + TransactionType: transactionType, + SecuritiesOwned: securitiesOwned, ReportingName: *shadow.ReportingName, TypeOfOwner: *shadow.TypeOfOwner, AcquisitionOrDisposition: *shadow.AcquisitionOrDisposition, - DirectOrIndirect: *shadow.DirectOrIndirect, + DirectOrIndirect: directOrIndirect, FormType: *shadow.FormType, SecuritiesTransacted: *shadow.SecuritiesTransacted, Price: *shadow.Price, @@ -290,42 +316,42 @@ func (m *InsiderTradeStatistics) UnmarshalJSONFrom(dec *jsontext.Decoder) error // BeneficialOwnershipAcquisition is one beneficial-ownership acquisition // filing row. type BeneficialOwnershipAcquisition struct { - CIK string `json:"cik"` - Symbol string `json:"symbol"` - FilingDate Date `json:"filingDate"` - AcceptedDate Date `json:"acceptedDate"` - CUSIP string `json:"cusip"` - NameOfReportingPerson string `json:"nameOfReportingPerson"` - CitizenshipOrPlaceOfOrganization string `json:"citizenshipOrPlaceOfOrganization"` - SoleVotingPower string `json:"soleVotingPower"` - SharedVotingPower string `json:"sharedVotingPower"` - SoleDispositivePower string `json:"soleDispositivePower"` - SharedDispositivePower string `json:"sharedDispositivePower"` - AmountBeneficiallyOwned string `json:"amountBeneficiallyOwned"` - PercentOfClass string `json:"percentOfClass"` - TypeOfReportingPerson string `json:"typeOfReportingPerson"` - URL string `json:"url"` + CIK string `json:"cik"` + Symbol string `json:"symbol"` + FilingDate Date `json:"filingDate"` + AcceptedDate Date `json:"acceptedDate"` + CUSIP *string `json:"cusip"` + NameOfReportingPerson string `json:"nameOfReportingPerson"` + CitizenshipOrPlaceOfOrganization *string `json:"citizenshipOrPlaceOfOrganization"` + SoleVotingPower string `json:"soleVotingPower"` + SharedVotingPower *string `json:"sharedVotingPower"` + SoleDispositivePower string `json:"soleDispositivePower"` + SharedDispositivePower string `json:"sharedDispositivePower"` + AmountBeneficiallyOwned string `json:"amountBeneficiallyOwned"` + PercentOfClass string `json:"percentOfClass"` + TypeOfReportingPerson string `json:"typeOfReportingPerson"` + URL string `json:"url"` } // beneficialOwnershipAcquisitionShadow mirrors BeneficialOwnershipAcquisition // with a pointer or raw value for every required member so a missing or null // member is observable after decoding. type beneficialOwnershipAcquisitionShadow struct { - CIK *string `json:"cik"` - Symbol *string `json:"symbol"` - FilingDate *Date `json:"filingDate"` - AcceptedDate *Date `json:"acceptedDate"` - CUSIP *string `json:"cusip"` - NameOfReportingPerson *string `json:"nameOfReportingPerson"` - CitizenshipOrPlaceOfOrganization *string `json:"citizenshipOrPlaceOfOrganization"` - SoleVotingPower *string `json:"soleVotingPower"` - SharedVotingPower *string `json:"sharedVotingPower"` - SoleDispositivePower *string `json:"soleDispositivePower"` - SharedDispositivePower *string `json:"sharedDispositivePower"` - AmountBeneficiallyOwned *string `json:"amountBeneficiallyOwned"` - PercentOfClass *string `json:"percentOfClass"` - TypeOfReportingPerson *string `json:"typeOfReportingPerson"` - URL *string `json:"url"` + CIK *string `json:"cik"` + Symbol *string `json:"symbol"` + FilingDate *Date `json:"filingDate"` + AcceptedDate *Date `json:"acceptedDate"` + CUSIP jsontext.Value `json:"cusip"` + NameOfReportingPerson *string `json:"nameOfReportingPerson"` + CitizenshipOrPlaceOfOrganization jsontext.Value `json:"citizenshipOrPlaceOfOrganization"` + SoleVotingPower *string `json:"soleVotingPower"` + SharedVotingPower jsontext.Value `json:"sharedVotingPower"` + SoleDispositivePower *string `json:"soleDispositivePower"` + SharedDispositivePower *string `json:"sharedDispositivePower"` + AmountBeneficiallyOwned *string `json:"amountBeneficiallyOwned"` + PercentOfClass *string `json:"percentOfClass"` + TypeOfReportingPerson *string `json:"typeOfReportingPerson"` + URL *string `json:"url"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -345,15 +371,15 @@ func (m *BeneficialOwnershipAcquisition) UnmarshalJSONFrom(dec *jsontext.Decoder return missingMemberError("BeneficialOwnershipAcquisition", "filingDate") case shadow.AcceptedDate == nil: return missingMemberError("BeneficialOwnershipAcquisition", "acceptedDate") - case shadow.CUSIP == nil: + case len(shadow.CUSIP) == 0: return missingMemberError("BeneficialOwnershipAcquisition", "cusip") case shadow.NameOfReportingPerson == nil: return missingMemberError("BeneficialOwnershipAcquisition", "nameOfReportingPerson") - case shadow.CitizenshipOrPlaceOfOrganization == nil: + case len(shadow.CitizenshipOrPlaceOfOrganization) == 0: return missingMemberError("BeneficialOwnershipAcquisition", "citizenshipOrPlaceOfOrganization") case shadow.SoleVotingPower == nil: return missingMemberError("BeneficialOwnershipAcquisition", "soleVotingPower") - case shadow.SharedVotingPower == nil: + case len(shadow.SharedVotingPower) == 0: return missingMemberError("BeneficialOwnershipAcquisition", "sharedVotingPower") case shadow.SoleDispositivePower == nil: return missingMemberError("BeneficialOwnershipAcquisition", "soleDispositivePower") @@ -368,16 +394,40 @@ func (m *BeneficialOwnershipAcquisition) UnmarshalJSONFrom(dec *jsontext.Decoder case shadow.URL == nil: return missingMemberError("BeneficialOwnershipAcquisition", "url") } + var cusip *string + if shadow.CUSIP.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.CUSIP, &value); err != nil { + return err + } + cusip = &value + } + var citizenshipOrPlaceOfOrganization *string + if shadow.CitizenshipOrPlaceOfOrganization.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.CitizenshipOrPlaceOfOrganization, &value); err != nil { + return err + } + citizenshipOrPlaceOfOrganization = &value + } + var sharedVotingPower *string + if shadow.SharedVotingPower.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.SharedVotingPower, &value); err != nil { + return err + } + sharedVotingPower = &value + } *m = BeneficialOwnershipAcquisition{ CIK: *shadow.CIK, Symbol: *shadow.Symbol, FilingDate: *shadow.FilingDate, AcceptedDate: *shadow.AcceptedDate, - CUSIP: *shadow.CUSIP, + CUSIP: cusip, NameOfReportingPerson: *shadow.NameOfReportingPerson, - CitizenshipOrPlaceOfOrganization: *shadow.CitizenshipOrPlaceOfOrganization, + CitizenshipOrPlaceOfOrganization: citizenshipOrPlaceOfOrganization, SoleVotingPower: *shadow.SoleVotingPower, - SharedVotingPower: *shadow.SharedVotingPower, + SharedVotingPower: sharedVotingPower, SoleDispositivePower: *shadow.SoleDispositivePower, SharedDispositivePower: *shadow.SharedDispositivePower, AmountBeneficiallyOwned: *shadow.AmountBeneficiallyOwned, diff --git a/sdk/go/insider_trading_parity_test.go b/sdk/go/insider_trading_parity_test.go index 29df37ea..dc88d04b 100644 --- a/sdk/go/insider_trading_parity_test.go +++ b/sdk/go/insider_trading_parity_test.go @@ -3,14 +3,15 @@ package fmp import ( "encoding/json/v2" "errors" + "reflect" "strings" "testing" ) // The 6 insider_trading fixtures over the 5 response models, decoded through // the shared parity helper (ADR 0030: identical bytes for the Rust and Go -// decoders). Every member is required and non-null, so no fixture carries an -// unknown or omitted member. +// decoders). Every member is required (some may be null), so no fixture +// carries an unknown or omitted member. func TestInsiderTradingFixturesDecodeAndReencodeToTheSameMemberSet(t *testing.T) { t.Parallel() assertFixtureParity[InsiderTrade](t, "latest_insider_trades.json") @@ -27,7 +28,7 @@ func TestDocumentedInsiderTradeRowsDecodeExactValues(t *testing.T) { t.Parallel() latest := assertFixtureParity[InsiderTrade](t, "latest_insider_trades.json") searched := assertFixtureParity[InsiderTrade](t, "searched_insider_trades.json") - if len(latest) != 1 || len(searched) != 1 || latest[0] != searched[0] { + if len(latest) != 1 || len(searched) != 1 || !reflect.DeepEqual(latest[0], searched[0]) { t.Fatalf("latest = %+v, searched = %+v, want one identical row each", latest, searched) } want := InsiderTrade{ @@ -36,19 +37,19 @@ func TestDocumentedInsiderTradeRowsDecodeExactValues(t *testing.T) { TransactionDate: mustParseDate(t, "2026-07-28"), ReportingCIK: "0001661867", CompanyCIK: "0000036146", - TransactionType: "A-Award", - SecuritiesOwned: 62_959, + TransactionType: new("A-Award"), + SecuritiesOwned: new(62_959.0), ReportingName: "Tate Granville Jr", TypeOfOwner: "officer: Secretary", AcquisitionOrDisposition: "A", - DirectOrIndirect: "D", + DirectOrIndirect: new("D"), FormType: "4", SecuritiesTransacted: 1_608, Price: 0, SecurityName: "Common Stock", URL: "https://www.sec.gov/Archives/edgar/data/36146/000003614626000087/0000036146-26-000087-index.htm", } - if latest[0] != want { + if !reflect.DeepEqual(latest[0], want) { t.Fatalf("latest_insider_trades[0] = %+v, want %+v", latest[0], want) } @@ -62,7 +63,7 @@ func TestDocumentedInsiderTradeRowsDecodeExactValues(t *testing.T) { `"price":225,"securityName":"Common Stock","url":"https://example.invalid"}]`), &rows); err != nil { t.Fatal(err) } - if rows[0].SecuritiesOwned != 1500.5 || rows[0].SecuritiesTransacted != -3 || rows[0].Price != 225.0 { + if rows[0].SecuritiesOwned == nil || *rows[0].SecuritiesOwned != 1500.5 || rows[0].SecuritiesTransacted != -3 || rows[0].Price != 225.0 { t.Fatalf("share quantities or integer price were not preserved: %+v", rows[0]) } } @@ -104,13 +105,13 @@ func TestDocumentedInsiderReferenceRowsDecodeExactValues(t *testing.T) { wantOwnership := BeneficialOwnershipAcquisition{ CIK: "0000320193", Symbol: "AAPL", FilingDate: mustParseDate(t, "2026-04-29"), AcceptedDate: mustParseDate(t, "2026-04-29"), - CUSIP: "037833100", NameOfReportingPerson: "Vanguard Capital Management", - CitizenshipOrPlaceOfOrganization: "PENNSYLVANIA", - SoleVotingPower: "0", SharedVotingPower: "0", SoleDispositivePower: "0", SharedDispositivePower: "0", + CUSIP: new("037833100"), NameOfReportingPerson: "Vanguard Capital Management", + CitizenshipOrPlaceOfOrganization: new("PENNSYLVANIA"), + SoleVotingPower: "0", SharedVotingPower: new("0"), SoleDispositivePower: "0", SharedDispositivePower: "0", AmountBeneficiallyOwned: "1099168953", PercentOfClass: "7.48", TypeOfReportingPerson: "IA", URL: "https://www.sec.gov/Archives/edgar/data/320193/000210011926000139/xslSCHEDULE_13G_X02/primary_doc.xml", } - if len(ownership) != 1 || ownership[0] != wantOwnership { + if len(ownership) != 1 || !reflect.DeepEqual(ownership[0], wantOwnership) { t.Fatalf("beneficial_ownership_acquisitions = %+v, want %+v", ownership, wantOwnership) } // The quoted numeric members stay the exact wire text, never scaled or @@ -126,6 +127,48 @@ func TestDocumentedInsiderReferenceRowsDecodeExactValues(t *testing.T) { } } +// Members FMP omits decode to nil and re-encode as null; an empty +// transactionType is the absent code too +// (nullable_trade_members_decode_null_and_empty_type_as_none, +// nullable_ownership_members_decode_null_as_none). +func TestInsiderNullableMembersDecodeNullAndEmptyAsNil(t *testing.T) { + t.Parallel() + trade := strings.NewReplacer(`"transactionType": "A-Award"`, `"transactionType": ""`, + `"securitiesOwned": 62959`, `"securitiesOwned": null`, + `"directOrIndirect": "D"`, `"directOrIndirect": null`).Replace(string(readFixture(t, "latest_insider_trades.json"))) + var trades []InsiderTrade + if err := json.Unmarshal([]byte(trade), &trades); err != nil { + t.Fatal(err) + } + if trades[0].TransactionType != nil || trades[0].SecuritiesOwned != nil || trades[0].DirectOrIndirect != nil { + t.Fatalf("nullable trade members = %+v, want nil", trades[0]) + } + encoded, err := json.Marshal(trades[0]) + if err != nil { + t.Fatal(err) + } + for _, member := range []string{`"transactionType":null`, `"securitiesOwned":null`, `"directOrIndirect":null`} { + if !strings.Contains(string(encoded), member) { + t.Fatalf("InsiderTrade re-encoded = %s, want it to contain %s", encoded, member) + } + } + trade = strings.Replace(trade, `"transactionType": ""`, `"transactionType": null`, 1) + if err := json.Unmarshal([]byte(trade), &trades); err != nil || trades[0].TransactionType != nil { + t.Fatalf("null transactionType = %+v, %v, want nil", trades, err) + } + + ownership := strings.NewReplacer(`"cusip": "037833100"`, `"cusip": null`, + `"citizenshipOrPlaceOfOrganization": "PENNSYLVANIA"`, `"citizenshipOrPlaceOfOrganization": null`, + `"sharedVotingPower": "0"`, `"sharedVotingPower": null`).Replace(string(readFixture(t, "beneficial_ownership_acquisitions.json"))) + var rows []BeneficialOwnershipAcquisition + if err := json.Unmarshal([]byte(ownership), &rows); err != nil { + t.Fatal(err) + } + if rows[0].CUSIP != nil || rows[0].CitizenshipOrPlaceOfOrganization != nil || rows[0].SharedVotingPower != nil { + t.Fatalf("nullable ownership members = %+v, want nil", rows[0]) + } +} + // Every contract is a bare array that preserves empty and multiple rows and // rejects an object envelope // (all_six_contracts_are_bare_arrays_preserving_empty_and_multiple_rows). @@ -134,7 +177,7 @@ func TestInsiderTradingContractsAreBareArrays(t *testing.T) { row := strings.TrimSpace(string(readFixture(t, "latest_insider_trades.json"))) row = strings.TrimSuffix(strings.TrimPrefix(row, "["), "]") var two []InsiderTrade - if err := json.Unmarshal([]byte("["+row+","+row+"]"), &two); err != nil || len(two) != 2 || two[0] != two[1] { + if err := json.Unmarshal([]byte("["+row+","+row+"]"), &two); err != nil || len(two) != 2 || !reflect.DeepEqual(two[0], two[1]) { t.Fatalf("two-row array = %+v, %v", two, err) } var empty []InsiderTrade diff --git a/sdk/go/insider_trading_test.go b/sdk/go/insider_trading_test.go index 1df0fbbf..6380c706 100644 --- a/sdk/go/insider_trading_test.go +++ b/sdk/go/insider_trading_test.go @@ -81,7 +81,7 @@ func TestInsiderTradingMethodsUseExactPathsAndWireParameterOrder(t *testing.T) { t.Fatalf("TradeStatistics = %+v, %v", statistics, err) } ownership, err := client.InsiderTrading.BeneficialOwnershipAcquisitions(ctx, NewBeneficialOwnershipAcquisitionsQuery("AAPL")) - if err != nil || len(ownership) != 1 || ownership[0].CUSIP != "037833100" { + if err != nil || len(ownership) != 1 || ownership[0].CUSIP == nil || *ownership[0].CUSIP != "037833100" { t.Fatalf("BeneficialOwnershipAcquisitions = %+v, %v", ownership, err) } limited, err := client.InsiderTrading.BeneficialOwnershipAcquisitions(ctx, diff --git a/sdk/go/institutional_ownership_models.go b/sdk/go/institutional_ownership_models.go index 85bec869..94a678b2 100644 --- a/sdk/go/institutional_ownership_models.go +++ b/sdk/go/institutional_ownership_models.go @@ -83,7 +83,7 @@ type InstitutionalHolding struct { AcceptedDate Date `json:"acceptedDate"` CIK string `json:"cik"` SecurityCUSIP string `json:"securityCusip"` - Symbol string `json:"symbol"` + Symbol *string `json:"symbol"` NameOfIssuer string `json:"nameOfIssuer"` Shares float64 `json:"shares"` TitleOfClass string `json:"titleOfClass"` @@ -98,20 +98,20 @@ type InstitutionalHolding struct { // raw value for every required member so a missing or null member is // observable after decoding. type institutionalHoldingShadow struct { - Date *Date `json:"date"` - FilingDate *Date `json:"filingDate"` - AcceptedDate *Date `json:"acceptedDate"` - CIK *string `json:"cik"` - SecurityCUSIP *string `json:"securityCusip"` - Symbol *string `json:"symbol"` - NameOfIssuer *string `json:"nameOfIssuer"` - Shares *float64 `json:"shares"` - TitleOfClass *string `json:"titleOfClass"` - SharesType *string `json:"sharesType"` - PutCallShare *string `json:"putCallShare"` - Value *float64 `json:"value"` - Link *string `json:"link"` - FinalLink *string `json:"finalLink"` + Date *Date `json:"date"` + FilingDate *Date `json:"filingDate"` + AcceptedDate *Date `json:"acceptedDate"` + CIK *string `json:"cik"` + SecurityCUSIP *string `json:"securityCusip"` + Symbol jsontext.Value `json:"symbol"` + NameOfIssuer *string `json:"nameOfIssuer"` + Shares *float64 `json:"shares"` + TitleOfClass *string `json:"titleOfClass"` + SharesType *string `json:"sharesType"` + PutCallShare *string `json:"putCallShare"` + Value *float64 `json:"value"` + Link *string `json:"link"` + FinalLink *string `json:"finalLink"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -133,7 +133,7 @@ func (m *InstitutionalHolding) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("InstitutionalHolding", "cik") case shadow.SecurityCUSIP == nil: return missingMemberError("InstitutionalHolding", "securityCusip") - case shadow.Symbol == nil: + case len(shadow.Symbol) == 0: return missingMemberError("InstitutionalHolding", "symbol") case shadow.NameOfIssuer == nil: return missingMemberError("InstitutionalHolding", "nameOfIssuer") @@ -152,13 +152,21 @@ func (m *InstitutionalHolding) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.FinalLink == nil: return missingMemberError("InstitutionalHolding", "finalLink") } + var symbol *string + if shadow.Symbol.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Symbol, &value); err != nil { + return err + } + symbol = &value + } *m = InstitutionalHolding{ Date: *shadow.Date, FilingDate: *shadow.FilingDate, AcceptedDate: *shadow.AcceptedDate, CIK: *shadow.CIK, SecurityCUSIP: *shadow.SecurityCUSIP, - Symbol: *shadow.Symbol, + Symbol: symbol, NameOfIssuer: *shadow.NameOfIssuer, Shares: *shadow.Shares, TitleOfClass: *shadow.TitleOfClass, @@ -627,36 +635,36 @@ func (m *HolderPerformanceSummary) UnmarshalJSONFrom(dec *jsontext.Decoder) erro // HolderIndustryBreakdown is one industry allocation and performance row for // an institutional holder. type HolderIndustryBreakdown struct { - Date Date `json:"date"` - CIK string `json:"cik"` - InvestorName string `json:"investorName"` - IndustryTitle string `json:"industryTitle"` - Weight float64 `json:"weight"` - LastWeight float64 `json:"lastWeight"` - ChangeInWeight float64 `json:"changeInWeight"` - ChangeInWeightPercentage float64 `json:"changeInWeightPercentage"` - Performance float64 `json:"performance"` - PerformancePercentage float64 `json:"performancePercentage"` - LastPerformance float64 `json:"lastPerformance"` - ChangeInPerformance float64 `json:"changeInPerformance"` + Date Date `json:"date"` + CIK string `json:"cik"` + InvestorName string `json:"investorName"` + IndustryTitle *string `json:"industryTitle"` + Weight float64 `json:"weight"` + LastWeight float64 `json:"lastWeight"` + ChangeInWeight float64 `json:"changeInWeight"` + ChangeInWeightPercentage *float64 `json:"changeInWeightPercentage"` + Performance float64 `json:"performance"` + PerformancePercentage *float64 `json:"performancePercentage"` + LastPerformance float64 `json:"lastPerformance"` + ChangeInPerformance float64 `json:"changeInPerformance"` } // holderIndustryBreakdownShadow mirrors HolderIndustryBreakdown with a pointer // or raw value for every required member so a missing or null member is // observable after decoding. type holderIndustryBreakdownShadow struct { - Date *Date `json:"date"` - CIK *string `json:"cik"` - InvestorName *string `json:"investorName"` - IndustryTitle *string `json:"industryTitle"` - Weight *float64 `json:"weight"` - LastWeight *float64 `json:"lastWeight"` - ChangeInWeight *float64 `json:"changeInWeight"` - ChangeInWeightPercentage *float64 `json:"changeInWeightPercentage"` - Performance *float64 `json:"performance"` - PerformancePercentage *float64 `json:"performancePercentage"` - LastPerformance *float64 `json:"lastPerformance"` - ChangeInPerformance *float64 `json:"changeInPerformance"` + Date *Date `json:"date"` + CIK *string `json:"cik"` + InvestorName *string `json:"investorName"` + IndustryTitle jsontext.Value `json:"industryTitle"` + Weight *float64 `json:"weight"` + LastWeight *float64 `json:"lastWeight"` + ChangeInWeight *float64 `json:"changeInWeight"` + ChangeInWeightPercentage jsontext.Value `json:"changeInWeightPercentage"` + Performance *float64 `json:"performance"` + PerformancePercentage jsontext.Value `json:"performancePercentage"` + LastPerformance *float64 `json:"lastPerformance"` + ChangeInPerformance *float64 `json:"changeInPerformance"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -674,7 +682,7 @@ func (m *HolderIndustryBreakdown) UnmarshalJSONFrom(dec *jsontext.Decoder) error return missingMemberError("HolderIndustryBreakdown", "cik") case shadow.InvestorName == nil: return missingMemberError("HolderIndustryBreakdown", "investorName") - case shadow.IndustryTitle == nil: + case len(shadow.IndustryTitle) == 0: return missingMemberError("HolderIndustryBreakdown", "industryTitle") case shadow.Weight == nil: return missingMemberError("HolderIndustryBreakdown", "weight") @@ -682,28 +690,52 @@ func (m *HolderIndustryBreakdown) UnmarshalJSONFrom(dec *jsontext.Decoder) error return missingMemberError("HolderIndustryBreakdown", "lastWeight") case shadow.ChangeInWeight == nil: return missingMemberError("HolderIndustryBreakdown", "changeInWeight") - case shadow.ChangeInWeightPercentage == nil: + case len(shadow.ChangeInWeightPercentage) == 0: return missingMemberError("HolderIndustryBreakdown", "changeInWeightPercentage") case shadow.Performance == nil: return missingMemberError("HolderIndustryBreakdown", "performance") - case shadow.PerformancePercentage == nil: + case len(shadow.PerformancePercentage) == 0: return missingMemberError("HolderIndustryBreakdown", "performancePercentage") case shadow.LastPerformance == nil: return missingMemberError("HolderIndustryBreakdown", "lastPerformance") case shadow.ChangeInPerformance == nil: return missingMemberError("HolderIndustryBreakdown", "changeInPerformance") } + var industryTitle *string + if shadow.IndustryTitle.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.IndustryTitle, &value); err != nil { + return err + } + industryTitle = &value + } + var changeInWeightPercentage *float64 + if shadow.ChangeInWeightPercentage.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.ChangeInWeightPercentage, &value); err != nil { + return err + } + changeInWeightPercentage = &value + } + var performancePercentage *float64 + if shadow.PerformancePercentage.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.PerformancePercentage, &value); err != nil { + return err + } + performancePercentage = &value + } *m = HolderIndustryBreakdown{ Date: *shadow.Date, CIK: *shadow.CIK, InvestorName: *shadow.InvestorName, - IndustryTitle: *shadow.IndustryTitle, + IndustryTitle: industryTitle, Weight: *shadow.Weight, LastWeight: *shadow.LastWeight, ChangeInWeight: *shadow.ChangeInWeight, - ChangeInWeightPercentage: *shadow.ChangeInWeightPercentage, + ChangeInWeightPercentage: changeInWeightPercentage, Performance: *shadow.Performance, - PerformancePercentage: *shadow.PerformancePercentage, + PerformancePercentage: performancePercentage, LastPerformance: *shadow.LastPerformance, ChangeInPerformance: *shadow.ChangeInPerformance, } diff --git a/sdk/go/institutional_ownership_parity_test.go b/sdk/go/institutional_ownership_parity_test.go index 02712115..01b45001 100644 --- a/sdk/go/institutional_ownership_parity_test.go +++ b/sdk/go/institutional_ownership_parity_test.go @@ -5,13 +5,14 @@ import ( "encoding/json/v2" "errors" "math" + "reflect" "strings" "testing" ) // The eight institutional_ownership fixtures, one per response model. Every -// member is required and non-null in the Rust models, so no fixture carries -// an intentionally unknown member. +// member is required (some may be null) in the Rust models, so no fixture +// carries an intentionally unknown member. func TestInstitutionalOwnershipFixturesDecodeAndReencodeToTheSameMemberSet(t *testing.T) { t.Parallel() assertFixtureParity[InstitutionalOwnershipFiling](t, "latest_institutional_ownership_filings.json") @@ -45,13 +46,13 @@ func TestDocumentedFilingExtractAndDatesFixturesDecodeExactly(t *testing.T) { holdings := assertFixtureParity[InstitutionalHolding](t, "institutional_ownership_extract.json") wantHolding := InstitutionalHolding{ Date: mustParseDate(t, "2023-09-30"), FilingDate: mustParseDate(t, "2023-11-13"), - AcceptedDate: mustParseDate(t, "2023-11-13"), CIK: "0001388838", SecurityCUSIP: "674215207", Symbol: "CHRD", + AcceptedDate: mustParseDate(t, "2023-11-13"), CIK: "0001388838", SecurityCUSIP: "674215207", Symbol: new("CHRD"), NameOfIssuer: "CHORD ENERGY CORPORATION", Shares: 13_280, TitleOfClass: "COM NEW", SharesType: "SH", PutCallShare: "", Value: 2_152_290, Link: "https://www.sec.gov/Archives/edgar/data/1388838/000117266123003760/0001172661-23-003760-index.htm", FinalLink: "https://www.sec.gov/Archives/edgar/data/1388838/000117266123003760/infotable.xml", } - if len(holdings) != 1 || holdings[0] != wantHolding { + if len(holdings) != 1 || !reflect.DeepEqual(holdings[0], wantHolding) { t.Fatalf("institutional_ownership_extract = %+v, want %+v", holdings, wantHolding) } if got := memberSet(t, holdings[0]); len(got) != 14 { @@ -98,15 +99,42 @@ func TestDocumentedHolderFixturesDecodeAnalyticsPerformanceAndIndustryRows(t *te industry := assertFixtureParity[HolderIndustryBreakdown](t, "holder_industry_breakdown.json") want := HolderIndustryBreakdown{ Date: mustParseDate(t, "2023-09-30"), CIK: "0001067983", InvestorName: "BERKSHIRE HATHAWAY INC", - IndustryTitle: "ELECTRONIC COMPUTERS", Weight: 49.7704, LastWeight: 51.0035, ChangeInWeight: -1.2332, - ChangeInWeightPercentage: -2.4178, Performance: -20_838_154_294, PerformancePercentage: -178.2938, + IndustryTitle: new("ELECTRONIC COMPUTERS"), Weight: 49.7704, LastWeight: 51.0035, ChangeInWeight: -1.2332, + ChangeInWeightPercentage: new(-2.4178), Performance: -20_838_154_294, PerformancePercentage: new(-178.2938), LastPerformance: 26_615_340_304, ChangeInPerformance: -47_453_494_598, } - if len(industry) != 1 || industry[0] != want { + if len(industry) != 1 || !reflect.DeepEqual(industry[0], want) { t.Fatalf("holder_industry_breakdown = %+v, want %+v", industry, want) } } +// Members FMP omits decode to nil; an empty industryTitle stays "" +// (nullable_industry_members_decode_null_as_none_and_keep_empty_titles). +func TestInstitutionalNullableMembersDecodeNullAsNil(t *testing.T) { + t.Parallel() + holding := strings.Replace(string(readFixture(t, "institutional_ownership_extract.json")), + `"symbol": "CHRD"`, `"symbol": null`, 1) + var holdings []InstitutionalHolding + if err := json.Unmarshal([]byte(holding), &holdings); err != nil || holdings[0].Symbol != nil { + t.Fatalf("null symbol = %+v, %v, want nil", holdings, err) + } + + industry := strings.NewReplacer(`"industryTitle": "ELECTRONIC COMPUTERS"`, `"industryTitle": null`, + `"changeInWeightPercentage": -2.4178`, `"changeInWeightPercentage": null`, + `"performancePercentage": -178.2938`, `"performancePercentage": null`).Replace(string(readFixture(t, "holder_industry_breakdown.json"))) + var rows []HolderIndustryBreakdown + if err := json.Unmarshal([]byte(industry), &rows); err != nil { + t.Fatal(err) + } + if rows[0].IndustryTitle != nil || rows[0].ChangeInWeightPercentage != nil || rows[0].PerformancePercentage != nil { + t.Fatalf("nullable industry members = %+v, want nil", rows[0]) + } + industry = strings.Replace(industry, `"industryTitle": null`, `"industryTitle": ""`, 1) + if err := json.Unmarshal([]byte(industry), &rows); err != nil || rows[0].IndustryTitle == nil || *rows[0].IndustryTitle != "" { + t.Fatalf("empty industryTitle = %+v, %v, want an empty title", rows, err) + } +} + // Spot values copied from crates/libfmp/tests/institutional_position_industry_summaries_responses.rs. func TestDocumentedPositionAndIndustrySummaryFixturesDecodeExactly(t *testing.T) { t.Parallel() diff --git a/sdk/go/institutional_ownership_test.go b/sdk/go/institutional_ownership_test.go index 077bbb34..5a2e1636 100644 --- a/sdk/go/institutional_ownership_test.go +++ b/sdk/go/institutional_ownership_test.go @@ -72,7 +72,7 @@ func TestInstitutionalOwnershipMethodsUseExactPathsAndWireOrder(t *testing.T) { t.Fatalf("HolderPerformanceSummary = %+v, %v", performance, err) } breakdown, err := ns.HolderIndustryBreakdown(ctx, NewHolderIndustryBreakdownQuery("0001067983", 2023, QuarterQ3)) - if err != nil || len(breakdown) != 1 || breakdown[0].IndustryTitle != "ELECTRONIC COMPUTERS" { + if err != nil || len(breakdown) != 1 || breakdown[0].IndustryTitle == nil || *breakdown[0].IndustryTitle != "ELECTRONIC COMPUTERS" { t.Fatalf("HolderIndustryBreakdown = %+v, %v", breakdown, err) } positions, err := ns.PositionsSummary(ctx, NewInstitutionalPositionsSummaryQuery("AAPL", 2023, QuarterQ3)) diff --git a/sdk/go/integral_f64_parity_test.go b/sdk/go/integral_f64_parity_test.go index 9164901d..020b1126 100644 --- a/sdk/go/integral_f64_parity_test.go +++ b/sdk/go/integral_f64_parity_test.go @@ -55,7 +55,7 @@ func TestQuantityAndAmountFieldsDecodeFractionalForms(t *testing.T) { t.Parallel() insider := assertFixtureParity[InsiderTrade](t, "latest_insider_trades_fractional_synthetic.json") - if len(insider) != 1 || insider[0].SecuritiesOwned != 62_959.5 || insider[0].SecuritiesTransacted != 1 { + if len(insider) != 1 || insider[0].SecuritiesOwned == nil || *insider[0].SecuritiesOwned != 62_959.5 || insider[0].SecuritiesTransacted != 1 { t.Fatalf("latest_insider_trades_fractional_synthetic = %+v", insider) }