From 5de48d0bb39ab773bce2117ae08e69c6802bb5e3 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 20:28:07 +0800 Subject: [PATCH] feat(company): make provider-omittable company members optional This commit relaxes company response models so members FMP can omit or send as null decode to None instead of failing, keeping the rest of each model strictly required. **Model changes:** * In `libfmp`, wraps the optional members in `Option`, adds a `required_option` deserializer for null-tolerant fields, and decodes an empty or null `ipoDate` via `empty_or_null_date`; nullable market-cap, float-shares, and option-award now serialize through the `serialize_option` codecs. * In the Python bindings, updates both the Rust structs and the generated `.pyi` stubs so the affected getters and constructor arguments expose `Optional`, mapping inner values through on conversion. * In the Go SDK, switches the affected fields to pointers, decodes null members to nil through `jsontext.Value` shadows while still rejecting missing required keys, and reports the first missing member as before. **Tests and fixtures:** * Extends the Rust, Python, and Go suites to assert None/nil decoding, null wire round-trips, and that a missing key still fails; updates fixtures to carry null and empty values for the newly optional members. BREAKING CHANGE: CompanyProfile, CompanyExecutive, CompanyMarketCapitalization, CompanyNote, CompanyShareFloat, ShareFloat, ExecutiveCompensation and MergerAcquisition members FMP can omit are now optional (Option / pointer / Optional); handle None. --- .../python/fmp/_native/company/__init__.pyi | 60 +-- crates/fmp-py/src/models/company.rs | 70 ++-- crates/fmp-py/tests/test_company_a.py | 6 +- crates/fmp-py/tests/test_company_b.py | 4 + crates/fmp-py/tests/typecheck_contract.py | 6 +- crates/libfmp/src/responses/company.rs | 60 ++- .../libfmp/tests/bulk_snapshot_responses.rs | 19 +- crates/libfmp/tests/company_endpoints.rs | 6 +- .../tests/company_governance_responses.rs | 8 +- .../tests/company_market_data_responses.rs | 35 +- .../company_mergers_acquisitions_endpoints.rs | 7 +- .../company_mergers_acquisitions_responses.rs | 19 +- crates/libfmp/tests/company_responses.rs | 40 +- .../company_executive_compensation_large.json | 2 +- .../company_key_executives_dynamic.json | 2 +- ...company_mergers_acquisitions_multiple.json | 4 +- .../fixtures/company_profile_multiple.json | 10 +- sdk/go/bulk_parity_test.go | 2 +- sdk/go/company_models.go | 360 ++++++++++++------ sdk/go/company_parity_test.go | 83 ++-- sdk/go/company_test.go | 8 +- 21 files changed, 522 insertions(+), 289 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/company/__init__.pyi b/crates/fmp-py/python/fmp/_native/company/__init__.pyi index 16ed792d..9315aafa 100644 --- a/crates/fmp-py/python/fmp/_native/company/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/company/__init__.pyi @@ -42,7 +42,7 @@ class CompanyExecutive: @property def currency_pay(self) -> builtins.str: ... @property - def gender(self) -> builtins.str: ... + def gender(self) -> typing.Optional[builtins.str]: ... @property def active(self) -> builtins.bool: ... @property @@ -59,7 +59,7 @@ class CompanyExecutive: name: builtins.str, pay: typing.Optional[builtins.str], currency_pay: builtins.str, - gender: builtins.str, + gender: typing.Optional[builtins.str], year_born: typing.Optional[builtins.str], title_since: typing.Optional[builtins.str], active: builtins.bool, @@ -85,10 +85,14 @@ class CompanyMarketCapitalization: @property def date(self) -> datetime.date: ... @property - def market_cap(self) -> builtins.float: ... + def market_cap(self) -> typing.Optional[builtins.float]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( - cls, *, symbol: builtins.str, date: datetime.date, market_cap: builtins.float + cls, + *, + symbol: builtins.str, + date: datetime.date, + market_cap: typing.Optional[builtins.float], ) -> CompanyMarketCapitalization: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... @@ -244,7 +248,7 @@ class CompanyNote: @property def title(self) -> builtins.str: ... @property - def exchange(self) -> builtins.str: ... + def exchange(self) -> typing.Optional[builtins.str]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, @@ -252,7 +256,7 @@ class CompanyNote: cik: builtins.str, symbol: builtins.str, title: builtins.str, - exchange: builtins.str, + exchange: typing.Optional[builtins.str], ) -> CompanyNote: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... @@ -330,11 +334,11 @@ class CompanyProfile: @property def currency(self) -> builtins.str: ... @property - def cik(self) -> builtins.str: ... + def cik(self) -> typing.Optional[builtins.str]: ... @property def isin(self) -> builtins.str: ... @property - def cusip(self) -> builtins.str: ... + def cusip(self) -> typing.Optional[builtins.str]: ... @property def exchange_full_name(self) -> builtins.str: ... @property @@ -352,9 +356,9 @@ class CompanyProfile: @property def country(self) -> builtins.str: ... @property - def full_time_employees(self) -> builtins.str: ... + def full_time_employees(self) -> typing.Optional[builtins.str]: ... @property - def phone(self) -> builtins.str: ... + def phone(self) -> typing.Optional[builtins.str]: ... @property def address(self) -> builtins.str: ... @property @@ -366,7 +370,7 @@ class CompanyProfile: @property def image(self) -> builtins.str: ... @property - def ipo_date(self) -> datetime.date: ... + def ipo_date(self) -> typing.Optional[datetime.date]: ... @property def default_image(self) -> builtins.bool: ... @property @@ -393,9 +397,9 @@ class CompanyProfile: average_volume: builtins.float, company_name: builtins.str, currency: builtins.str, - cik: builtins.str, + cik: typing.Optional[builtins.str], isin: builtins.str, - cusip: builtins.str, + cusip: typing.Optional[builtins.str], exchange_full_name: builtins.str, exchange: builtins.str, industry: builtins.str, @@ -404,14 +408,14 @@ class CompanyProfile: ceo: builtins.str, sector: builtins.str, country: builtins.str, - full_time_employees: builtins.str, - phone: builtins.str, + full_time_employees: typing.Optional[builtins.str], + phone: typing.Optional[builtins.str], address: builtins.str, city: builtins.str, state: builtins.str, zip: builtins.str, image: builtins.str, - ipo_date: datetime.date, + ipo_date: typing.Optional[datetime.date], default_image: builtins.bool, is_etf: builtins.bool, is_actively_trading: builtins.bool, @@ -450,7 +454,7 @@ class CompanyShareFloat: @property def outstanding_shares(self) -> builtins.float: ... @property - def source(self) -> builtins.str: ... + def source(self) -> typing.Optional[builtins.str]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, @@ -460,7 +464,7 @@ class CompanyShareFloat: free_float: builtins.float, float_shares: builtins.float, outstanding_shares: builtins.float, - source: builtins.str, + source: typing.Optional[builtins.str], ) -> CompanyShareFloat: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... @@ -609,7 +613,7 @@ class ExecutiveCompensation: @property def stock_award(self) -> builtins.float: ... @property - def option_award(self) -> builtins.float: ... + def option_award(self) -> typing.Optional[builtins.float]: ... @property def incentive_plan_compensation(self) -> builtins.float: ... @property @@ -632,7 +636,7 @@ class ExecutiveCompensation: salary: builtins.float, bonus: builtins.float, stock_award: builtins.float, - option_award: builtins.float, + option_award: typing.Optional[builtins.float], incentive_plan_compensation: builtins.float, all_other_compensation: builtins.float, total: builtins.float, @@ -703,9 +707,9 @@ class MergerAcquisition: @property def targeted_company_name(self) -> builtins.str: ... @property - def targeted_cik(self) -> builtins.str: ... + def targeted_cik(self) -> typing.Optional[builtins.str]: ... @property - def targeted_symbol(self) -> builtins.str: ... + def targeted_symbol(self) -> typing.Optional[builtins.str]: ... @property def transaction_date(self) -> datetime.date: ... @property @@ -720,8 +724,8 @@ class MergerAcquisition: company_name: builtins.str, cik: builtins.str, targeted_company_name: builtins.str, - targeted_cik: builtins.str, - targeted_symbol: builtins.str, + targeted_cik: typing.Optional[builtins.str], + targeted_symbol: typing.Optional[builtins.str], transaction_date: datetime.date, accepted_date: datetime.datetime, link: builtins.str, @@ -751,9 +755,9 @@ class ShareFloat: @property def date(self) -> datetime.datetime: ... @property - def free_float(self) -> builtins.float: ... + def free_float(self) -> typing.Optional[builtins.float]: ... @property - def float_shares(self) -> builtins.float: ... + def float_shares(self) -> typing.Optional[builtins.float]: ... @property def outstanding_shares(self) -> builtins.float: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... @@ -762,8 +766,8 @@ class ShareFloat: *, symbol: builtins.str, date: datetime.datetime, - free_float: builtins.float, - float_shares: builtins.float, + free_float: typing.Optional[builtins.float], + float_shares: typing.Optional[builtins.float], outstanding_shares: builtins.float, ) -> ShareFloat: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... diff --git a/crates/fmp-py/src/models/company.rs b/crates/fmp-py/src/models/company.rs index e7d019cc..00397929 100644 --- a/crates/fmp-py/src/models/company.rs +++ b/crates/fmp-py/src/models/company.rs @@ -34,11 +34,11 @@ pub(crate) struct CompanyProfile { #[pyo3(get)] pub currency: String, #[pyo3(get)] - pub cik: String, + pub cik: Option, #[pyo3(get)] pub isin: String, #[pyo3(get)] - pub cusip: String, + pub cusip: Option, #[pyo3(get)] pub exchange_full_name: String, #[pyo3(get)] @@ -56,9 +56,9 @@ pub(crate) struct CompanyProfile { #[pyo3(get)] pub country: String, #[pyo3(get)] - pub full_time_employees: String, + pub full_time_employees: Option, #[pyo3(get)] - pub phone: String, + pub phone: Option, #[pyo3(get)] pub address: String, #[pyo3(get)] @@ -70,7 +70,7 @@ pub(crate) struct CompanyProfile { #[pyo3(get)] pub image: String, #[pyo3(get)] - pub ipo_date: ::chrono::NaiveDate, + pub ipo_date: Option<::chrono::NaiveDate>, #[pyo3(get)] pub default_image: bool, #[pyo3(get)] @@ -103,9 +103,9 @@ impl CompanyProfile { average_volume: f64, company_name: String, currency: String, - cik: String, + cik: Option, isin: String, - cusip: String, + cusip: Option, exchange_full_name: String, exchange: String, industry: String, @@ -114,14 +114,14 @@ impl CompanyProfile { ceo: String, sector: String, country: String, - full_time_employees: String, - phone: String, + full_time_employees: Option, + phone: Option, address: String, city: String, state: String, zip: String, image: String, - ipo_date: ::chrono::NaiveDate, + ipo_date: Option<::chrono::NaiveDate>, default_image: bool, is_etf: bool, is_actively_trading: bool, @@ -528,9 +528,9 @@ impl From for CompanyProfile { average_volume: value.average_volume, company_name: value.company_name, currency: value.currency.into_inner(), - cik: value.cik.into_inner(), + cik: value.cik.map(|value| value.into_inner()), isin: value.isin.into_inner(), - cusip: value.cusip.into_inner(), + cusip: value.cusip.map(|value| value.into_inner()), exchange_full_name: value.exchange_full_name, exchange: value.exchange.into_inner(), industry: value.industry.into_inner(), @@ -539,14 +539,14 @@ impl From for CompanyProfile { ceo: value.ceo, sector: value.sector.into_inner(), country: value.country.into_inner(), - full_time_employees: value.full_time_employees.into_inner(), + full_time_employees: value.full_time_employees.map(|value| value.into_inner()), phone: value.phone, address: value.address, city: value.city, state: value.state, zip: value.zip, image: value.image, - ipo_date: value.ipo_date.into_inner(), + ipo_date: value.ipo_date.map(|value| value.into_inner()), default_image: value.default_image, is_etf: value.is_etf, is_actively_trading: value.is_actively_trading, @@ -573,7 +573,7 @@ pub(crate) struct CompanyNote { #[pyo3(get)] pub title: String, #[pyo3(get)] - pub exchange: String, + pub exchange: Option, } #[gen_stub_pymethods] @@ -583,7 +583,7 @@ impl CompanyNote { #[allow(clippy::too_many_arguments)] #[allow(clippy::fn_params_excessive_bools)] #[pyo3(signature = (*, cik, symbol, title, exchange))] - fn new(cik: String, symbol: String, title: String, exchange: String) -> Self { + fn new(cik: String, symbol: String, title: String, exchange: Option) -> Self { Self { cik, symbol, @@ -657,7 +657,7 @@ impl From for CompanyNote { cik: value.cik.into_inner(), symbol: value.symbol.into_inner(), title: value.title, - exchange: value.exchange.into_inner(), + exchange: value.exchange.map(|value| value.into_inner()), } } } @@ -1141,7 +1141,7 @@ pub(crate) struct CompanyMarketCapitalization { #[pyo3(get)] pub date: ::chrono::NaiveDate, #[pyo3(get)] - pub market_cap: f64, + pub market_cap: Option, } #[gen_stub_pymethods] @@ -1151,7 +1151,7 @@ impl CompanyMarketCapitalization { #[allow(clippy::too_many_arguments)] #[allow(clippy::fn_params_excessive_bools)] #[pyo3(signature = (*, symbol, date, market_cap))] - fn new(symbol: String, date: ::chrono::NaiveDate, market_cap: f64) -> Self { + fn new(symbol: String, date: ::chrono::NaiveDate, market_cap: Option) -> Self { Self { symbol, date, @@ -1243,7 +1243,7 @@ pub(crate) struct CompanyShareFloat { #[pyo3(get)] pub outstanding_shares: f64, #[pyo3(get)] - pub source: String, + pub source: Option, } #[gen_stub_pymethods] @@ -1259,7 +1259,7 @@ impl CompanyShareFloat { free_float: f64, float_shares: f64, outstanding_shares: f64, - source: String, + source: Option, ) -> Self { Self { symbol, @@ -1392,9 +1392,9 @@ pub(crate) struct ShareFloat { #[pyo3(get)] pub date: ::chrono::NaiveDateTime, #[pyo3(get)] - pub free_float: f64, + pub free_float: Option, #[pyo3(get)] - pub float_shares: f64, + pub float_shares: Option, #[pyo3(get)] pub outstanding_shares: f64, } @@ -1409,8 +1409,8 @@ impl ShareFloat { fn new( symbol: String, date: ::chrono::NaiveDateTime, - free_float: f64, - float_shares: f64, + free_float: Option, + float_shares: Option, outstanding_shares: f64, ) -> Self { Self { @@ -1539,9 +1539,9 @@ pub(crate) struct MergerAcquisition { #[pyo3(get)] pub targeted_company_name: String, #[pyo3(get)] - pub targeted_cik: String, + pub targeted_cik: Option, #[pyo3(get)] - pub targeted_symbol: String, + pub targeted_symbol: Option, #[pyo3(get)] pub transaction_date: ::chrono::NaiveDate, #[pyo3(get)] @@ -1562,8 +1562,8 @@ impl MergerAcquisition { company_name: String, cik: String, targeted_company_name: String, - targeted_cik: String, - targeted_symbol: String, + targeted_cik: Option, + targeted_symbol: Option, transaction_date: ::chrono::NaiveDate, accepted_date: ::chrono::NaiveDateTime, link: String, @@ -1726,8 +1726,8 @@ impl From for MergerAcquisition { company_name: value.company_name, cik: value.cik.into_inner(), targeted_company_name: value.targeted_company_name, - targeted_cik: value.targeted_cik.into_inner(), - targeted_symbol: value.targeted_symbol.into_inner(), + targeted_cik: value.targeted_cik.map(|value| value.into_inner()), + targeted_symbol: value.targeted_symbol.map(|value| value.into_inner()), transaction_date: value.transaction_date.into_inner(), accepted_date: value.accepted_date.into_inner(), link: value.link, @@ -1753,7 +1753,7 @@ pub(crate) struct CompanyExecutive { #[pyo3(get)] pub currency_pay: String, #[pyo3(get)] - pub gender: String, + pub gender: Option, year_born: Option<::serde_json::Value>, title_since: Option<::serde_json::Value>, #[pyo3(get)] @@ -1772,7 +1772,7 @@ impl CompanyExecutive { name: String, pay: Option, currency_pay: String, - gender: String, + gender: Option, year_born: Option, title_since: Option, active: bool, @@ -2017,7 +2017,7 @@ pub(crate) struct ExecutiveCompensation { #[pyo3(get)] pub stock_award: f64, #[pyo3(get)] - pub option_award: f64, + pub option_award: Option, #[pyo3(get)] pub incentive_plan_compensation: f64, #[pyo3(get)] @@ -2046,7 +2046,7 @@ impl ExecutiveCompensation { salary: f64, bonus: f64, stock_award: f64, - option_award: f64, + option_award: Option, incentive_plan_compensation: f64, all_other_compensation: f64, total: f64, diff --git a/crates/fmp-py/tests/test_company_a.py b/crates/fmp-py/tests/test_company_a.py index 1b110502..9eab9ce1 100644 --- a/crates/fmp-py/tests/test_company_a.py +++ b/crates/fmp-py/tests/test_company_a.py @@ -67,7 +67,11 @@ def test_profile_decodes_multiple_rows_with_large_integers(client: Any, fixture_ assert rows[0].volume == float(18_446_744_073_709_551_615) assert isinstance(rows[0].market_cap, float) assert rows[0].market_cap == float(9_007_199_254_740_993) - assert rows[0].ipo_date == datetime.date(2026, 8, 27) + assert rows[0].ipo_date is None + assert rows[0].cik is None + assert rows[0].cusip is None + assert rows[0].full_time_employees is None + assert rows[0].phone is None assert rows[0].is_fund is True assert rows[1].ipo_date == datetime.date(1980, 12, 12) diff --git a/crates/fmp-py/tests/test_company_b.py b/crates/fmp-py/tests/test_company_b.py index 24d804aa..d34db8c3 100644 --- a/crates/fmp-py/tests/test_company_b.py +++ b/crates/fmp-py/tests/test_company_b.py @@ -101,6 +101,8 @@ def test_latest_mergers_acquisitions_with_page_only_decodes_multiple_rows( assert len(rows) == 2 assert [row.symbol for row in rows] == ["AGH", "PEGY"] assert rows[1].transaction_date == datetime.date(2021, 11, 12) + assert rows[1].targeted_cik is None + assert rows[1].targeted_symbol is None def test_search_mergers_acquisitions_preserves_the_name_verbatim(client: Any, fixture_server: FixtureServer) -> None: @@ -151,6 +153,7 @@ def test_key_executives_surfaces_dynamic_json_values(client: Any, fixture_server assert probe.pay == {"amount": "00123.450", "components": [1, True, None]} assert probe.title_since == 1_704_067_200 assert probe.year_born == "01980" + assert probe.gender is None def test_key_executives_keeps_big_integers_exact(client: Any, fixture_server: FixtureServer) -> None: @@ -213,6 +216,7 @@ def test_executive_compensation_decodes_large_amounts(client: Any, fixture_serve assert rows[1].company_name == "Large Amount Probe" assert rows[1].salary == 5_000_000_000 assert rows[1].all_other_compensation == 10_000_000_000 + assert rows[1].option_award is None assert rows[1].accepted_date == datetime.datetime(2026, 1, 1, 0, 0, 0) diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 132f1b58..7f7db1d3 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -145,7 +145,7 @@ def check_bulk_contract(client: FmpClient) -> None: """Type-check the constructor-only bulk arguments and the cross-domain profile rows.""" bulk: BulkNamespace = client.bulk profiles: list[CompanyProfile] = bulk.company_profiles("0") - ipo_date: datetime.date = profiles[0].ipo_date + ipo_date: datetime.date | None = profiles[0].ipo_date statements: list[BulkIncomeStatement] = client.bulk.income_statements(2026, "Q1") accepted: datetime.datetime = statements[0].accepted_date revenue: str = statements[0].revenue @@ -161,11 +161,11 @@ def check_company_contract(client: FmpClient) -> None: """Type-check the flat company namespace, its date keywords, and the batch list input.""" company: CompanyNamespace = client.company profiles: list[CompanyProfile] = company.profile("AAPL") - ipo_date: datetime.date = profiles[0].ipo_date + ipo_date: datetime.date | None = profiles[0].ipo_date history: list[CompanyMarketCapitalization] = client.company.historical_market_capitalization( "AAPL", limit=5001, from_=datetime.date(2026, 4, 16), to="2026-07-16" ) - market_cap: float = history[0].market_cap + market_cap: float | None = history[0].market_cap batch: list[CompanyMarketCapitalization] = client.company.batch_market_capitalization(["AAPL", "MSFT"]) benchmarks: list[ExecutiveCompensationBenchmark] = client.company.executive_compensation_benchmark(year="2024") average: float = benchmarks[0].average_compensation diff --git a/crates/libfmp/src/responses/company.rs b/crates/libfmp/src/responses/company.rs index d8f3c190..fdf4a19d 100644 --- a/crates/libfmp/src/responses/company.rs +++ b/crates/libfmp/src/responses/company.rs @@ -4,10 +4,10 @@ //! The Python binding exposes these thirteen models under `fmp.company` with //! the same names. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ - codecs::{DynamicJson, NumericString}, + codecs::{DynamicJson, NumericString, empty_or_null_date}, types::{ ApiDateTime, Change, Cik, Count, CountryCode, CurrencyCode, Cusip, Date, ExchangeCode, Industry, Isin, MarketCapitalization, MarketValue, PerShareAmount, Percentage, Price, @@ -15,6 +15,14 @@ use crate::{ }, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// A detailed worldwide company profile returned by symbol or US CIK lookup. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] @@ -37,9 +45,11 @@ pub struct CompanyProfile { pub average_volume: Volume, pub company_name: String, pub currency: CurrencyCode, - pub cik: Cik, + #[serde(deserialize_with = "required_option")] + pub cik: Option, pub isin: Isin, - pub cusip: Cusip, + #[serde(deserialize_with = "required_option")] + pub cusip: Option, pub exchange_full_name: String, pub exchange: ExchangeCode, pub industry: Industry, @@ -48,14 +58,17 @@ pub struct CompanyProfile { pub ceo: String, pub sector: Sector, pub country: CountryCode, - pub full_time_employees: NumericString, - pub phone: String, + #[serde(deserialize_with = "required_option")] + pub full_time_employees: Option, + #[serde(deserialize_with = "required_option")] + pub phone: Option, pub address: String, pub city: String, pub state: String, pub zip: String, pub image: String, - pub ipo_date: Date, + #[serde(with = "empty_or_null_date")] + pub ipo_date: Option, pub default_image: bool, pub is_etf: bool, pub is_actively_trading: bool, @@ -71,7 +84,8 @@ pub struct CompanyNote { pub cik: Cik, pub symbol: Ticker, pub title: String, - pub exchange: ExchangeCode, + #[serde(deserialize_with = "required_option")] + pub exchange: Option, } /// One worldwide stock peer selected by the provider. @@ -123,8 +137,9 @@ pub struct EmployeeCount { pub struct CompanyMarketCapitalization { pub symbol: Ticker, pub date: Date, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub market_cap: MarketCapitalization, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub market_cap: Option, } /// One worldwide company share-float observation with its filing source. @@ -139,7 +154,8 @@ pub struct CompanyShareFloat { pub float_shares: Quantity, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub outstanding_shares: Quantity, - pub source: String, + #[serde(deserialize_with = "required_option")] + pub source: Option, } /// One all-company share-float observation, whose payload has no source field. @@ -149,9 +165,11 @@ pub struct CompanyShareFloat { pub struct ShareFloat { pub symbol: Ticker, pub date: ApiDateTime, - pub free_float: Percentage, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub float_shares: Quantity, + #[serde(deserialize_with = "required_option")] + pub free_float: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub float_shares: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub outstanding_shares: Quantity, } @@ -165,8 +183,10 @@ pub struct MergerAcquisition { pub company_name: String, pub cik: Cik, pub targeted_company_name: String, - pub targeted_cik: Cik, - pub targeted_symbol: Ticker, + #[serde(deserialize_with = "required_option")] + pub targeted_cik: Option, + #[serde(deserialize_with = "required_option")] + pub targeted_symbol: Option, pub transaction_date: Date, pub accepted_date: ApiDateTime, pub link: String, @@ -181,7 +201,8 @@ pub struct CompanyExecutive { pub name: String, pub pay: Option, pub currency_pay: CurrencyCode, - pub gender: String, + #[serde(deserialize_with = "required_option")] + pub gender: Option, pub year_born: Option, pub title_since: Option, pub active: bool, @@ -205,8 +226,9 @@ pub struct ExecutiveCompensation { pub bonus: MarketValue, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub stock_award: MarketValue, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub option_award: MarketValue, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub option_award: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub incentive_plan_compensation: MarketValue, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] diff --git a/crates/libfmp/tests/bulk_snapshot_responses.rs b/crates/libfmp/tests/bulk_snapshot_responses.rs index 1c0038c3..6ed0be56 100644 --- a/crates/libfmp/tests/bulk_snapshot_responses.rs +++ b/crates/libfmp/tests/bulk_snapshot_responses.rs @@ -33,7 +33,10 @@ fn exact_outer_md_fixtures_round_trip_with_exact_field_counts() { #[test] fn every_documented_field_is_required_non_null_and_unknown_fields_are_tolerated() { - assert_required::(PROFILE); + assert_required_nullable::( + PROFILE, + &["cik", "cusip", "fullTimeEmployees", "phone", "ipoDate"], + ); assert_required::(RATING); assert_required::(DCF); assert_required::(SCORES); @@ -112,6 +115,13 @@ where } fn assert_required(fixture: &[u8]) +where + T: DeserializeOwned, +{ + assert_required_nullable::(fixture, &[]); +} + +fn assert_required_nullable(fixture: &[u8], nullable: &[&str]) where T: DeserializeOwned, { @@ -126,9 +136,10 @@ where let mut null = row.clone(); null.insert(field.clone(), Value::Null); - assert!( - serde_json::from_value::(Value::Object(null)).is_err(), - "null {field} unexpectedly decoded" + assert_eq!( + serde_json::from_value::(Value::Object(null)).is_ok(), + nullable.contains(&field.as_str()), + "null {field} decoded contrary to its nullability" ); } diff --git a/crates/libfmp/tests/company_endpoints.rs b/crates/libfmp/tests/company_endpoints.rs index f7906bbf..f9636b03 100644 --- a/crates/libfmp/tests/company_endpoints.rs +++ b/crates/libfmp/tests/company_endpoints.rs @@ -82,6 +82,8 @@ async fn custom_proxy_client_executes_all_four_paths_with_exact_required_queries .await .unwrap()[0] .cik + .as_ref() + .unwrap() .as_str(), "0000320193" ); @@ -91,6 +93,8 @@ async fn custom_proxy_client_executes_all_four_paths_with_exact_required_queries .await .unwrap()[0] .full_time_employees + .as_ref() + .unwrap() .as_str(), "166000" ); @@ -151,7 +155,7 @@ async fn profile_client_preserves_empty_multiple_and_unknown_field_arrays() { assert_eq!(multiple[0].market_cap, 9_007_199_254_740_992.0); assert_eq!(multiple[0].volume, u64::MAX as f64); assert_eq!(unknown.len(), 1); - assert_eq!(unknown[0].cik.as_str(), "0000320193"); + assert_eq!(unknown[0].cik.as_ref().unwrap().as_str(), "0000320193"); } #[tokio::test] diff --git a/crates/libfmp/tests/company_governance_responses.rs b/crates/libfmp/tests/company_governance_responses.rs index 298daefc..bbeae7b6 100644 --- a/crates/libfmp/tests/company_governance_responses.rs +++ b/crates/libfmp/tests/company_governance_responses.rs @@ -20,7 +20,7 @@ fn documented_company_executive_decodes_exact_fields_and_nulls() { assert_eq!(row.name, "Kristin Huguet Quayle"); assert_eq!(row.pay, None); assert_eq!(row.currency_pay.as_str(), "USD"); - assert_eq!(row.gender, "female"); + assert_eq!(row.gender.as_deref(), Some("female")); assert_eq!(row.year_born, None); assert_eq!(row.title_since, None); assert!(row.active); @@ -56,6 +56,8 @@ fn company_executive_dynamic_fields_preserve_synthetic_non_null_json() { ); assert_eq!(row.year_born, Some(json!("01980"))); assert_eq!(row.title_since, Some(json!(1704067200))); + assert_eq!(row.gender, None); + assert!(serde_json::to_value(row).unwrap()["gender"].is_null()); assert!(!row.active); } @@ -77,7 +79,7 @@ fn documented_executive_compensation_decodes_dates_integer_year_and_amounts() { assert_eq!(row.salary, 819_231.0); assert_eq!(row.bonus, 0.0); assert_eq!(row.stock_award, 13_003_031.0); - assert_eq!(row.option_award, 0.0); + assert_eq!(row.option_award, Some(0.0)); assert_eq!(row.incentive_plan_compensation, 1_638_462.0); assert_eq!(row.all_other_compensation, 22_204.0); assert_eq!(row.total, 15_482_928.0); @@ -94,6 +96,8 @@ fn executive_compensation_arrays_decode_large_amounts_and_preserve_exact_casing( assert_eq!(rows[1].salary, 5_000_000_000.0); assert_eq!(rows[1].all_other_compensation, 10_000_000_000.0); assert_eq!(rows[1].total, u64::MAX as f64); + assert_eq!(rows[1].option_award, None); + assert!(serde_json::to_value(&rows[1]).unwrap()["optionAward"].is_null()); let encoded = serde_json::to_value(&rows[0]).unwrap(); assert_eq!(encoded["companyName"], "Apple Inc."); diff --git a/crates/libfmp/tests/company_market_data_responses.rs b/crates/libfmp/tests/company_market_data_responses.rs index 260a407e..91a19858 100644 --- a/crates/libfmp/tests/company_market_data_responses.rs +++ b/crates/libfmp/tests/company_market_data_responses.rs @@ -9,7 +9,7 @@ fn documented_market_capitalization_decodes_every_exact_field_and_wire_type() { let row = &rows[0]; assert_eq!(row.symbol.as_str(), "AAPL"); assert_eq!(row.date.to_string(), "2026-07-30"); - assert_eq!(row.market_cap, 4_874_072_686_740.0); + assert_eq!(row.market_cap, Some(4_874_072_686_740.0)); let wire = serde_json::to_value(row).unwrap(); assert_eq!(wire["date"], "2026-07-30"); @@ -24,7 +24,7 @@ fn documented_market_capitalization_decodes_every_exact_field_and_wire_type() { assert_eq!(historical.len(), 1); assert_eq!(historical[0].symbol.as_str(), "AAPL"); assert_eq!(historical[0].date.to_string(), "2026-07-30"); - assert_eq!(historical[0].market_cap, 4_879_177_245_542.0); + assert_eq!(historical[0].market_cap, Some(4_879_177_245_542.0)); } #[test] @@ -40,8 +40,8 @@ fn documented_company_share_float_decodes_source_and_exact_wire_types() { assert_eq!(row.float_shares, 14_662_387_495.0); assert_eq!(row.outstanding_shares, 14_687_356_000.0); assert_eq!( - row.source, - "https://www.sec.gov/Archives/edgar/data/320193/000032019326000013/aapl-20260328.htm" + row.source.as_deref(), + Some("https://www.sec.gov/Archives/edgar/data/320193/000032019326000013/aapl-20260328.htm") ); let wire = serde_json::to_value(row).unwrap(); @@ -63,8 +63,8 @@ fn documented_all_share_float_is_a_separate_source_free_shape() { let row = &rows[0]; assert_eq!(row.symbol.as_str(), "000001.SZ"); assert_eq!(row.date.to_string(), "2026-07-29 14:23:30"); - assert_eq!(row.free_float, 41.40900000201062); - assert_eq!(row.float_shares, 8_035_796_667.0); + assert_eq!(row.free_float, Some(41.40900000201062)); + assert_eq!(row.float_shares, Some(8_035_796_667.0)); assert_eq!(row.outstanding_shares, 19_405_918_198.0); let wire = serde_json::to_value(row).unwrap(); @@ -98,22 +98,26 @@ fn market_data_arrays_preserve_empty_multiple_unknown_fields_and_large_values() let market_caps: Vec = serde_json::from_str( r#"[ {"symbol":"AAPL","date":"2026-07-30","marketCap":18446744073709551615,"future":true}, - {"symbol":"MSFT","date":"2026-07-30","marketCap":9007199254740993} + {"symbol":"MSFT","date":"2026-07-30","marketCap":9007199254740993}, + {"symbol":"EURUSD","date":"2026-07-30","marketCap":null} ]"#, ) .unwrap(); - assert_eq!(market_caps.len(), 2); - assert_eq!(market_caps[0].market_cap, u64::MAX as f64); - assert_eq!(market_caps[1].market_cap, 9_007_199_254_740_992.0); + assert_eq!(market_caps.len(), 3); + assert_eq!(market_caps[0].market_cap, Some(u64::MAX as f64)); + assert_eq!(market_caps[1].market_cap, Some(9_007_199_254_740_992.0)); + assert_eq!(market_caps[2].market_cap, None); + assert!(serde_json::to_value(&market_caps[2]).unwrap()["marketCap"].is_null()); let company_floats: Vec = serde_json::from_str( r#"[ {"symbol":"AAPL","date":"2026-07-30 15:48:00","freeFloat":99.83,"floatShares":18446744073709551615,"outstandingShares":9007199254740993,"source":"https://www.sec.gov/example","future":{"nested":true}}, - {"symbol":"MSFT","date":"2026-07-30 15:48:00","freeFloat":98.1,"floatShares":1,"outstandingShares":2,"source":"https://www.sec.gov/example"} + {"symbol":"MSFT","date":"2026-07-30 15:48:00","freeFloat":98.1,"floatShares":1,"outstandingShares":2,"source":null} ]"#, ) .unwrap(); assert_eq!(company_floats.len(), 2); + assert_eq!(company_floats[1].source, None); assert_eq!(company_floats[0].float_shares, u64::MAX as f64); assert_eq!( company_floats[0].outstanding_shares, @@ -123,11 +127,16 @@ fn market_data_arrays_preserve_empty_multiple_unknown_fields_and_large_values() let all_floats: Vec = serde_json::from_str( r#"[ {"symbol":"000001.SZ","date":"2026-07-29 14:23:30","freeFloat":41.409,"floatShares":18446744073709551615,"outstandingShares":9007199254740993,"future":null}, - {"symbol":"000002.SZ","date":"2026-07-29 14:23:30","freeFloat":42.0,"floatShares":3,"outstandingShares":4} + {"symbol":"000002.SZ","date":"2026-07-29 14:23:30","freeFloat":null,"floatShares":null,"outstandingShares":4} ]"#, ) .unwrap(); assert_eq!(all_floats.len(), 2); - assert_eq!(all_floats[0].float_shares, u64::MAX as f64); + assert_eq!(all_floats[0].float_shares, Some(u64::MAX as f64)); + assert_eq!(all_floats[1].free_float, None); + assert_eq!(all_floats[1].float_shares, None); + let null_wire = serde_json::to_value(&all_floats[1]).unwrap(); + assert!(null_wire["freeFloat"].is_null()); + assert!(null_wire["floatShares"].is_null()); assert_eq!(all_floats[0].outstanding_shares, 9_007_199_254_740_992.0); } diff --git a/crates/libfmp/tests/company_mergers_acquisitions_endpoints.rs b/crates/libfmp/tests/company_mergers_acquisitions_endpoints.rs index dfed1a11..1799b7e5 100644 --- a/crates/libfmp/tests/company_mergers_acquisitions_endpoints.rs +++ b/crates/libfmp/tests/company_mergers_acquisitions_endpoints.rs @@ -79,7 +79,10 @@ async fn proxy_client_preserves_omission_order_zero_above_bound_and_name_encodin .await .unwrap(); assert_eq!(latest[0].cik.as_str(), "0002009312"); - assert_eq!(latest[0].targeted_cik.as_str(), "0002009312"); + assert_eq!( + latest[0].targeted_cik.as_ref().unwrap().as_str(), + "0002009312" + ); assert_eq!( latest[0].targeted_company_name, "Aureus Greenway Holdings, Inc." @@ -144,7 +147,7 @@ async fn merger_acquisition_arrays_preserve_multiple_empty_and_unknown_field_pay .unwrap(); assert_eq!(multiple.len(), 2); assert_eq!(multiple[1].symbol.as_str(), "PEGY"); - assert_eq!(multiple[1].targeted_symbol.as_str(), "JCS"); + assert_eq!(multiple[1].targeted_symbol, None); assert_eq!(multiple[1].transaction_date.to_string(), "2021-11-12"); assert_eq!(multiple[1].accepted_date.to_string(), "2021-11-12 09:54:22"); diff --git a/crates/libfmp/tests/company_mergers_acquisitions_responses.rs b/crates/libfmp/tests/company_mergers_acquisitions_responses.rs index 4da80e9f..62b89409 100644 --- a/crates/libfmp/tests/company_mergers_acquisitions_responses.rs +++ b/crates/libfmp/tests/company_mergers_acquisitions_responses.rs @@ -15,8 +15,8 @@ fn documented_latest_merger_acquisition_decodes_every_exact_field_and_wire_type( assert_eq!(row.company_name, "Aureus Greenway Holdings Inc"); assert_eq!(row.cik.as_str(), "0002009312"); assert_eq!(row.targeted_company_name, "Aureus Greenway Holdings, Inc."); - assert_eq!(row.targeted_cik.as_str(), "0002009312"); - assert_eq!(row.targeted_symbol.as_str(), "PUSA"); + assert_eq!(row.targeted_cik.as_ref().unwrap().as_str(), "0002009312"); + assert_eq!(row.targeted_symbol.as_ref().unwrap().as_str(), "PUSA"); assert_eq!(row.transaction_date.to_string(), "2026-07-29"); assert_eq!(row.accepted_date.to_string(), "2026-07-29 16:00:46"); assert_eq!( @@ -34,8 +34,8 @@ fn documented_search_merger_acquisition_preserves_leading_zero_ciks_names_and_ur assert_eq!(row.company_name, "Pineapple Energy Inc."); assert_eq!(row.cik.as_str(), "0000022701"); assert_eq!(row.targeted_company_name, "Communications Systems, Inc."); - assert_eq!(row.targeted_cik.as_str(), "0000022701"); - assert_eq!(row.targeted_symbol.as_str(), "JCS"); + assert_eq!(row.targeted_cik.as_ref().unwrap().as_str(), "0000022701"); + assert_eq!(row.targeted_symbol.as_ref().unwrap().as_str(), "JCS"); assert_eq!(row.transaction_date.to_string(), "2021-11-12"); assert_eq!(row.accepted_date.to_string(), "2021-11-12 09:54:22"); assert_eq!( @@ -52,8 +52,15 @@ fn merger_acquisition_arrays_preserve_empty_multiple_and_unknown_field_shapes() assert!(empty.is_empty()); assert_eq!(multiple.len(), 2); - assert_eq!(multiple[0].targeted_symbol.as_str(), "PUSA"); - assert_eq!(multiple[1].targeted_symbol.as_str(), "JCS"); + assert_eq!( + multiple[0].targeted_symbol.as_ref().unwrap().as_str(), + "PUSA" + ); + assert_eq!(multiple[1].targeted_cik, None); + assert_eq!(multiple[1].targeted_symbol, None); + let wire = serde_json::to_value(&multiple[1]).unwrap(); + assert!(wire["targetedCik"].is_null()); + assert!(wire["targetedSymbol"].is_null()); assert_eq!(unknown.len(), 1); assert_eq!(unknown[0].cik.as_str(), "0002009312"); } diff --git a/crates/libfmp/tests/company_responses.rs b/crates/libfmp/tests/company_responses.rs index bab9d3e1..3fc23030 100644 --- a/crates/libfmp/tests/company_responses.rs +++ b/crates/libfmp/tests/company_responses.rs @@ -19,9 +19,9 @@ fn documented_company_profile_decodes_every_exact_field_and_wire_type() { assert_eq!(row.average_volume, 55_309_000.0); assert_eq!(row.company_name, "Apple Inc."); assert_eq!(row.currency.as_str(), "USD"); - assert_eq!(row.cik.as_str(), "0000320193"); + assert_eq!(row.cik.as_ref().unwrap().as_str(), "0000320193"); assert_eq!(row.isin.as_str(), "US0378331005"); - assert_eq!(row.cusip.as_str(), "037833100"); + assert_eq!(row.cusip.as_ref().unwrap().as_str(), "037833100"); assert_eq!(row.exchange_full_name, "NASDAQ Global Select"); assert_eq!(row.exchange.as_str(), "NASDAQ"); assert_eq!(row.industry.as_str(), "Consumer Electronics"); @@ -33,8 +33,8 @@ fn documented_company_profile_decodes_every_exact_field_and_wire_type() { assert_eq!(row.ceo, "Timothy D. Cook"); assert_eq!(row.sector.as_str(), "Technology"); assert_eq!(row.country.as_str(), "US"); - assert_eq!(row.full_time_employees.as_str(), "166000"); - assert_eq!(row.phone, "(408) 996-1010"); + assert_eq!(row.full_time_employees.as_ref().unwrap().as_str(), "166000"); + assert_eq!(row.phone.as_deref(), Some("(408) 996-1010")); assert_eq!(row.address, "One Apple Park Way"); assert_eq!(row.city, "Cupertino"); assert_eq!(row.state, "CA"); @@ -43,7 +43,7 @@ fn documented_company_profile_decodes_every_exact_field_and_wire_type() { row.image, "https://images.financialmodelingprep.com/symbol/AAPL.png" ); - assert_eq!(row.ipo_date.to_string(), "1980-12-12"); + assert_eq!(row.ipo_date.unwrap().to_string(), "1980-12-12"); assert!(!row.default_image); assert!(!row.is_etf); assert!(row.is_actively_trading); @@ -77,7 +77,7 @@ fn documented_company_note_and_stock_peer_preserve_exact_names() { assert_eq!(notes[0].cik.as_str(), "0000320193"); assert_eq!(notes[0].symbol.as_str(), "AAPL"); assert_eq!(notes[0].title, "0.000% Notes due 2025"); - assert_eq!(notes[0].exchange.as_str(), "NASDAQ"); + assert_eq!(notes[0].exchange.as_ref().unwrap().as_str(), "NASDAQ"); assert_eq!(peers.len(), 1); assert_eq!(peers[0].symbol.as_str(), "GOOGL"); @@ -119,12 +119,34 @@ fn company_arrays_preserve_empty_multiple_unknown_and_large_values() { assert!(profiles[0].market_cap >= 2_f64.powi(53)); assert_eq!(profiles[0].volume, u64::MAX as f64); assert_eq!(profiles[0].average_volume, 4_294_967_296.0); - assert_eq!(profiles[0].full_time_employees.as_str(), "42"); + assert_eq!(profiles[0].cik, None); + assert_eq!(profiles[0].cusip, None); + assert_eq!(profiles[0].full_time_employees, None); + assert_eq!(profiles[0].phone, None); + assert_eq!(profiles[0].ipo_date, None); let large_wire = serde_json::to_value(&profiles[0]).unwrap(); - assert_eq!(large_wire["fullTimeEmployees"], "42"); - assert!(large_wire["fullTimeEmployees"].is_string()); + assert!(large_wire["cik"].is_null()); + assert!(large_wire["fullTimeEmployees"].is_null()); + assert!(large_wire["ipoDate"].is_null()); assert_eq!(profiles[0].sector.as_str(), "Future Sector"); assert_eq!(profiles[0].industry.as_str(), "Future Industry"); assert_eq!(unknown.len(), 1); assert_eq!(unknown[0].symbol.as_str(), "AAPL"); } + +#[test] +fn company_note_decodes_a_null_exchange_as_none() { + let notes: Vec = serde_json::from_str( + r#"[{"cik":"0001577552","symbol":"BABA","title":"2.125% Notes due 2031","exchange":null}]"#, + ) + .unwrap(); + + assert_eq!(notes[0].exchange, None); + assert!(serde_json::to_value(¬es[0]).unwrap()["exchange"].is_null()); + assert!( + serde_json::from_str::>( + r#"[{"cik":"0001577552","symbol":"BABA","title":"t"}]"# + ) + .is_err() + ); +} diff --git a/crates/libfmp/tests/fixtures/company_executive_compensation_large.json b/crates/libfmp/tests/fixtures/company_executive_compensation_large.json index 4e8cd2d9..94c3b945 100644 --- a/crates/libfmp/tests/fixtures/company_executive_compensation_large.json +++ b/crates/libfmp/tests/fixtures/company_executive_compensation_large.json @@ -26,7 +26,7 @@ "incentivePlanCompensation": 9000000000, "link": "https://example.test/filing", "nameAndPosition": "Synthetic Executive", - "optionAward": 8000000000, + "optionAward": null, "salary": 5000000000, "stockAward": 7000000000, "symbol": "BIG", diff --git a/crates/libfmp/tests/fixtures/company_key_executives_dynamic.json b/crates/libfmp/tests/fixtures/company_key_executives_dynamic.json index f2ebdd82..1574ad86 100644 --- a/crates/libfmp/tests/fixtures/company_key_executives_dynamic.json +++ b/crates/libfmp/tests/fixtures/company_key_executives_dynamic.json @@ -12,7 +12,7 @@ { "active": false, "currencyPay": "XTS", - "gender": "unspecified", + "gender": null, "name": "Representation Probe", "pay": { "amount": "00123.450", diff --git a/crates/libfmp/tests/fixtures/company_mergers_acquisitions_multiple.json b/crates/libfmp/tests/fixtures/company_mergers_acquisitions_multiple.json index d9a9aaef..e46f4797 100644 --- a/crates/libfmp/tests/fixtures/company_mergers_acquisitions_multiple.json +++ b/crates/libfmp/tests/fixtures/company_mergers_acquisitions_multiple.json @@ -16,9 +16,9 @@ "companyName": "Pineapple Energy Inc.", "link": "https://www.sec.gov/Archives/edgar/data/22701/000089710121000932/a211292_s-4.htm", "symbol": "PEGY", - "targetedCik": "0000022701", + "targetedCik": null, "targetedCompanyName": "Communications Systems, Inc.", - "targetedSymbol": "JCS", + "targetedSymbol": null, "transactionDate": "2021-11-12" } ] diff --git a/crates/libfmp/tests/fixtures/company_profile_multiple.json b/crates/libfmp/tests/fixtures/company_profile_multiple.json index e63a3f29..a71bd35b 100644 --- a/crates/libfmp/tests/fixtures/company_profile_multiple.json +++ b/crates/libfmp/tests/fixtures/company_profile_multiple.json @@ -6,20 +6,20 @@ "ceo": "A. Person", "change": 0.25, "changePercentage": 20, - "cik": "0000000001", + "cik": null, "city": "Testville", "companyName": "Large Values Inc.", "country": "ZZ", "currency": "XTS", - "cusip": "000000001", + "cusip": null, "defaultImage": true, "description": "Large integer fixture.", "exchange": "FUTURE", "exchangeFullName": "Future Exchange", - "fullTimeEmployees": "42", + "fullTimeEmployees": null, "image": "https://example.com/image.png", "industry": "Future Industry", - "ipoDate": "2026-08-27", + "ipoDate": "", "isActivelyTrading": false, "isAdr": true, "isEtf": true, @@ -27,7 +27,7 @@ "isin": "TEST00000001", "lastDividend": 0, "marketCap": 9007199254740993, - "phone": "0", + "phone": null, "price": 1.5, "range": "0-2", "sector": "Future Sector", diff --git a/sdk/go/bulk_parity_test.go b/sdk/go/bulk_parity_test.go index cf97dc66..60bb6b16 100644 --- a/sdk/go/bulk_parity_test.go +++ b/sdk/go/bulk_parity_test.go @@ -42,7 +42,7 @@ func TestBulkFixturesDecodeAndReencodeToTheSameMemberSet(t *testing.T) { func TestDocumentedBulkSnapshotsDecodeExactValues(t *testing.T) { t.Parallel() profiles := assertFixtureParity[CompanyProfile](t, "bulk_company_profiles.json") - if len(profiles) != 1 || profiles[0].Symbol != "AAPL" || profiles[0].CIK != "0000320193" { + if len(profiles) != 1 || profiles[0].Symbol != "AAPL" || profiles[0].CIK == nil || *profiles[0].CIK != "0000320193" { t.Fatalf("bulk_company_profiles = %+v", profiles) } ratings := assertFixtureParity[BulkStockRating](t, "bulk_stock_ratings.json") diff --git a/sdk/go/company_models.go b/sdk/go/company_models.go index 2e28789e..d76ec3de 100644 --- a/sdk/go/company_models.go +++ b/sdk/go/company_models.go @@ -25,9 +25,9 @@ type CompanyProfile struct { AverageVolume float64 `json:"averageVolume"` CompanyName string `json:"companyName"` Currency string `json:"currency"` - CIK string `json:"cik"` + CIK *string `json:"cik"` ISIN string `json:"isin"` - CUSIP string `json:"cusip"` + CUSIP *string `json:"cusip"` ExchangeFullName string `json:"exchangeFullName"` Exchange string `json:"exchange"` Industry string `json:"industry"` @@ -36,14 +36,14 @@ type CompanyProfile struct { Ceo string `json:"ceo"` Sector string `json:"sector"` Country string `json:"country"` - FullTimeEmployees string `json:"fullTimeEmployees"` - Phone string `json:"phone"` + FullTimeEmployees *string `json:"fullTimeEmployees"` + Phone *string `json:"phone"` Address string `json:"address"` City string `json:"city"` State string `json:"state"` Zip string `json:"zip"` Image string `json:"image"` - IPODate Date `json:"ipoDate"` + IPODate *Date `json:"ipoDate"` DefaultImage bool `json:"defaultImage"` IsETF bool `json:"isEtf"` IsActivelyTrading bool `json:"isActivelyTrading"` @@ -55,42 +55,42 @@ type CompanyProfile struct { // every required member so a missing or null member is observable after // decoding. type companyProfileShadow struct { - Symbol *string `json:"symbol"` - Price *float64 `json:"price"` - MarketCap *float64 `json:"marketCap"` - Beta *float64 `json:"beta"` - LastDividend *float64 `json:"lastDividend"` - Range *string `json:"range"` - Change *float64 `json:"change"` - ChangePercentage *float64 `json:"changePercentage"` - Volume *float64 `json:"volume"` - AverageVolume *float64 `json:"averageVolume"` - CompanyName *string `json:"companyName"` - Currency *string `json:"currency"` - CIK *string `json:"cik"` - ISIN *string `json:"isin"` - CUSIP *string `json:"cusip"` - ExchangeFullName *string `json:"exchangeFullName"` - Exchange *string `json:"exchange"` - Industry *string `json:"industry"` - Website *string `json:"website"` - Description *string `json:"description"` - Ceo *string `json:"ceo"` - Sector *string `json:"sector"` - Country *string `json:"country"` - FullTimeEmployees *string `json:"fullTimeEmployees"` - Phone *string `json:"phone"` - Address *string `json:"address"` - City *string `json:"city"` - State *string `json:"state"` - Zip *string `json:"zip"` - Image *string `json:"image"` - IPODate *Date `json:"ipoDate"` - DefaultImage *bool `json:"defaultImage"` - IsETF *bool `json:"isEtf"` - IsActivelyTrading *bool `json:"isActivelyTrading"` - IsAdr *bool `json:"isAdr"` - IsFund *bool `json:"isFund"` + Symbol *string `json:"symbol"` + Price *float64 `json:"price"` + MarketCap *float64 `json:"marketCap"` + Beta *float64 `json:"beta"` + LastDividend *float64 `json:"lastDividend"` + Range *string `json:"range"` + Change *float64 `json:"change"` + ChangePercentage *float64 `json:"changePercentage"` + Volume *float64 `json:"volume"` + AverageVolume *float64 `json:"averageVolume"` + CompanyName *string `json:"companyName"` + Currency *string `json:"currency"` + CIK jsontext.Value `json:"cik"` + ISIN *string `json:"isin"` + CUSIP jsontext.Value `json:"cusip"` + ExchangeFullName *string `json:"exchangeFullName"` + Exchange *string `json:"exchange"` + Industry *string `json:"industry"` + Website *string `json:"website"` + Description *string `json:"description"` + Ceo *string `json:"ceo"` + Sector *string `json:"sector"` + Country *string `json:"country"` + FullTimeEmployees jsontext.Value `json:"fullTimeEmployees"` + Phone jsontext.Value `json:"phone"` + Address *string `json:"address"` + City *string `json:"city"` + State *string `json:"state"` + Zip *string `json:"zip"` + Image *string `json:"image"` + IPODate jsontext.Value `json:"ipoDate"` + DefaultImage *bool `json:"defaultImage"` + IsETF *bool `json:"isEtf"` + IsActivelyTrading *bool `json:"isActivelyTrading"` + IsAdr *bool `json:"isAdr"` + IsFund *bool `json:"isFund"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -126,11 +126,11 @@ func (m *CompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CompanyProfile", "companyName") case shadow.Currency == nil: return missingMemberError("CompanyProfile", "currency") - case shadow.CIK == nil: + case len(shadow.CIK) == 0: return missingMemberError("CompanyProfile", "cik") case shadow.ISIN == nil: return missingMemberError("CompanyProfile", "isin") - case shadow.CUSIP == nil: + case len(shadow.CUSIP) == 0: return missingMemberError("CompanyProfile", "cusip") case shadow.ExchangeFullName == nil: return missingMemberError("CompanyProfile", "exchangeFullName") @@ -148,9 +148,9 @@ func (m *CompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CompanyProfile", "sector") case shadow.Country == nil: return missingMemberError("CompanyProfile", "country") - case shadow.FullTimeEmployees == nil: + case len(shadow.FullTimeEmployees) == 0: return missingMemberError("CompanyProfile", "fullTimeEmployees") - case shadow.Phone == nil: + case len(shadow.Phone) == 0: return missingMemberError("CompanyProfile", "phone") case shadow.Address == nil: return missingMemberError("CompanyProfile", "address") @@ -162,7 +162,7 @@ func (m *CompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CompanyProfile", "zip") case shadow.Image == nil: return missingMemberError("CompanyProfile", "image") - case shadow.IPODate == nil: + case len(shadow.IPODate) == 0: return missingMemberError("CompanyProfile", "ipoDate") case shadow.DefaultImage == nil: return missingMemberError("CompanyProfile", "defaultImage") @@ -175,6 +175,42 @@ func (m *CompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.IsFund == nil: return missingMemberError("CompanyProfile", "isFund") } + var cik *string + if shadow.CIK.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.CIK, &value); err != nil { + return err + } + cik = &value + } + 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 fullTimeEmployees *string + if shadow.FullTimeEmployees.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.FullTimeEmployees, &value); err != nil { + return err + } + fullTimeEmployees = &value + } + var phone *string + if shadow.Phone.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Phone, &value); err != nil { + return err + } + phone = &value + } + ipoDate, err := decodeEmptyDate("CompanyProfile", "ipoDate", shadow.IPODate, true) + if err != nil { + return err + } *m = CompanyProfile{ Symbol: *shadow.Symbol, Price: *shadow.Price, @@ -188,9 +224,9 @@ func (m *CompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { AverageVolume: *shadow.AverageVolume, CompanyName: *shadow.CompanyName, Currency: *shadow.Currency, - CIK: *shadow.CIK, + CIK: cik, ISIN: *shadow.ISIN, - CUSIP: *shadow.CUSIP, + CUSIP: cusip, ExchangeFullName: *shadow.ExchangeFullName, Exchange: *shadow.Exchange, Industry: *shadow.Industry, @@ -199,14 +235,14 @@ func (m *CompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { Ceo: *shadow.Ceo, Sector: *shadow.Sector, Country: *shadow.Country, - FullTimeEmployees: *shadow.FullTimeEmployees, - Phone: *shadow.Phone, + FullTimeEmployees: fullTimeEmployees, + Phone: phone, Address: *shadow.Address, City: *shadow.City, State: *shadow.State, Zip: *shadow.Zip, Image: *shadow.Image, - IPODate: *shadow.IPODate, + IPODate: ipoDate, DefaultImage: *shadow.DefaultImage, IsETF: *shadow.IsETF, IsActivelyTrading: *shadow.IsActivelyTrading, @@ -218,19 +254,19 @@ func (m *CompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // CompanyNote is one US company-issued note. type CompanyNote struct { - CIK string `json:"cik"` - Symbol string `json:"symbol"` - Title string `json:"title"` - Exchange string `json:"exchange"` + CIK string `json:"cik"` + Symbol string `json:"symbol"` + Title string `json:"title"` + Exchange *string `json:"exchange"` } // companyNoteShadow mirrors CompanyNote with a pointer or raw value for every // required member so a missing or null member is observable after decoding. type companyNoteShadow struct { - CIK *string `json:"cik"` - Symbol *string `json:"symbol"` - Title *string `json:"title"` - Exchange *string `json:"exchange"` + CIK *string `json:"cik"` + Symbol *string `json:"symbol"` + Title *string `json:"title"` + Exchange jsontext.Value `json:"exchange"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -248,14 +284,22 @@ func (m *CompanyNote) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CompanyNote", "symbol") case shadow.Title == nil: return missingMemberError("CompanyNote", "title") - case shadow.Exchange == nil: + case len(shadow.Exchange) == 0: return missingMemberError("CompanyNote", "exchange") } + var exchange *string + if shadow.Exchange.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Exchange, &value); err != nil { + return err + } + exchange = &value + } *m = CompanyNote{ CIK: *shadow.CIK, Symbol: *shadow.Symbol, Title: *shadow.Title, - Exchange: *shadow.Exchange, + Exchange: exchange, } return nil } @@ -431,18 +475,18 @@ func (m *EmployeeCount) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // CompanyMarketCapitalization is one current or historical worldwide // market-capitalization observation. type CompanyMarketCapitalization struct { - Symbol string `json:"symbol"` - Date Date `json:"date"` - MarketCap float64 `json:"marketCap"` + Symbol string `json:"symbol"` + Date Date `json:"date"` + MarketCap *float64 `json:"marketCap"` } // companyMarketCapitalizationShadow mirrors CompanyMarketCapitalization with a // pointer or raw value for every required member so a missing or null member // is observable after decoding. type companyMarketCapitalizationShadow struct { - Symbol *string `json:"symbol"` - Date *Date `json:"date"` - MarketCap *float64 `json:"marketCap"` + Symbol *string `json:"symbol"` + Date *Date `json:"date"` + MarketCap jsontext.Value `json:"marketCap"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -458,13 +502,21 @@ func (m *CompanyMarketCapitalization) UnmarshalJSONFrom(dec *jsontext.Decoder) e return missingMemberError("CompanyMarketCapitalization", "symbol") case shadow.Date == nil: return missingMemberError("CompanyMarketCapitalization", "date") - case shadow.MarketCap == nil: + case len(shadow.MarketCap) == 0: return missingMemberError("CompanyMarketCapitalization", "marketCap") } + var marketCap *float64 + if shadow.MarketCap.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.MarketCap, &value); err != nil { + return err + } + marketCap = &value + } *m = CompanyMarketCapitalization{ Symbol: *shadow.Symbol, Date: *shadow.Date, - MarketCap: *shadow.MarketCap, + MarketCap: marketCap, } return nil } @@ -477,19 +529,19 @@ type CompanyShareFloat struct { FreeFloat float64 `json:"freeFloat"` FloatShares float64 `json:"floatShares"` OutstandingShares float64 `json:"outstandingShares"` - Source string `json:"source"` + Source *string `json:"source"` } // companyShareFloatShadow mirrors CompanyShareFloat with a pointer or raw // value for every required member so a missing or null member is observable // after decoding. type companyShareFloatShadow struct { - Symbol *string `json:"symbol"` - Date *DateTime `json:"date"` - FreeFloat *float64 `json:"freeFloat"` - FloatShares *float64 `json:"floatShares"` - OutstandingShares *float64 `json:"outstandingShares"` - Source *string `json:"source"` + Symbol *string `json:"symbol"` + Date *DateTime `json:"date"` + FreeFloat *float64 `json:"freeFloat"` + FloatShares *float64 `json:"floatShares"` + OutstandingShares *float64 `json:"outstandingShares"` + Source jsontext.Value `json:"source"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -511,16 +563,24 @@ func (m *CompanyShareFloat) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CompanyShareFloat", "floatShares") case shadow.OutstandingShares == nil: return missingMemberError("CompanyShareFloat", "outstandingShares") - case shadow.Source == nil: + case len(shadow.Source) == 0: return missingMemberError("CompanyShareFloat", "source") } + var source *string + if shadow.Source.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Source, &value); err != nil { + return err + } + source = &value + } *m = CompanyShareFloat{ Symbol: *shadow.Symbol, Date: *shadow.Date, FreeFloat: *shadow.FreeFloat, FloatShares: *shadow.FloatShares, OutstandingShares: *shadow.OutstandingShares, - Source: *shadow.Source, + Source: source, } return nil } @@ -530,19 +590,19 @@ func (m *CompanyShareFloat) UnmarshalJSONFrom(dec *jsontext.Decoder) error { type ShareFloat struct { Symbol string `json:"symbol"` Date DateTime `json:"date"` - FreeFloat float64 `json:"freeFloat"` - FloatShares float64 `json:"floatShares"` + FreeFloat *float64 `json:"freeFloat"` + FloatShares *float64 `json:"floatShares"` OutstandingShares float64 `json:"outstandingShares"` } // shareFloatShadow mirrors ShareFloat with a pointer or raw value for every // required member so a missing or null member is observable after decoding. type shareFloatShadow struct { - Symbol *string `json:"symbol"` - Date *DateTime `json:"date"` - FreeFloat *float64 `json:"freeFloat"` - FloatShares *float64 `json:"floatShares"` - OutstandingShares *float64 `json:"outstandingShares"` + Symbol *string `json:"symbol"` + Date *DateTime `json:"date"` + FreeFloat jsontext.Value `json:"freeFloat"` + FloatShares jsontext.Value `json:"floatShares"` + OutstandingShares *float64 `json:"outstandingShares"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -558,18 +618,34 @@ func (m *ShareFloat) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("ShareFloat", "symbol") case shadow.Date == nil: return missingMemberError("ShareFloat", "date") - case shadow.FreeFloat == nil: + case len(shadow.FreeFloat) == 0: return missingMemberError("ShareFloat", "freeFloat") - case shadow.FloatShares == nil: + case len(shadow.FloatShares) == 0: return missingMemberError("ShareFloat", "floatShares") case shadow.OutstandingShares == nil: return missingMemberError("ShareFloat", "outstandingShares") } + var freeFloat *float64 + if shadow.FreeFloat.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.FreeFloat, &value); err != nil { + return err + } + freeFloat = &value + } + var floatShares *float64 + if shadow.FloatShares.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.FloatShares, &value); err != nil { + return err + } + floatShares = &value + } *m = ShareFloat{ Symbol: *shadow.Symbol, Date: *shadow.Date, - FreeFloat: *shadow.FreeFloat, - FloatShares: *shadow.FloatShares, + FreeFloat: freeFloat, + FloatShares: floatShares, OutstandingShares: *shadow.OutstandingShares, } return nil @@ -582,8 +658,8 @@ type MergerAcquisition struct { CompanyName string `json:"companyName"` CIK string `json:"cik"` TargetedCompanyName string `json:"targetedCompanyName"` - TargetedCIK string `json:"targetedCik"` - TargetedSymbol string `json:"targetedSymbol"` + TargetedCIK *string `json:"targetedCik"` + TargetedSymbol *string `json:"targetedSymbol"` TransactionDate Date `json:"transactionDate"` AcceptedDate DateTime `json:"acceptedDate"` Link string `json:"link"` @@ -593,15 +669,15 @@ type MergerAcquisition struct { // value for every required member so a missing or null member is observable // after decoding. type mergerAcquisitionShadow struct { - Symbol *string `json:"symbol"` - CompanyName *string `json:"companyName"` - CIK *string `json:"cik"` - TargetedCompanyName *string `json:"targetedCompanyName"` - TargetedCIK *string `json:"targetedCik"` - TargetedSymbol *string `json:"targetedSymbol"` - TransactionDate *Date `json:"transactionDate"` - AcceptedDate *DateTime `json:"acceptedDate"` - Link *string `json:"link"` + Symbol *string `json:"symbol"` + CompanyName *string `json:"companyName"` + CIK *string `json:"cik"` + TargetedCompanyName *string `json:"targetedCompanyName"` + TargetedCIK jsontext.Value `json:"targetedCik"` + TargetedSymbol jsontext.Value `json:"targetedSymbol"` + TransactionDate *Date `json:"transactionDate"` + AcceptedDate *DateTime `json:"acceptedDate"` + Link *string `json:"link"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -621,9 +697,9 @@ func (m *MergerAcquisition) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("MergerAcquisition", "cik") case shadow.TargetedCompanyName == nil: return missingMemberError("MergerAcquisition", "targetedCompanyName") - case shadow.TargetedCIK == nil: + case len(shadow.TargetedCIK) == 0: return missingMemberError("MergerAcquisition", "targetedCik") - case shadow.TargetedSymbol == nil: + case len(shadow.TargetedSymbol) == 0: return missingMemberError("MergerAcquisition", "targetedSymbol") case shadow.TransactionDate == nil: return missingMemberError("MergerAcquisition", "transactionDate") @@ -632,13 +708,29 @@ func (m *MergerAcquisition) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.Link == nil: return missingMemberError("MergerAcquisition", "link") } + var targetedCIK *string + if shadow.TargetedCIK.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.TargetedCIK, &value); err != nil { + return err + } + targetedCIK = &value + } + var targetedSymbol *string + if shadow.TargetedSymbol.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.TargetedSymbol, &value); err != nil { + return err + } + targetedSymbol = &value + } *m = MergerAcquisition{ Symbol: *shadow.Symbol, CompanyName: *shadow.CompanyName, CIK: *shadow.CIK, TargetedCompanyName: *shadow.TargetedCompanyName, - TargetedCIK: *shadow.TargetedCIK, - TargetedSymbol: *shadow.TargetedSymbol, + TargetedCIK: targetedCIK, + TargetedSymbol: targetedSymbol, TransactionDate: *shadow.TransactionDate, AcceptedDate: *shadow.AcceptedDate, Link: *shadow.Link, @@ -653,7 +745,7 @@ type CompanyExecutive struct { Name string `json:"name"` Pay *jsontext.Value `json:"pay"` CurrencyPay string `json:"currencyPay"` - Gender string `json:"gender"` + Gender *string `json:"gender"` YearBorn *jsontext.Value `json:"yearBorn"` TitleSince *jsontext.Value `json:"titleSince"` Active bool `json:"active"` @@ -667,7 +759,7 @@ type companyExecutiveShadow struct { Name *string `json:"name"` Pay *jsontext.Value `json:"pay"` CurrencyPay *string `json:"currencyPay"` - Gender *string `json:"gender"` + Gender jsontext.Value `json:"gender"` YearBorn *jsontext.Value `json:"yearBorn"` TitleSince *jsontext.Value `json:"titleSince"` Active *bool `json:"active"` @@ -688,17 +780,25 @@ func (m *CompanyExecutive) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CompanyExecutive", "name") case shadow.CurrencyPay == nil: return missingMemberError("CompanyExecutive", "currencyPay") - case shadow.Gender == nil: + case len(shadow.Gender) == 0: return missingMemberError("CompanyExecutive", "gender") case shadow.Active == nil: return missingMemberError("CompanyExecutive", "active") } + var gender *string + if shadow.Gender.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Gender, &value); err != nil { + return err + } + gender = &value + } *m = CompanyExecutive{ Title: *shadow.Title, Name: *shadow.Name, Pay: shadow.Pay, CurrencyPay: *shadow.CurrencyPay, - Gender: *shadow.Gender, + Gender: gender, YearBorn: shadow.YearBorn, TitleSince: shadow.TitleSince, Active: *shadow.Active, @@ -719,7 +819,7 @@ type ExecutiveCompensation struct { Salary float64 `json:"salary"` Bonus float64 `json:"bonus"` StockAward float64 `json:"stockAward"` - OptionAward float64 `json:"optionAward"` + OptionAward *float64 `json:"optionAward"` IncentivePlanCompensation float64 `json:"incentivePlanCompensation"` AllOtherCompensation float64 `json:"allOtherCompensation"` Total float64 `json:"total"` @@ -730,21 +830,21 @@ type ExecutiveCompensation struct { // raw value for every required member so a missing or null member is // observable after decoding. type executiveCompensationShadow struct { - CIK *string `json:"cik"` - Symbol *string `json:"symbol"` - CompanyName *string `json:"companyName"` - FilingDate *Date `json:"filingDate"` - AcceptedDate *DateTime `json:"acceptedDate"` - NameAndPosition *string `json:"nameAndPosition"` - Year *int64 `json:"year"` - Salary *float64 `json:"salary"` - Bonus *float64 `json:"bonus"` - StockAward *float64 `json:"stockAward"` - OptionAward *float64 `json:"optionAward"` - IncentivePlanCompensation *float64 `json:"incentivePlanCompensation"` - AllOtherCompensation *float64 `json:"allOtherCompensation"` - Total *float64 `json:"total"` - Link *string `json:"link"` + CIK *string `json:"cik"` + Symbol *string `json:"symbol"` + CompanyName *string `json:"companyName"` + FilingDate *Date `json:"filingDate"` + AcceptedDate *DateTime `json:"acceptedDate"` + NameAndPosition *string `json:"nameAndPosition"` + Year *int64 `json:"year"` + Salary *float64 `json:"salary"` + Bonus *float64 `json:"bonus"` + StockAward *float64 `json:"stockAward"` + OptionAward jsontext.Value `json:"optionAward"` + IncentivePlanCompensation *float64 `json:"incentivePlanCompensation"` + AllOtherCompensation *float64 `json:"allOtherCompensation"` + Total *float64 `json:"total"` + Link *string `json:"link"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -776,7 +876,7 @@ func (m *ExecutiveCompensation) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("ExecutiveCompensation", "bonus") case shadow.StockAward == nil: return missingMemberError("ExecutiveCompensation", "stockAward") - case shadow.OptionAward == nil: + case len(shadow.OptionAward) == 0: return missingMemberError("ExecutiveCompensation", "optionAward") case shadow.IncentivePlanCompensation == nil: return missingMemberError("ExecutiveCompensation", "incentivePlanCompensation") @@ -787,6 +887,14 @@ func (m *ExecutiveCompensation) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.Link == nil: return missingMemberError("ExecutiveCompensation", "link") } + var optionAward *float64 + if shadow.OptionAward.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.OptionAward, &value); err != nil { + return err + } + optionAward = &value + } *m = ExecutiveCompensation{ CIK: *shadow.CIK, Symbol: *shadow.Symbol, @@ -798,7 +906,7 @@ func (m *ExecutiveCompensation) UnmarshalJSONFrom(dec *jsontext.Decoder) error { Salary: *shadow.Salary, Bonus: *shadow.Bonus, StockAward: *shadow.StockAward, - OptionAward: *shadow.OptionAward, + OptionAward: optionAward, IncentivePlanCompensation: *shadow.IncentivePlanCompensation, AllOtherCompensation: *shadow.AllOtherCompensation, Total: *shadow.Total, diff --git a/sdk/go/company_parity_test.go b/sdk/go/company_parity_test.go index f556570d..8c50bbf0 100644 --- a/sdk/go/company_parity_test.go +++ b/sdk/go/company_parity_test.go @@ -4,6 +4,7 @@ import ( "encoding/json/jsontext" "encoding/json/v2" "errors" + "reflect" "strings" "testing" ) @@ -52,15 +53,15 @@ func TestDocumentedCompanyProfileNoteAndPeerDecodeExactValues(t *testing.T) { want := CompanyProfile{ Symbol: "AAPL", Price: 331.85501, MarketCap: 4_874_072_686_740, Beta: 1.097, LastDividend: 1.05, Range: "201.5-344.57", Change: -6.33498, ChangePercentage: -1.8732, Volume: 28_718_014, - AverageVolume: 55_309_000, CompanyName: "Apple Inc.", Currency: "USD", CIK: "0000320193", - ISIN: "US0378331005", CUSIP: "037833100", ExchangeFullName: "NASDAQ Global Select", Exchange: "NASDAQ", + AverageVolume: 55_309_000, CompanyName: "Apple Inc.", Currency: "USD", CIK: new("0000320193"), + ISIN: "US0378331005", CUSIP: new("037833100"), ExchangeFullName: "NASDAQ Global Select", Exchange: "NASDAQ", Industry: "Consumer Electronics", Website: "https://www.apple.com", Description: profile.Description, - Ceo: "Timothy D. Cook", Sector: "Technology", Country: "US", FullTimeEmployees: "166000", - Phone: "(408) 996-1010", Address: "One Apple Park Way", City: "Cupertino", State: "CA", Zip: "95014", - Image: "https://images.financialmodelingprep.com/symbol/AAPL.png", IPODate: mustParseDate(t, "1980-12-12"), + Ceo: "Timothy D. Cook", Sector: "Technology", Country: "US", FullTimeEmployees: new("166000"), + Phone: new("(408) 996-1010"), Address: "One Apple Park Way", City: "Cupertino", State: "CA", Zip: "95014", + Image: "https://images.financialmodelingprep.com/symbol/AAPL.png", IPODate: new(mustParseDate(t, "1980-12-12")), DefaultImage: false, IsETF: false, IsActivelyTrading: true, IsAdr: false, IsFund: false, } - if profile != want { + if !reflect.DeepEqual(profile, want) { t.Fatalf("profile = %+v, want %+v", profile, want) } if !strings.HasPrefix(profile.Description, "Apple Inc. is a global technology corporation") { @@ -74,7 +75,8 @@ func TestDocumentedCompanyProfileNoteAndPeerDecodeExactValues(t *testing.T) { multiple := assertFixtureParity[CompanyProfile](t, "company_profile_multiple.json") if len(multiple) != 2 || multiple[0].MarketCap != 9_007_199_254_740_993 || multiple[0].Volume != 18_446_744_073_709_551_615 || - multiple[0].AverageVolume != 4_294_967_296 || multiple[0].FullTimeEmployees != "42" || + multiple[0].AverageVolume != 4_294_967_296 || multiple[0].FullTimeEmployees != nil || + multiple[0].CIK != nil || multiple[0].CUSIP != nil || multiple[0].Phone != nil || multiple[0].IPODate != nil || multiple[0].Sector != "Future Sector" || multiple[0].Industry != "Future Industry" { t.Fatalf("company_profile_multiple = %+v", multiple) } @@ -87,8 +89,8 @@ func TestDocumentedCompanyProfileNoteAndPeerDecodeExactValues(t *testing.T) { } notes := assertFixtureParity[CompanyNote](t, "company_note.json") - if want := (CompanyNote{CIK: "0000320193", Symbol: "AAPL", Title: "0.000% Notes due 2025", Exchange: "NASDAQ"}); len(notes) != 1 || - notes[0] != want { + if want := (CompanyNote{CIK: "0000320193", Symbol: "AAPL", Title: "0.000% Notes due 2025", Exchange: new("NASDAQ")}); len(notes) != 1 || + !reflect.DeepEqual(notes[0], want) { t.Fatalf("company_note = %+v", notes) } peers := assertFixtureParity[StockPeer](t, "stock_peer.json") @@ -120,24 +122,24 @@ func TestDocumentedWorkforceAndMarketDataFixturesDecodeExactValues(t *testing.T) } caps := assertFixtureParity[CompanyMarketCapitalization](t, "company_market_capitalization.json") - if want := (CompanyMarketCapitalization{Symbol: "AAPL", Date: mustParseDate(t, "2026-07-30"), MarketCap: 4_874_072_686_740}); len(caps) != 1 || - caps[0] != want { + if want := (CompanyMarketCapitalization{Symbol: "AAPL", Date: mustParseDate(t, "2026-07-30"), MarketCap: new(4_874_072_686_740.0)}); len(caps) != 1 || + !reflect.DeepEqual(caps[0], want) { t.Fatalf("company_market_capitalization = %+v", caps) } historical := assertFixtureParity[CompanyMarketCapitalization](t, "company_historical_market_capitalization.json") - if len(historical) != 1 || historical[0].Symbol != "AAPL" || historical[0].MarketCap != 4_879_177_245_542 { + if len(historical) != 1 || historical[0].Symbol != "AAPL" || historical[0].MarketCap == nil || *historical[0].MarketCap != 4_879_177_245_542 { t.Fatalf("company_historical_market_capitalization = %+v", historical) } floats := assertFixtureParity[CompanyShareFloat](t, "company_shares_float.json") if want := (CompanyShareFloat{Symbol: "AAPL", Date: mustParseDateTime(t, "2026-07-30 15:48:00"), FreeFloat: 99.83000000136171, FloatShares: 14_662_387_495, OutstandingShares: 14_687_356_000, - Source: "https://www.sec.gov/Archives/edgar/data/320193/000032019326000013/aapl-20260328.htm"}); len(floats) != 1 || - floats[0] != want { + Source: new("https://www.sec.gov/Archives/edgar/data/320193/000032019326000013/aapl-20260328.htm")}); len(floats) != 1 || + !reflect.DeepEqual(floats[0], want) { t.Fatalf("company_shares_float = %+v", floats) } all := assertFixtureParity[ShareFloat](t, "company_shares_float_all.json") - if want := (ShareFloat{Symbol: "000001.SZ", Date: mustParseDateTime(t, "2026-07-29 14:23:30"), FreeFloat: 41.40900000201062, - FloatShares: 8_035_796_667, OutstandingShares: 19_405_918_198}); len(all) != 1 || all[0] != want { + if want := (ShareFloat{Symbol: "000001.SZ", Date: mustParseDateTime(t, "2026-07-29 14:23:30"), FreeFloat: new(41.40900000201062), + FloatShares: new(8_035_796_667.0), OutstandingShares: 19_405_918_198}); len(all) != 1 || !reflect.DeepEqual(all[0], want) { t.Fatalf("company_all_shares_float = %+v", all) } } @@ -148,33 +150,34 @@ func TestDocumentedMergerAndGovernanceFixturesDecodeExactValues(t *testing.T) { t.Parallel() latest := assertFixtureParity[MergerAcquisition](t, "company_mergers_acquisitions_latest.json") if want := (MergerAcquisition{Symbol: "AGH", CompanyName: "Aureus Greenway Holdings Inc", CIK: "0002009312", - TargetedCompanyName: "Aureus Greenway Holdings, Inc.", TargetedCIK: "0002009312", TargetedSymbol: "PUSA", + TargetedCompanyName: "Aureus Greenway Holdings, Inc.", TargetedCIK: new("0002009312"), TargetedSymbol: new("PUSA"), TransactionDate: mustParseDate(t, "2026-07-29"), AcceptedDate: mustParseDateTime(t, "2026-07-29 16:00:46"), Link: "https://www.sec.gov/Archives/edgar/data/2009312/000149315226035181/forms-4.htm"}); len(latest) != 1 || - latest[0] != want { + !reflect.DeepEqual(latest[0], want) { t.Fatalf("company_latest_mergers_acquisitions = %+v", latest) } search := assertFixtureParity[MergerAcquisition](t, "company_mergers_acquisitions_search.json") - if len(search) != 1 || search[0].Symbol != "PEGY" || search[0].CIK != "0000022701" || search[0].TargetedSymbol != "JCS" || + if len(search) != 1 || search[0].Symbol != "PEGY" || search[0].CIK != "0000022701" || search[0].TargetedSymbol == nil || *search[0].TargetedSymbol != "JCS" || search[0].TransactionDate != mustParseDate(t, "2021-11-12") { t.Fatalf("company_search_mergers_acquisitions = %+v", search) } multiple := assertFixtureParity[MergerAcquisition](t, "company_mergers_acquisitions_multiple.json") - if len(multiple) != 2 || multiple[0].TargetedSymbol != "PUSA" || multiple[1].TargetedSymbol != "JCS" { + if len(multiple) != 2 || multiple[0].TargetedSymbol == nil || *multiple[0].TargetedSymbol != "PUSA" || + multiple[1].TargetedCIK != nil || multiple[1].TargetedSymbol != nil { t.Fatalf("company_mergers_acquisitions_multiple = %+v", multiple) } executives := assertFixtureParity[CompanyExecutive](t, "company_key_executives.json") if len(executives) != 1 || executives[0].Title != "Vice President of Worldwide Communications" || executives[0].Name != "Kristin Huguet Quayle" || executives[0].CurrencyPay != "USD" || - executives[0].Gender != "female" || !executives[0].Active { + executives[0].Gender == nil || *executives[0].Gender != "female" || !executives[0].Active { t.Fatalf("company_key_executives = %+v", executives) } if executives[0].Pay != nil || executives[0].YearBorn != nil || executives[0].TitleSince != nil { t.Fatalf("null dynamic members decoded as non-nil: %+v", executives[0]) } dynamic := assertFixtureParity[CompanyExecutive](t, "company_key_executives_dynamic.json") - if len(dynamic) != 2 || dynamic[1].Active { + if len(dynamic) != 2 || dynamic[1].Active || dynamic[1].Gender != nil { t.Fatalf("company_key_executives_dynamic = %+v", dynamic) } assertCanonicalJSON(t, dynamic[1].Pay, `{"amount":"00123.450","components":[1,true,null]}`) @@ -185,15 +188,15 @@ func TestDocumentedMergerAndGovernanceFixturesDecodeExactValues(t *testing.T) { if want := (ExecutiveCompensation{CIK: "0000320193", Symbol: "AAPL", CompanyName: "Apple Inc.", FilingDate: mustParseDate(t, "2026-01-08"), AcceptedDate: mustParseDateTime(t, "2026-01-08 16:31:36"), NameAndPosition: "Luca Maestri Former Senior Vice President, Chief Financial Officer", Year: 2025, Salary: 819_231, - Bonus: 0, StockAward: 13_003_031, OptionAward: 0, IncentivePlanCompensation: 1_638_462, + Bonus: 0, StockAward: 13_003_031, OptionAward: new(0.0), IncentivePlanCompensation: 1_638_462, AllOtherCompensation: 22_204, Total: 15_482_928, Link: "https://www.sec.gov/Archives/edgar/data/320193/000130817926000008/0001308179-26-000008-index.htm"}); len(compensation) != 1 || - compensation[0] != want { + !reflect.DeepEqual(compensation[0], want) { t.Fatalf("company_executive_compensation = %+v", compensation) } large := assertFixtureParity[ExecutiveCompensation](t, "company_executive_compensation_large.json") if len(large) != 2 || large[1].Salary != 5_000_000_000 || large[1].AllOtherCompensation != 10_000_000_000 || - large[1].Total != 18_446_744_073_709_551_615 { + large[1].Total != 18_446_744_073_709_551_615 || large[1].OptionAward != nil { t.Fatalf("company_executive_compensation_large = %+v", large) } benchmark := assertFixtureParity[ExecutiveCompensationBenchmark](t, "company_executive_compensation_benchmark.json") @@ -219,7 +222,7 @@ func TestCompanyRequiredMembersAreEnforcedLikeSerde(t *testing.T) { member string }{ {"missing member", `[{"symbol":"AAPL","marketCap":1}]`, "date"}, - {"null member", `[{"symbol":"AAPL","date":"2026-07-30","marketCap":null}]`, "marketCap"}, + {"null member", `[{"symbol":"AAPL","date":null,"marketCap":1}]`, "date"}, {"empty object", `[{}]`, "symbol"}, {"null element", `[null]`, "symbol"}, } @@ -236,6 +239,34 @@ func TestCompanyRequiredMembersAreEnforcedLikeSerde(t *testing.T) { assertCompanyDecodeError(t, json.Unmarshal([]byte(wire), &executives), "CompanyExecutive", "active") } +// Members FMP sends as null decode to nil like serde's None, while a missing +// key still fails as a missing required member. +func TestCompanyNullableMembersDecodeNullAsNil(t *testing.T) { + t.Parallel() + var notes []CompanyNote + if err := json.Unmarshal([]byte(`[{"cik":"0001577552","symbol":"BABA","title":"t","exchange":null}]`), ¬es); err != nil || + notes[0].Exchange != nil { + t.Fatalf("company note = %+v, %v", notes, err) + } + var caps []CompanyMarketCapitalization + if err := json.Unmarshal([]byte(`[{"symbol":"EURUSD","date":"2026-07-30","marketCap":null}]`), &caps); err != nil || + caps[0].MarketCap != nil { + t.Fatalf("market cap = %+v, %v", caps, err) + } + var floats []CompanyShareFloat + wire := `[{"symbol":"SPY","date":"2026-07-30 15:48:00","freeFloat":1,"floatShares":1,"outstandingShares":1,"source":null}]` + if err := json.Unmarshal([]byte(wire), &floats); err != nil || floats[0].Source != nil { + t.Fatalf("company share float = %+v, %v", floats, err) + } + var all []ShareFloat + wire = `[{"symbol":"0050.TW","date":"2026-07-29 14:23:30","freeFloat":null,"floatShares":null,"outstandingShares":1}]` + if err := json.Unmarshal([]byte(wire), &all); err != nil || all[0].FreeFloat != nil || all[0].FloatShares != nil { + t.Fatalf("share float = %+v, %v", all, err) + } + err := json.Unmarshal([]byte(`[{"cik":"0001577552","symbol":"BABA","title":"t"}]`), ¬es) + assertCompanyDecodeError(t, err, "CompanyNote", "exchange") +} + func assertCompanyDecodeError(t *testing.T, err error, model, member string) { t.Helper() var typed *Error diff --git a/sdk/go/company_test.go b/sdk/go/company_test.go index 5b5a525d..4b0bfa88 100644 --- a/sdk/go/company_test.go +++ b/sdk/go/company_test.go @@ -49,7 +49,7 @@ func TestCompanyMethodsUseExactPathsAndWireParameterOrder(t *testing.T) { ctx := context.Background() profile, err := client.Company.Profile(ctx, NewProfileQuery("AAPL")) - if err != nil || len(profile) != 1 || profile[0].CIK != "0000320193" { + if err != nil || len(profile) != 1 || profile[0].CIK == nil || *profile[0].CIK != "0000320193" { t.Fatalf("Profile = %+v, %v", profile, err) } byCIK, err := client.Company.ProfileByCIK(ctx, NewProfileByCIKQuery("0000320193")) @@ -57,11 +57,11 @@ func TestCompanyMethodsUseExactPathsAndWireParameterOrder(t *testing.T) { t.Fatalf("ProfileByCIK = %+v, %v", byCIK, err) } batch, err := client.Company.BatchMarketCapitalization(ctx, NewBatchMarketCapitalizationQuery([]string{"AAPL", "MSFT"})) - if err != nil || len(batch) != 1 || batch[0].MarketCap != 4_874_072_686_740 { + if err != nil || len(batch) != 1 || batch[0].MarketCap == nil || *batch[0].MarketCap != 4_874_072_686_740 { t.Fatalf("BatchMarketCapitalization = %+v, %v", batch, err) } search, err := client.Company.SearchMergersAcquisitions(ctx, NewSearchMergersAcquisitionsQuery("Pineapple Energy")) - if err != nil || len(search) != 1 || search[0].TargetedSymbol != "JCS" { + if err != nil || len(search) != 1 || search[0].TargetedSymbol == nil || *search[0].TargetedSymbol != "JCS" { t.Fatalf("SearchMergersAcquisitions = %+v, %v", search, err) } @@ -82,7 +82,7 @@ func TestCompanyMethodsUseExactPathsAndWireParameterOrder(t *testing.T) { to := mustParseDate(t, "2026-07-16") history, err := client.Company.HistoricalMarketCapitalization(ctx, NewHistoricalMarketCapitalizationQuery("AAPL").WithFrom(from)) - if err != nil || len(history) != 1 || history[0].MarketCap != 4_879_177_245_542 { + if err != nil || len(history) != 1 || history[0].MarketCap == nil || *history[0].MarketCap != 4_879_177_245_542 { t.Fatalf("HistoricalMarketCapitalization from = %+v, %v", history, err) } reversed, err := client.Company.HistoricalMarketCapitalization(ctx,