From fca258327bd257e5b98497d94c5745d35f46a589 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 22:04:30 +0800 Subject: [PATCH] feat(statements): make FMP-omittable statement members optional Fields that FMP can send as null are now decoded as optional across the Rust responses, the PyO3 models and stubs, and the Go SDK. This covers IncomeStatement's filing_date, accepted_date and interest_income; KeyMetrics and KeyMetricsTtm graham_number; FinancialScore's altman_z_score; AsReportedFinancialStatement's reported_currency; and FinancialEstimate's EBIT and EBITDA low/high/avg members. Each nullable member deserializes null to None (Option / pointer / Optional) and re-encodes back to null, while a missing key still fails as a required member. Tests across libfmp, fmp-py and the Go SDK assert the decode-as-none, reencode-as-null and still-required behaviour. BREAKING CHANGE: IncomeStatement, KeyMetrics, KeyMetricsTtm, FinancialScore, AsReportedFinancialStatement and FinancialEstimate members FMP can omit are now optional (Option / pointer / Optional); handle None. --- .../python/fmp/_native/analyst/__init__.pyi | 24 +- .../statements/as_reported/__init__.pyi | 4 +- .../_native/statements/income/__init__.pyi | 12 +- .../_native/statements/metrics/__init__.pyi | 8 +- .../_native/statements/summaries/__init__.pyi | 4 +- crates/fmp-py/src/models/analyst.rs | 24 +- .../src/models/statements/as_reported.rs | 6 +- crates/fmp-py/src/models/statements/income.rs | 16 +- .../fmp-py/src/models/statements/metrics.rs | 8 +- .../fmp-py/src/models/statements/summaries.rs | 4 +- crates/fmp-py/tests/test_analyst.py | 14 + crates/fmp-py/tests/test_statements_core.py | 20 + crates/fmp-py/tests/test_statements_growth.py | 11 + .../fmp-py/tests/test_statements_reports.py | 11 + crates/fmp-py/tests/typecheck_contract.py | 2 + crates/libfmp/src/responses/analyst.rs | 40 +- .../src/responses/statements/as_reported.rs | 13 +- .../libfmp/src/responses/statements/income.rs | 21 +- .../src/responses/statements/metrics.rs | 16 +- .../src/responses/statements/summaries.rs | 13 +- crates/libfmp/tests/analyst_responses.rs | 63 +- ...tements_as_reported_cash_full_responses.rs | 2 +- .../tests/statements_as_reported_responses.rs | 12 +- .../tests/statements_income_responses.rs | 42 +- .../tests/statements_metrics_responses.rs | 30 +- .../tests/statements_summary_responses.rs | 12 +- sdk/go/analyst_models.go | 128 ++-- sdk/go/analyst_parity_test.go | 22 +- sdk/go/statements_models.go | 628 ++++++++++-------- sdk/go/statements_parity_test.go | 61 +- sdk/go/statements_reports_parity_test.go | 7 +- sdk/go/statements_test.go | 4 +- 32 files changed, 830 insertions(+), 452 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/analyst/__init__.pyi b/crates/fmp-py/python/fmp/_native/analyst/__init__.pyi index cfafe0e5..3c3095ab 100644 --- a/crates/fmp-py/python/fmp/_native/analyst/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/analyst/__init__.pyi @@ -120,17 +120,17 @@ class FinancialEstimate: @property def revenue_avg(self) -> builtins.float: ... @property - def ebitda_low(self) -> builtins.float: ... + def ebitda_low(self) -> typing.Optional[builtins.float]: ... @property - def ebitda_high(self) -> builtins.float: ... + def ebitda_high(self) -> typing.Optional[builtins.float]: ... @property - def ebitda_avg(self) -> builtins.float: ... + def ebitda_avg(self) -> typing.Optional[builtins.float]: ... @property - def ebit_low(self) -> builtins.float: ... + def ebit_low(self) -> typing.Optional[builtins.float]: ... @property - def ebit_high(self) -> builtins.float: ... + def ebit_high(self) -> typing.Optional[builtins.float]: ... @property - def ebit_avg(self) -> builtins.float: ... + def ebit_avg(self) -> typing.Optional[builtins.float]: ... @property def net_income_low(self) -> builtins.float: ... @property @@ -162,12 +162,12 @@ class FinancialEstimate: revenue_low: builtins.float, revenue_high: builtins.float, revenue_avg: builtins.float, - ebitda_low: builtins.float, - ebitda_high: builtins.float, - ebitda_avg: builtins.float, - ebit_low: builtins.float, - ebit_high: builtins.float, - ebit_avg: builtins.float, + ebitda_low: typing.Optional[builtins.float], + ebitda_high: typing.Optional[builtins.float], + ebitda_avg: typing.Optional[builtins.float], + ebit_low: typing.Optional[builtins.float], + ebit_high: typing.Optional[builtins.float], + ebit_avg: typing.Optional[builtins.float], net_income_low: builtins.float, net_income_high: builtins.float, net_income_avg: builtins.float, diff --git a/crates/fmp-py/python/fmp/_native/statements/as_reported/__init__.pyi b/crates/fmp-py/python/fmp/_native/statements/as_reported/__init__.pyi index da3302e7..4219e6a3 100644 --- a/crates/fmp-py/python/fmp/_native/statements/as_reported/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/statements/as_reported/__init__.pyi @@ -27,7 +27,7 @@ class AsReportedFinancialStatement: @property def fiscal_year(self) -> builtins.int: ... @property - def reported_currency(self) -> builtins.str: ... + def reported_currency(self) -> typing.Optional[builtins.str]: ... @property def date(self) -> datetime.date: ... @property @@ -41,7 +41,7 @@ class AsReportedFinancialStatement: symbol: builtins.str, fiscal_year: builtins.int, period: typing.Literal["Q1", "Q2", "Q3", "Q4", "FY"], - reported_currency: builtins.str, + reported_currency: typing.Optional[builtins.str], date: datetime.date, data: builtins.str, ) -> AsReportedFinancialStatement: ... diff --git a/crates/fmp-py/python/fmp/_native/statements/income/__init__.pyi b/crates/fmp-py/python/fmp/_native/statements/income/__init__.pyi index 4b5adce2..88fa7881 100644 --- a/crates/fmp-py/python/fmp/_native/statements/income/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/statements/income/__init__.pyi @@ -64,9 +64,9 @@ class IncomeStatement: @property def cik(self) -> builtins.str: ... @property - def filing_date(self) -> datetime.date: ... + def filing_date(self) -> typing.Optional[datetime.date]: ... @property - def accepted_date(self) -> datetime.datetime: ... + def accepted_date(self) -> typing.Optional[datetime.datetime]: ... @property def fiscal_year(self) -> builtins.str: ... @property @@ -92,7 +92,7 @@ class IncomeStatement: @property def net_interest_income(self) -> builtins.float: ... @property - def interest_income(self) -> builtins.float: ... + def interest_income(self) -> typing.Optional[builtins.float]: ... @property def interest_expense(self) -> builtins.float: ... @property @@ -141,8 +141,8 @@ class IncomeStatement: symbol: builtins.str, reported_currency: builtins.str, cik: builtins.str, - filing_date: datetime.date, - accepted_date: datetime.datetime, + filing_date: typing.Optional[datetime.date], + accepted_date: typing.Optional[datetime.datetime], fiscal_year: builtins.str, period: typing.Literal["Q1", "Q2", "Q3", "Q4", "FY"], revenue: builtins.float, @@ -156,7 +156,7 @@ class IncomeStatement: operating_expenses: builtins.float, cost_and_expenses: builtins.float, net_interest_income: builtins.float, - interest_income: builtins.float, + interest_income: typing.Optional[builtins.float], interest_expense: builtins.float, depreciation_and_amortization: builtins.float, ebitda: builtins.float, diff --git a/crates/fmp-py/python/fmp/_native/statements/metrics/__init__.pyi b/crates/fmp-py/python/fmp/_native/statements/metrics/__init__.pyi index 8b3936be..6d13d3c1 100644 --- a/crates/fmp-py/python/fmp/_native/statements/metrics/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/statements/metrics/__init__.pyi @@ -91,7 +91,7 @@ class KeyMetrics: @property def income_quality(self) -> builtins.float: ... @property - def graham_number(self) -> builtins.float: ... + def graham_number(self) -> typing.Optional[builtins.float]: ... @property def graham_net_net(self) -> builtins.float: ... @property @@ -176,7 +176,7 @@ class KeyMetrics: net_debt_to_ebitda: builtins.float, current_ratio: builtins.float, income_quality: builtins.float, - graham_number: builtins.float, + graham_number: typing.Optional[builtins.float], graham_net_net: builtins.float, tax_burden: builtins.float, interest_burden: builtins.float, @@ -289,7 +289,7 @@ class KeyMetricsTtm: @property def income_quality_ttm(self) -> builtins.float: ... @property - def graham_number_ttm(self) -> builtins.float: ... + def graham_number_ttm(self) -> typing.Optional[builtins.float]: ... @property def graham_net_net_ttm(self) -> builtins.float: ... @property @@ -368,7 +368,7 @@ class KeyMetricsTtm: net_debt_to_ebitda_ttm: builtins.float, current_ratio_ttm: builtins.float, income_quality_ttm: builtins.float, - graham_number_ttm: builtins.float, + graham_number_ttm: typing.Optional[builtins.float], graham_net_net_ttm: builtins.float, tax_burden_ttm: builtins.float, interest_burden_ttm: builtins.float, diff --git a/crates/fmp-py/python/fmp/_native/statements/summaries/__init__.pyi b/crates/fmp-py/python/fmp/_native/statements/summaries/__init__.pyi index 8a81c2bc..a3dacffe 100644 --- a/crates/fmp-py/python/fmp/_native/statements/summaries/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/statements/summaries/__init__.pyi @@ -87,7 +87,7 @@ class FinancialScore: @property def reported_currency(self) -> builtins.str: ... @property - def altman_z_score(self) -> builtins.float: ... + def altman_z_score(self) -> typing.Optional[builtins.float]: ... @property def piotroski_score(self) -> builtins.int: ... @property @@ -110,7 +110,7 @@ class FinancialScore: *, symbol: builtins.str, reported_currency: builtins.str, - altman_z_score: builtins.float, + altman_z_score: typing.Optional[builtins.float], piotroski_score: builtins.int, working_capital: builtins.float, total_assets: builtins.float, diff --git a/crates/fmp-py/src/models/analyst.rs b/crates/fmp-py/src/models/analyst.rs index 6063f520..8f925092 100644 --- a/crates/fmp-py/src/models/analyst.rs +++ b/crates/fmp-py/src/models/analyst.rs @@ -20,17 +20,17 @@ pub(crate) struct FinancialEstimate { #[pyo3(get)] pub revenue_avg: f64, #[pyo3(get)] - pub ebitda_low: f64, + pub ebitda_low: Option, #[pyo3(get)] - pub ebitda_high: f64, + pub ebitda_high: Option, #[pyo3(get)] - pub ebitda_avg: f64, + pub ebitda_avg: Option, #[pyo3(get)] - pub ebit_low: f64, + pub ebit_low: Option, #[pyo3(get)] - pub ebit_high: f64, + pub ebit_high: Option, #[pyo3(get)] - pub ebit_avg: f64, + pub ebit_avg: Option, #[pyo3(get)] pub net_income_low: f64, #[pyo3(get)] @@ -68,12 +68,12 @@ impl FinancialEstimate { revenue_low: f64, revenue_high: f64, revenue_avg: f64, - ebitda_low: f64, - ebitda_high: f64, - ebitda_avg: f64, - ebit_low: f64, - ebit_high: f64, - ebit_avg: f64, + ebitda_low: Option, + ebitda_high: Option, + ebitda_avg: Option, + ebit_low: Option, + ebit_high: Option, + ebit_avg: Option, net_income_low: f64, net_income_high: f64, net_income_avg: f64, diff --git a/crates/fmp-py/src/models/statements/as_reported.rs b/crates/fmp-py/src/models/statements/as_reported.rs index e9ccd578..8f7686ab 100644 --- a/crates/fmp-py/src/models/statements/as_reported.rs +++ b/crates/fmp-py/src/models/statements/as_reported.rs @@ -20,7 +20,7 @@ pub(crate) struct AsReportedFinancialStatement { pub fiscal_year: u32, pub period: String, #[pyo3(get)] - pub reported_currency: String, + pub reported_currency: Option, #[pyo3(get)] pub date: ::chrono::NaiveDate, data: ::serde_json::Map, @@ -38,7 +38,7 @@ impl AsReportedFinancialStatement { fiscal_year: u32, #[gen_stub(override_type(type_repr = "typing.Literal[\"Q1\", \"Q2\", \"Q3\", \"Q4\", \"FY\"]", imports = ("builtins", "typing")))] period: String, - reported_currency: String, + reported_currency: Option, date: ::chrono::NaiveDate, data: String, ) -> PyResult { @@ -178,7 +178,7 @@ impl From, #[pyo3(get)] - pub accepted_date: ::chrono::NaiveDateTime, + pub accepted_date: Option<::chrono::NaiveDateTime>, #[pyo3(get)] pub fiscal_year: String, pub period: String, @@ -47,7 +47,7 @@ pub(crate) struct IncomeStatement { #[pyo3(get)] pub net_interest_income: f64, #[pyo3(get)] - pub interest_income: f64, + pub interest_income: Option, #[pyo3(get)] pub interest_expense: f64, #[pyo3(get)] @@ -100,8 +100,8 @@ impl IncomeStatement { symbol: String, reported_currency: String, cik: String, - filing_date: ::chrono::NaiveDate, - accepted_date: ::chrono::NaiveDateTime, + filing_date: Option<::chrono::NaiveDate>, + accepted_date: Option<::chrono::NaiveDateTime>, fiscal_year: String, #[gen_stub(override_type(type_repr = "typing.Literal[\"Q1\", \"Q2\", \"Q3\", \"Q4\", \"FY\"]", imports = ("builtins", "typing")))] period: String, @@ -116,7 +116,7 @@ impl IncomeStatement { operating_expenses: f64, cost_and_expenses: f64, net_interest_income: f64, - interest_income: f64, + interest_income: Option, interest_expense: f64, depreciation_and_amortization: f64, ebitda: f64, @@ -760,8 +760,8 @@ impl From for IncomeStat symbol: value.symbol.into_inner(), reported_currency: value.reported_currency.into_inner(), cik: value.cik.into_inner(), - filing_date: value.filing_date.into_inner(), - accepted_date: value.accepted_date.into_inner(), + filing_date: value.filing_date.map(|value| value.into_inner()), + accepted_date: value.accepted_date.map(|value| value.into_inner()), fiscal_year: value.fiscal_year.as_str().to_owned(), period: value.period.to_string(), revenue: value.revenue, diff --git a/crates/fmp-py/src/models/statements/metrics.rs b/crates/fmp-py/src/models/statements/metrics.rs index 6adbff45..cfe72670 100644 --- a/crates/fmp-py/src/models/statements/metrics.rs +++ b/crates/fmp-py/src/models/statements/metrics.rs @@ -37,7 +37,7 @@ pub(crate) struct KeyMetrics { #[pyo3(get)] pub income_quality: f64, #[pyo3(get)] - pub graham_number: f64, + pub graham_number: Option, #[pyo3(get)] pub graham_net_net: f64, #[pyo3(get)] @@ -127,7 +127,7 @@ impl KeyMetrics { net_debt_to_ebitda: f64, current_ratio: f64, income_quality: f64, - graham_number: f64, + graham_number: Option, graham_net_net: f64, tax_burden: f64, interest_burden: f64, @@ -989,7 +989,7 @@ pub(crate) struct KeyMetricsTtm { #[pyo3(get)] pub income_quality_ttm: f64, #[pyo3(get)] - pub graham_number_ttm: f64, + pub graham_number_ttm: Option, #[pyo3(get)] pub graham_net_net_ttm: f64, #[pyo3(get)] @@ -1074,7 +1074,7 @@ impl KeyMetricsTtm { net_debt_to_ebitda_ttm: f64, current_ratio_ttm: f64, income_quality_ttm: f64, - graham_number_ttm: f64, + graham_number_ttm: Option, graham_net_net_ttm: f64, tax_burden_ttm: f64, interest_burden_ttm: f64, diff --git a/crates/fmp-py/src/models/statements/summaries.rs b/crates/fmp-py/src/models/statements/summaries.rs index 18b0ad91..080c1e6d 100644 --- a/crates/fmp-py/src/models/statements/summaries.rs +++ b/crates/fmp-py/src/models/statements/summaries.rs @@ -163,7 +163,7 @@ pub(crate) struct FinancialScore { #[pyo3(get)] pub reported_currency: String, #[pyo3(get)] - pub altman_z_score: f64, + pub altman_z_score: Option, #[pyo3(get)] pub piotroski_score: u64, #[pyo3(get)] @@ -192,7 +192,7 @@ impl FinancialScore { fn new( symbol: String, reported_currency: String, - altman_z_score: f64, + altman_z_score: Option, piotroski_score: u64, working_capital: f64, total_assets: f64, diff --git a/crates/fmp-py/tests/test_analyst.py b/crates/fmp-py/tests/test_analyst.py index 243ca3b8..76fe3af6 100644 --- a/crates/fmp-py/tests/test_analyst.py +++ b/crates/fmp-py/tests/test_analyst.py @@ -58,6 +58,20 @@ def test_financial_estimates_annual_without_options(client: Any, fixture_server: assert row.num_analysts_eps == 7 +def test_null_ebit_estimates_decode_as_none(client: Any, fixture_server: FixtureServer) -> None: + """Issue #368: null EBIT and EBITDA estimate members decode as ``None``.""" + rows = load_fixture("financial_estimates.json") + members = ("ebitdaLow", "ebitdaHigh", "ebitdaAvg", "ebitLow", "ebitHigh", "ebitAvg") + for member in members: + rows[0][member] = None + fixture_server.route("/analyst-estimates", rows) + + row = client.analyst.financial_estimates("AAPL", "annual")[0] + assert (row.ebitda_low, row.ebitda_high, row.ebitda_avg) == (None, None, None) + assert (row.ebit_low, row.ebit_high, row.ebit_avg) == (None, None, None) + assert row.revenue_high == 735_022_980_353 + + def test_financial_estimates_quarter_with_every_option(client: Any, fixture_server: FixtureServer) -> None: """Encoding order is ``symbol``, ``period``, ``page``, ``limit``; ``0`` and ``u32::MAX`` are sent verbatim.""" fixture_server.route("/analyst-estimates", load_fixture("financial_estimates.json")) diff --git a/crates/fmp-py/tests/test_statements_core.py b/crates/fmp-py/tests/test_statements_core.py index bf1b739d..953033e1 100644 --- a/crates/fmp-py/tests/test_statements_core.py +++ b/crates/fmp-py/tests/test_statements_core.py @@ -143,6 +143,26 @@ def test_pre_ipo_stub_rows_decode_null_amounts_as_none(client: Any, fixture_serv assert cash_row.net_income == 112_010_000_000 +def test_nullable_income_and_metrics_members_decode_as_none(client: Any, fixture_server: FixtureServer) -> None: + """Issue #368: null income dates, ``interestIncome`` and Graham numbers decode as ``None``.""" + income = load_fixture("income_statement.json") + for member in ("interestIncome", "filingDate", "acceptedDate"): + income[0][member] = None + metrics = load_fixture("key_metrics.json") + metrics[0]["grahamNumber"] = None + metrics_ttm = load_fixture("key_metrics_ttm.json") + metrics_ttm[0]["grahamNumberTTM"] = None + fixture_server.route("/income-statement", income) + fixture_server.route("/key-metrics", metrics) + fixture_server.route("/key-metrics-ttm", metrics_ttm) + + income_row = client.statements.income.statement("AAPL")[0] + assert (income_row.interest_income, income_row.filing_date, income_row.accepted_date) == (None, None, None) + assert income_row.revenue == 416_161_000_000 + assert client.statements.metrics.key_metrics("AAPL")[0].graham_number is None + assert client.statements.metrics.key_metrics_ttm("AAPL")[0].graham_number_ttm is None + + def test_key_metrics_with_fiscal_quarter(client: Any, fixture_server: FixtureServer) -> None: """``metrics.key_metrics`` accepts a ``Q3`` fiscal quarter as the period.""" fixture_server.route("/key-metrics", load_fixture("key_metrics.json")) diff --git a/crates/fmp-py/tests/test_statements_growth.py b/crates/fmp-py/tests/test_statements_growth.py index 6e19a940..694791ef 100644 --- a/crates/fmp-py/tests/test_statements_growth.py +++ b/crates/fmp-py/tests/test_statements_growth.py @@ -108,6 +108,17 @@ def test_income_as_reported_with_annual_frequency(client: Any, fixture_server: F _assert_as_reported(rows[0], "comprehensiveincomenetoftax", 113_611_000_000) +def test_null_as_reported_currency_decodes_as_none(client: Any, fixture_server: FixtureServer) -> None: + """Issue #368: a null ``reportedCurrency`` on an as-reported row decodes as ``None``.""" + rows = load_fixture("income_statement_as_reported.json") + rows[0]["reportedCurrency"] = None + fixture_server.route("/income-statement-as-reported", rows) + + row = client.statements.as_reported.income("AAPL")[0] + assert row.reported_currency is None + assert row.symbol == "AAPL" + + def test_balance_sheet_as_reported_with_quarter_frequency(client: Any, fixture_server: FixtureServer) -> None: """``as_reported.balance_sheet`` takes ``quarter``.""" fixture_server.route( diff --git a/crates/fmp-py/tests/test_statements_reports.py b/crates/fmp-py/tests/test_statements_reports.py index dd251270..09f100b0 100644 --- a/crates/fmp-py/tests/test_statements_reports.py +++ b/crates/fmp-py/tests/test_statements_reports.py @@ -119,6 +119,17 @@ def test_latest_financial_statements_with_page_and_limit(client: Any, fixture_se client.statements.summaries.latest_financial_statements(0) +def test_null_altman_z_score_decodes_as_none(client: Any, fixture_server: FixtureServer) -> None: + """Issue #368: a null ``altmanZScore`` decodes as ``None``.""" + scores = load_fixture("financial_scores.json") + scores[0]["altmanZScore"] = None + fixture_server.route("/financial-scores", scores) + + row = client.statements.summaries.financial_scores("AAPL")[0] + assert row.altman_z_score is None + assert row.piotroski_score == 9 + + def test_financial_scores_takes_only_a_symbol(client: Any, fixture_server: FixtureServer) -> None: """``summaries.financial_scores`` sends the symbol alone.""" fixture_server.route("/financial-scores", load_fixture("financial_scores.json")) diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 1dab68c0..6be82201 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -117,6 +117,7 @@ def check_statements_contract(client: FmpClient) -> None: rows: list[IncomeStatement] = income.statement("AAPL", period="annual", limit=5) reported_on: datetime.date = rows[0].date revenue: float = rows[0].revenue + interest_income: float | None = rows[0].interest_income fiscal: Literal["Q1", "Q2", "Q3", "Q4", "FY"] = rows[0].period dates: list[FinancialReportDate] = client.statements.reports.dates("AAPL") link: str = dates[0].expose_secret_url_json() @@ -420,6 +421,7 @@ def check_analyst_contract(client: FmpClient) -> None: estimated_on: datetime.date = estimates[0].date revenue_high: float = estimates[0].revenue_high eps_avg: float = estimates[0].eps_avg + ebit_avg: float | None = estimates[0].ebit_avg consensus: list[PriceTargetConsensus] = client.analyst.price_target_consensus("AAPL") target_high: float = consensus[0].target_high grades: list[StockGrade] = client.analyst.stock_grades("AAPL") diff --git a/crates/libfmp/src/responses/analyst.rs b/crates/libfmp/src/responses/analyst.rs index 51359375..80373d3a 100644 --- a/crates/libfmp/src/responses/analyst.rs +++ b/crates/libfmp/src/responses/analyst.rs @@ -1,9 +1,17 @@ //! Response rows returned by analyst endpoints. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::types::{Count, Date, Price, StatementAmount, Ticker}; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One dated set of analyst financial estimates. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] @@ -17,18 +25,24 @@ pub struct FinancialEstimate { pub revenue_high: StatementAmount, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub revenue_avg: StatementAmount, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub ebitda_low: StatementAmount, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub ebitda_high: StatementAmount, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub ebitda_avg: StatementAmount, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub ebit_low: StatementAmount, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub ebit_high: StatementAmount, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub ebit_avg: StatementAmount, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub ebitda_low: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub ebitda_high: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub ebitda_avg: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub ebit_low: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub ebit_high: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub ebit_avg: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub net_income_low: StatementAmount, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] diff --git a/crates/libfmp/src/responses/statements/as_reported.rs b/crates/libfmp/src/responses/statements/as_reported.rs index 663784e7..c8d73d31 100644 --- a/crates/libfmp/src/responses/statements/as_reported.rs +++ b/crates/libfmp/src/responses/statements/as_reported.rs @@ -1,6 +1,6 @@ //! Shared envelope for financial statements as reported by a company. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ codecs::DynamicObject, @@ -8,6 +8,14 @@ use crate::{ types::{CalendarYear, CurrencyCode, Date, Ticker}, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One as-reported financial statement with provider-native data keys. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] @@ -16,7 +24,8 @@ pub struct AsReportedFinancialStatement { pub symbol: Ticker, pub fiscal_year: CalendarYear, pub period: FiscalPeriod, - pub reported_currency: CurrencyCode, + #[serde(deserialize_with = "required_option")] + pub reported_currency: Option, pub date: Date, pub data: DynamicObject, } diff --git a/crates/libfmp/src/responses/statements/income.rs b/crates/libfmp/src/responses/statements/income.rs index 528793fe..8f4c85b1 100644 --- a/crates/libfmp/src/responses/statements/income.rs +++ b/crates/libfmp/src/responses/statements/income.rs @@ -1,6 +1,6 @@ //! Income-statement response models. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ codecs::FiscalYearString, @@ -8,6 +8,14 @@ use crate::{ types::{ApiDateTime, Cik, CurrencyCode, Date, Quantity, StatementAmount, Ticker}, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One historical or trailing-twelve-month worldwide income statement. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] @@ -17,8 +25,10 @@ pub struct IncomeStatement { pub symbol: Ticker, pub reported_currency: CurrencyCode, pub cik: Cik, - pub filing_date: Date, - pub accepted_date: ApiDateTime, + #[serde(deserialize_with = "required_option")] + pub filing_date: Option, + #[serde(deserialize_with = "required_option")] + pub accepted_date: Option, pub fiscal_year: FiscalYearString, pub period: FiscalPeriod, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] @@ -43,8 +53,9 @@ pub struct IncomeStatement { pub cost_and_expenses: StatementAmount, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub net_interest_income: StatementAmount, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub interest_income: StatementAmount, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub interest_income: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub interest_expense: StatementAmount, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] diff --git a/crates/libfmp/src/responses/statements/metrics.rs b/crates/libfmp/src/responses/statements/metrics.rs index d1bf196a..a0248c7d 100644 --- a/crates/libfmp/src/responses/statements/metrics.rs +++ b/crates/libfmp/src/responses/statements/metrics.rs @@ -1,6 +1,6 @@ //! Historical and trailing-twelve-month key-metrics response models. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ codecs::FiscalYearString, @@ -8,6 +8,14 @@ use crate::{ types::{CurrencyCode, Date, MarketCapitalization, StatementAmount, Ticker}, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One historical worldwide key-metrics row. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] @@ -31,7 +39,8 @@ pub struct KeyMetrics { pub net_debt_to_ebitda: f64, pub current_ratio: f64, pub income_quality: f64, - pub graham_number: f64, + #[serde(deserialize_with = "required_option")] + pub graham_number: Option, pub graham_net_net: f64, pub tax_burden: f64, pub interest_burden: f64, @@ -105,8 +114,9 @@ pub struct KeyMetricsTtm { pub current_ratio_ttm: f64, #[serde(rename = "incomeQualityTTM")] pub income_quality_ttm: f64, + #[serde(deserialize_with = "required_option")] #[serde(rename = "grahamNumberTTM")] - pub graham_number_ttm: f64, + pub graham_number_ttm: Option, #[serde(rename = "grahamNetNetTTM")] pub graham_net_net_ttm: f64, #[serde(rename = "taxBurdenTTM")] diff --git a/crates/libfmp/src/responses/statements/summaries.rs b/crates/libfmp/src/responses/statements/summaries.rs index ff7c5d36..d5865dde 100644 --- a/crates/libfmp/src/responses/statements/summaries.rs +++ b/crates/libfmp/src/responses/statements/summaries.rs @@ -1,6 +1,6 @@ //! Compact financial-summary response models. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ codecs::FiscalYearString, @@ -11,6 +11,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 recently added financial-statement filing. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] @@ -30,7 +38,8 @@ pub struct LatestFinancialStatement { pub struct FinancialScore { pub symbol: Ticker, pub reported_currency: CurrencyCode, - pub altman_z_score: f64, + #[serde(deserialize_with = "required_option")] + pub altman_z_score: Option, #[serde(deserialize_with = "crate::codecs::count::deserialize")] pub piotroski_score: Count, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] diff --git a/crates/libfmp/tests/analyst_responses.rs b/crates/libfmp/tests/analyst_responses.rs index bfcf08bd..bedeecf1 100644 --- a/crates/libfmp/tests/analyst_responses.rs +++ b/crates/libfmp/tests/analyst_responses.rs @@ -24,6 +24,15 @@ const GRADES_SUMMARY: &[u8] = include_bytes!("fixtures/stock_grades_summary.json const PUBLISHERS: &str = "[\"StreetInsider\",\"TheFly\",\"Benzinga\",\"Pulse 2.0\",\"TipRanks Contributor\",\"MarketWatch\",\"Investing\",\"Barrons\",\"Investor's Business Daily\"]"; +const ESTIMATE_NULLABLE: [&str; 6] = [ + "ebitdaLow", + "ebitdaHigh", + "ebitdaAvg", + "ebitLow", + "ebitHigh", + "ebitAvg", +]; + #[test] fn exact_financial_estimate_decodes_all_22_fields() { assert_field_count(ESTIMATES, 22); @@ -37,12 +46,12 @@ fn exact_financial_estimate_decodes_all_22_fields() { revenue_low: 648_228_509_004.0, revenue_high: 735_022_980_353.0, revenue_avg: 679_000_000_000.0, - ebitda_low: 233_968_328_102.0, - ebitda_high: 265_295_486_763.0, - ebitda_avg: 245_074_834_838.0, - ebit_low: 217_109_092_822.0, - ebit_high: 246_178_886_382.0, - ebit_avg: 227_415_289_483.0, + ebitda_low: Some(233_968_328_102.0), + ebitda_high: Some(265_295_486_763.0), + ebitda_avg: Some(245_074_834_838.0), + ebit_low: Some(217_109_092_822.0), + ebit_high: Some(246_178_886_382.0), + ebit_avg: Some(227_415_289_483.0), net_income_low: 191_547_261_069.0, net_income_high: 225_370_398_908.0, net_income_avg: 203_538_714_818.0, @@ -193,15 +202,32 @@ fn estimate_amounts_are_signed_and_counts_preserve_the_full_u64_domain() { } #[test] -fn all_fields_are_required_non_null_and_unknowns_are_accepted() { - assert_contract::(ESTIMATES); - assert_contract::(SNAPSHOT); - assert_contract::(HISTORICAL_RATINGS); - assert_contract::(TARGET_SUMMARY); - assert_contract::(TARGET_CONSENSUS); - assert_contract::(GRADES); - assert_contract::(HISTORICAL_GRADES); - assert_contract::(GRADES_SUMMARY); +fn all_fields_are_required_only_estimate_ebit_members_are_nullable_and_unknowns_are_accepted() { + assert_contract::(ESTIMATES, &ESTIMATE_NULLABLE); + assert_contract::(SNAPSHOT, &[]); + assert_contract::(HISTORICAL_RATINGS, &[]); + assert_contract::(TARGET_SUMMARY, &[]); + assert_contract::(TARGET_CONSENSUS, &[]); + assert_contract::(GRADES, &[]); + assert_contract::(HISTORICAL_GRADES, &[]); + assert_contract::(GRADES_SUMMARY, &[]); +} + +#[test] +fn null_estimate_ebit_members_decode_as_none_and_reencode_as_null() { + let mut value: serde_json::Value = serde_json::from_slice(ESTIMATES).unwrap(); + for member in ESTIMATE_NULLABLE { + value[0][member] = serde_json::Value::Null; + } + + let rows: Vec = serde_json::from_value(value).unwrap(); + assert_eq!(rows[0].ebit_avg, None); + assert_eq!(rows[0].ebitda_low, None); + + let encoded = serde_json::to_value(&rows).unwrap(); + for member in ESTIMATE_NULLABLE { + assert!(encoded[0][member].is_null(), "{member}"); + } } #[test] @@ -220,7 +246,7 @@ fn all_eight_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() @@ -237,9 +263,10 @@ fn assert_contract(fixture: &[u8]) { ); let mut null = source.clone(); null[0][&key] = serde_json::Value::Null; - assert!( + assert_eq!( serde_json::from_value::>(null).is_err(), - "accepted null {key}" + !nullable.contains(&key.as_str()), + "null {key}" ); } let mut forward = source; diff --git a/crates/libfmp/tests/statements_as_reported_cash_full_responses.rs b/crates/libfmp/tests/statements_as_reported_cash_full_responses.rs index 73e858a8..d104b758 100644 --- a/crates/libfmp/tests/statements_as_reported_cash_full_responses.rs +++ b/crates/libfmp/tests/statements_as_reported_cash_full_responses.rs @@ -192,6 +192,6 @@ fn assert_envelope(row: &AsReportedFinancialStatement) { assert_eq!(row.symbol.as_str(), "AAPL"); assert_eq!(row.fiscal_year.get(), 2025); assert_eq!(row.period.to_string(), "FY"); - assert_eq!(row.reported_currency.as_str(), "USD"); + assert_eq!(row.reported_currency.as_ref().unwrap().as_str(), "USD"); assert_eq!(row.date.to_string(), "2025-09-26"); } diff --git a/crates/libfmp/tests/statements_as_reported_responses.rs b/crates/libfmp/tests/statements_as_reported_responses.rs index ebea3507..201a1a4c 100644 --- a/crates/libfmp/tests/statements_as_reported_responses.rs +++ b/crates/libfmp/tests/statements_as_reported_responses.rs @@ -113,7 +113,7 @@ fn assert_envelope(row: &AsReportedFinancialStatement) { assert_eq!(row.symbol.as_str(), "AAPL"); assert_eq!(row.fiscal_year.get(), 2025); assert_eq!(row.period.to_string(), "FY"); - assert_eq!(row.reported_currency.as_str(), "USD"); + assert_eq!(row.reported_currency.as_ref().unwrap().as_str(), "USD"); assert_eq!(row.date.to_string(), "2025-09-26"); } @@ -187,3 +187,13 @@ fn dynamic_data_is_open_while_the_shared_bare_array_shape_preserves_empty_and_mu let wrapped = serde_json::json!({ "incomeStatementAsReported": income }); assert!(serde_json::from_value::>(wrapped).is_err()); } + +#[test] +fn null_reported_currency_decodes_as_none_and_reencodes_as_null() { + let mut value: serde_json::Value = serde_json::from_slice(INCOME).unwrap(); + value[0]["reportedCurrency"] = serde_json::Value::Null; + + let rows: Vec = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(rows[0].reported_currency, None); + assert_eq!(serde_json::to_value(&rows).unwrap(), value); +} diff --git a/crates/libfmp/tests/statements_income_responses.rs b/crates/libfmp/tests/statements_income_responses.rs index b1ae1b36..e597a517 100644 --- a/crates/libfmp/tests/statements_income_responses.rs +++ b/crates/libfmp/tests/statements_income_responses.rs @@ -23,7 +23,10 @@ fn historical_fixture_decodes_all_39_documented_fields_exactly() { assert_eq!(rows[0].revenue, 416_161_000_000.0); assert_eq!(rows[0].total_other_income_expenses_net, -321_000_000.0); assert_eq!(rows[0].cik.as_str(), "0000320193"); - assert_eq!(rows[0].accepted_date.to_string(), "2025-10-31 06:01:26"); + assert_eq!( + rows[0].accepted_date.unwrap().to_string(), + "2025-10-31 06:01:26" + ); } #[test] @@ -49,6 +52,31 @@ fn fiscal_year_rejects_a_numeric_json_value() { assert!(error.to_string().contains("string")); } +#[test] +fn nullable_members_decode_null_as_none_and_reencode_as_null() { + let mut value: serde_json::Value = serde_json::from_slice(HISTORICAL).unwrap(); + for member in ["interestIncome", "filingDate", "acceptedDate"] { + value[0][member] = serde_json::Value::Null; + } + + let rows: Vec = serde_json::from_value(value).unwrap(); + assert_eq!(rows[0].interest_income, None); + assert_eq!(rows[0].filing_date, None); + assert_eq!(rows[0].accepted_date, None); + + let encoded = serde_json::to_value(&rows).unwrap(); + for member in ["interestIncome", "filingDate", "acceptedDate"] { + assert!(encoded[0][member].is_null(), "{member}"); + } +} + +#[test] +fn nullable_members_stay_required_keys() { + let mut value: serde_json::Value = serde_json::from_slice(HISTORICAL).unwrap(); + value[0].as_object_mut().unwrap().remove("interestIncome"); + assert!(serde_json::from_value::>(value).is_err()); +} + #[test] fn statement_amounts_preserve_large_integral_values_and_share_quantities_keep_fractions() { for (field, extreme) in [ @@ -74,8 +102,8 @@ fn assert_historical(row: &IncomeStatement) { symbol: Ticker::new("AAPL").unwrap(), reported_currency: CurrencyCode::new("USD").unwrap(), cik: Cik::new("0000320193").unwrap(), - filing_date: Date::from_str("2025-10-31").unwrap(), - accepted_date: ApiDateTime::from_str("2025-10-31 06:01:26").unwrap(), + filing_date: Some(Date::from_str("2025-10-31").unwrap()), + accepted_date: Some(ApiDateTime::from_str("2025-10-31 06:01:26").unwrap()), fiscal_year: FiscalYearString::new("2025").unwrap(), period: FiscalPeriod::FullYear, revenue: 416_161_000_000.0, @@ -89,7 +117,7 @@ fn assert_historical(row: &IncomeStatement) { operating_expenses: 62_151_000_000.0, cost_and_expenses: 283_111_000_000.0, net_interest_income: 0.0, - interest_income: 0.0, + interest_income: Some(0.0), interest_expense: 0.0, depreciation_and_amortization: 11_698_000_000.0, ebitda: 144_427_000_000.0, @@ -121,8 +149,8 @@ fn assert_ttm(row: &IncomeStatement) { symbol: Ticker::new("AAPL").unwrap(), reported_currency: CurrencyCode::new("USD").unwrap(), cik: Cik::new("0000320193").unwrap(), - filing_date: Date::from_str("2026-05-01").unwrap(), - accepted_date: ApiDateTime::from_str("2026-05-01 10:01:00").unwrap(), + filing_date: Some(Date::from_str("2026-05-01").unwrap()), + accepted_date: Some(ApiDateTime::from_str("2026-05-01 10:01:00").unwrap()), fiscal_year: FiscalYearString::new("2026").unwrap(), period: FiscalPeriod::Q2, revenue: 451_442_000_000.0, @@ -136,7 +164,7 @@ fn assert_ttm(row: &IncomeStatement) { operating_expenses: 68_705_000_000.0, cost_and_expenses: 304_076_000_000.0, net_interest_income: 0.0, - interest_income: 0.0, + interest_income: Some(0.0), interest_expense: 0.0, depreciation_and_amortization: 12_610_000_000.0, ebitda: 160_332_000_000.0, diff --git a/crates/libfmp/tests/statements_metrics_responses.rs b/crates/libfmp/tests/statements_metrics_responses.rs index 9ab9766d..f87463b1 100644 --- a/crates/libfmp/tests/statements_metrics_responses.rs +++ b/crates/libfmp/tests/statements_metrics_responses.rs @@ -38,7 +38,7 @@ fn documented_key_metrics_decodes_all_47_exact_fields() { net_debt_to_ebitda: 0.5292846905357032, current_ratio: 0.8932929222186667, income_quality: 0.995286135166503, - graham_number: 28.8371936709443, + graham_number: Some(28.8371936709443), graham_net_net: -11.588738000468274, tax_burden: 0.8438999766441395, interest_burden: 1.0, @@ -94,7 +94,7 @@ fn documented_key_metrics_ttm_decodes_all_43_exact_fields() { net_debt_to_ebitda_ttm: 0.3017675822667964, current_ratio_ttm: 1.07035746912159, income_quality_ttm: 1.1439689985723027, - graham_number_ttm: 36.83959035988331, + graham_number_ttm: Some(36.83959035988331), graham_net_net_ttm: -10.37184248926531, tax_burden_ttm: 0.8300602695198754, interest_burden_ttm: 1.0, @@ -132,12 +132,27 @@ fn documented_key_metrics_ttm_decodes_all_43_exact_fields() { } #[test] -fn every_documented_field_is_required_and_non_nullable() { - assert_every_field_required_and_non_null::(HISTORICAL); - assert_every_field_required_and_non_null::(TTM); +fn every_documented_field_is_required_and_only_graham_number_is_nullable() { + assert_every_field_required_and_non_null::(HISTORICAL, "grahamNumber"); + assert_every_field_required_and_non_null::(TTM, "grahamNumberTTM"); } -fn assert_every_field_required_and_non_null(fixture: &[u8]) { +#[test] +fn null_graham_number_decodes_as_none_and_reencodes_as_null() { + let mut historical: serde_json::Value = serde_json::from_slice(HISTORICAL).unwrap(); + historical[0]["grahamNumber"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(historical).unwrap(); + assert_eq!(rows[0].graham_number, None); + assert!(serde_json::to_value(&rows).unwrap()[0]["grahamNumber"].is_null()); + + let mut ttm: serde_json::Value = serde_json::from_slice(TTM).unwrap(); + ttm[0]["grahamNumberTTM"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(ttm).unwrap(); + assert_eq!(rows[0].graham_number_ttm, None); + assert!(serde_json::to_value(&rows).unwrap()[0]["grahamNumberTTM"].is_null()); +} + +fn assert_every_field_required_and_non_null(fixture: &[u8], nullable: &str) { let source: serde_json::Value = serde_json::from_slice(fixture).unwrap(); let keys = source[0] .as_object() @@ -156,8 +171,9 @@ fn assert_every_field_required_and_non_null(fixture: &[u8]) let mut null = source.clone(); null[0][&key] = serde_json::Value::Null; - assert!( + assert_eq!( serde_json::from_value::>(null).is_err(), + key != nullable, "accepted null required field {key}" ); } diff --git a/crates/libfmp/tests/statements_summary_responses.rs b/crates/libfmp/tests/statements_summary_responses.rs index d235b7ad..d2eeccaf 100644 --- a/crates/libfmp/tests/statements_summary_responses.rs +++ b/crates/libfmp/tests/statements_summary_responses.rs @@ -50,7 +50,7 @@ fn documented_financial_score_decodes_all_eleven_exact_fields() { [FinancialScore { symbol: Ticker::new("AAPL").unwrap(), reported_currency: CurrencyCode::new("USD").unwrap(), - altman_z_score: 14.041374927993303, + altman_z_score: Some(14.041374927993303), piotroski_score: 9, working_capital: 9_473_000_000.0, total_assets: 371_082_000_000.0, @@ -63,6 +63,16 @@ fn documented_financial_score_decodes_all_eleven_exact_fields() { ); } +#[test] +fn null_altman_z_score_decodes_as_none_and_reencodes_as_null() { + let mut value: serde_json::Value = serde_json::from_slice(SCORES).unwrap(); + value[0]["altmanZScore"] = serde_json::Value::Null; + + let rows: Vec = serde_json::from_value(value).unwrap(); + assert_eq!(rows[0].altman_z_score, None); + assert!(serde_json::to_value(&rows).unwrap()[0]["altmanZScore"].is_null()); +} + #[test] fn documented_owner_earnings_decodes_exact_average_ppe_and_signed_fields() { let value: serde_json::Value = serde_json::from_slice(OWNER).unwrap(); diff --git a/sdk/go/analyst_models.go b/sdk/go/analyst_models.go index 98735d2d..080f858f 100644 --- a/sdk/go/analyst_models.go +++ b/sdk/go/analyst_models.go @@ -12,28 +12,28 @@ import ( // FinancialEstimate is one dated set of analyst financial estimates. type FinancialEstimate struct { - Symbol string `json:"symbol"` - Date Date `json:"date"` - RevenueLow float64 `json:"revenueLow"` - RevenueHigh float64 `json:"revenueHigh"` - RevenueAvg float64 `json:"revenueAvg"` - EbitdaLow float64 `json:"ebitdaLow"` - EbitdaHigh float64 `json:"ebitdaHigh"` - EbitdaAvg float64 `json:"ebitdaAvg"` - EbitLow float64 `json:"ebitLow"` - EbitHigh float64 `json:"ebitHigh"` - EbitAvg float64 `json:"ebitAvg"` - NetIncomeLow float64 `json:"netIncomeLow"` - NetIncomeHigh float64 `json:"netIncomeHigh"` - NetIncomeAvg float64 `json:"netIncomeAvg"` - SgaExpenseLow float64 `json:"sgaExpenseLow"` - SgaExpenseHigh float64 `json:"sgaExpenseHigh"` - SgaExpenseAvg float64 `json:"sgaExpenseAvg"` - EPSAvg float64 `json:"epsAvg"` - EPSHigh float64 `json:"epsHigh"` - EPSLow float64 `json:"epsLow"` - NumAnalystsRevenue uint64 `json:"numAnalystsRevenue"` - NumAnalystsEPS uint64 `json:"numAnalystsEps"` + Symbol string `json:"symbol"` + Date Date `json:"date"` + RevenueLow float64 `json:"revenueLow"` + RevenueHigh float64 `json:"revenueHigh"` + RevenueAvg float64 `json:"revenueAvg"` + EbitdaLow *float64 `json:"ebitdaLow"` + EbitdaHigh *float64 `json:"ebitdaHigh"` + EbitdaAvg *float64 `json:"ebitdaAvg"` + EbitLow *float64 `json:"ebitLow"` + EbitHigh *float64 `json:"ebitHigh"` + EbitAvg *float64 `json:"ebitAvg"` + NetIncomeLow float64 `json:"netIncomeLow"` + NetIncomeHigh float64 `json:"netIncomeHigh"` + NetIncomeAvg float64 `json:"netIncomeAvg"` + SgaExpenseLow float64 `json:"sgaExpenseLow"` + SgaExpenseHigh float64 `json:"sgaExpenseHigh"` + SgaExpenseAvg float64 `json:"sgaExpenseAvg"` + EPSAvg float64 `json:"epsAvg"` + EPSHigh float64 `json:"epsHigh"` + EPSLow float64 `json:"epsLow"` + NumAnalystsRevenue uint64 `json:"numAnalystsRevenue"` + NumAnalystsEPS uint64 `json:"numAnalystsEps"` } // financialEstimateShadow mirrors FinancialEstimate with a pointer or raw @@ -45,12 +45,12 @@ type financialEstimateShadow struct { RevenueLow *float64 `json:"revenueLow"` RevenueHigh *float64 `json:"revenueHigh"` RevenueAvg *float64 `json:"revenueAvg"` - EbitdaLow *float64 `json:"ebitdaLow"` - EbitdaHigh *float64 `json:"ebitdaHigh"` - EbitdaAvg *float64 `json:"ebitdaAvg"` - EbitLow *float64 `json:"ebitLow"` - EbitHigh *float64 `json:"ebitHigh"` - EbitAvg *float64 `json:"ebitAvg"` + EbitdaLow jsontext.Value `json:"ebitdaLow"` + EbitdaHigh jsontext.Value `json:"ebitdaHigh"` + EbitdaAvg jsontext.Value `json:"ebitdaAvg"` + EbitLow jsontext.Value `json:"ebitLow"` + EbitHigh jsontext.Value `json:"ebitHigh"` + EbitAvg jsontext.Value `json:"ebitAvg"` NetIncomeLow *float64 `json:"netIncomeLow"` NetIncomeHigh *float64 `json:"netIncomeHigh"` NetIncomeAvg *float64 `json:"netIncomeAvg"` @@ -83,17 +83,17 @@ func (m *FinancialEstimate) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("FinancialEstimate", "revenueHigh") case shadow.RevenueAvg == nil: return missingMemberError("FinancialEstimate", "revenueAvg") - case shadow.EbitdaLow == nil: + case len(shadow.EbitdaLow) == 0: return missingMemberError("FinancialEstimate", "ebitdaLow") - case shadow.EbitdaHigh == nil: + case len(shadow.EbitdaHigh) == 0: return missingMemberError("FinancialEstimate", "ebitdaHigh") - case shadow.EbitdaAvg == nil: + case len(shadow.EbitdaAvg) == 0: return missingMemberError("FinancialEstimate", "ebitdaAvg") - case shadow.EbitLow == nil: + case len(shadow.EbitLow) == 0: return missingMemberError("FinancialEstimate", "ebitLow") - case shadow.EbitHigh == nil: + case len(shadow.EbitHigh) == 0: return missingMemberError("FinancialEstimate", "ebitHigh") - case shadow.EbitAvg == nil: + case len(shadow.EbitAvg) == 0: return missingMemberError("FinancialEstimate", "ebitAvg") case shadow.NetIncomeLow == nil: return missingMemberError("FinancialEstimate", "netIncomeLow") @@ -118,6 +118,54 @@ func (m *FinancialEstimate) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case len(shadow.NumAnalystsEPS) == 0: return missingMemberError("FinancialEstimate", "numAnalystsEps") } + var ebitdaLow *float64 + if shadow.EbitdaLow.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.EbitdaLow, &value); err != nil { + return err + } + ebitdaLow = &value + } + var ebitdaHigh *float64 + if shadow.EbitdaHigh.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.EbitdaHigh, &value); err != nil { + return err + } + ebitdaHigh = &value + } + var ebitdaAvg *float64 + if shadow.EbitdaAvg.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.EbitdaAvg, &value); err != nil { + return err + } + ebitdaAvg = &value + } + var ebitLow *float64 + if shadow.EbitLow.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.EbitLow, &value); err != nil { + return err + } + ebitLow = &value + } + var ebitHigh *float64 + if shadow.EbitHigh.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.EbitHigh, &value); err != nil { + return err + } + ebitHigh = &value + } + var ebitAvg *float64 + if shadow.EbitAvg.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.EbitAvg, &value); err != nil { + return err + } + ebitAvg = &value + } numAnalystsRevenue, err := decodeCount("FinancialEstimate", "numAnalystsRevenue", shadow.NumAnalystsRevenue) if err != nil { return err @@ -132,12 +180,12 @@ func (m *FinancialEstimate) UnmarshalJSONFrom(dec *jsontext.Decoder) error { RevenueLow: *shadow.RevenueLow, RevenueHigh: *shadow.RevenueHigh, RevenueAvg: *shadow.RevenueAvg, - EbitdaLow: *shadow.EbitdaLow, - EbitdaHigh: *shadow.EbitdaHigh, - EbitdaAvg: *shadow.EbitdaAvg, - EbitLow: *shadow.EbitLow, - EbitHigh: *shadow.EbitHigh, - EbitAvg: *shadow.EbitAvg, + EbitdaLow: ebitdaLow, + EbitdaHigh: ebitdaHigh, + EbitdaAvg: ebitdaAvg, + EbitLow: ebitLow, + EbitHigh: ebitHigh, + EbitAvg: ebitAvg, NetIncomeLow: *shadow.NetIncomeLow, NetIncomeHigh: *shadow.NetIncomeHigh, NetIncomeAvg: *shadow.NetIncomeAvg, diff --git a/sdk/go/analyst_parity_test.go b/sdk/go/analyst_parity_test.go index 8e60fc80..8f34672a 100644 --- a/sdk/go/analyst_parity_test.go +++ b/sdk/go/analyst_parity_test.go @@ -5,6 +5,7 @@ import ( "encoding/json/v2" "errors" "math" + "reflect" "strings" "testing" "time" @@ -44,14 +45,14 @@ func TestDocumentedFinancialEstimateDecodesAll22Fields(t *testing.T) { want := FinancialEstimate{ Symbol: "AAPL", Date: mustNewDate(t, 2030, time.September, 27), RevenueLow: 648_228_509_004, RevenueHigh: 735_022_980_353, RevenueAvg: 679_000_000_000, - EbitdaLow: 233_968_328_102, EbitdaHigh: 265_295_486_763, EbitdaAvg: 245_074_834_838, - EbitLow: 217_109_092_822, EbitHigh: 246_178_886_382, EbitAvg: 227_415_289_483, + EbitdaLow: new(233_968_328_102.0), EbitdaHigh: new(265_295_486_763.0), EbitdaAvg: new(245_074_834_838.0), + EbitLow: new(217_109_092_822.0), EbitHigh: new(246_178_886_382.0), EbitAvg: new(227_415_289_483.0), NetIncomeLow: 191_547_261_069, NetIncomeHigh: 225_370_398_908, NetIncomeAvg: 203_538_714_818, SgaExpenseLow: 41_721_580_524, SgaExpenseHigh: 47_307_886_087, SgaExpenseAvg: 43_702_109_337, EPSAvg: 13.565, EPSHigh: 15.01999, EPSLow: 12.76582, NumAnalystsRevenue: 16, NumAnalystsEPS: 7, } - if len(rows) != 1 || rows[0] != want { + if len(rows) != 1 || !reflect.DeepEqual(rows[0], want) { t.Fatalf("financial_estimates = %+v, want %+v", rows, want) } if got := memberSet(t, rows[0]); len(got) != 22 { @@ -179,3 +180,18 @@ func TestAnalystRequiredMembersAreEnforcedLikeSerde(t *testing.T) { t.Fatal("a malformed date decoded into a Date member") } } + +// The EBIT and EBITDA estimate members FMP sends as null (issue #368 audit), +// mirroring null_estimate_ebit_members_decode_as_none_and_reencode_as_null. +func TestNullableFinancialEstimateMembersDecodeNullAsNil(t *testing.T) { + t.Parallel() + members := []string{"ebitdaLow", "ebitdaHigh", "ebitdaAvg", "ebitLow", "ebitHigh", "ebitAvg"} + var rows []FinancialEstimate + if err := json.Unmarshal(statementsWithNullMembers(t, "financial_estimates.json", members), &rows); err != nil { + t.Fatalf("financial estimate: %v", err) + } + if rows[0].EbitAvg != nil || rows[0].EbitdaLow != nil || rows[0].RevenueAvg == 0 { + t.Fatalf("financial estimate = %+v", rows[0]) + } + assertStubMembersReencodeNull(t, rows[0], members) +} diff --git a/sdk/go/statements_models.go b/sdk/go/statements_models.go index 742e1433..b003b76f 100644 --- a/sdk/go/statements_models.go +++ b/sdk/go/statements_models.go @@ -16,7 +16,7 @@ type AsReportedFinancialStatement struct { Symbol string `json:"symbol"` FiscalYear uint32 `json:"fiscalYear"` Period string `json:"period"` - ReportedCurrency string `json:"reportedCurrency"` + ReportedCurrency *string `json:"reportedCurrency"` Date Date `json:"date"` Data jsontext.Value `json:"data"` } @@ -28,7 +28,7 @@ type asReportedFinancialStatementShadow struct { Symbol *string `json:"symbol"` FiscalYear *uint32 `json:"fiscalYear"` Period *string `json:"period"` - ReportedCurrency *string `json:"reportedCurrency"` + ReportedCurrency jsontext.Value `json:"reportedCurrency"` Date *Date `json:"date"` Data *jsontext.Value `json:"data"` } @@ -48,13 +48,21 @@ func (m *AsReportedFinancialStatement) UnmarshalJSONFrom(dec *jsontext.Decoder) return missingMemberError("AsReportedFinancialStatement", "fiscalYear") case shadow.Period == nil: return missingMemberError("AsReportedFinancialStatement", "period") - case shadow.ReportedCurrency == nil: + case len(shadow.ReportedCurrency) == 0: return missingMemberError("AsReportedFinancialStatement", "reportedCurrency") case shadow.Date == nil: return missingMemberError("AsReportedFinancialStatement", "date") case shadow.Data == nil: return missingMemberError("AsReportedFinancialStatement", "data") } + var reportedCurrency *string + if shadow.ReportedCurrency.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.ReportedCurrency, &value); err != nil { + return err + } + reportedCurrency = &value + } if shadow.Data.Kind() != '{' { return invalidMemberError("AsReportedFinancialStatement", "data", "object") } @@ -62,7 +70,7 @@ func (m *AsReportedFinancialStatement) UnmarshalJSONFrom(dec *jsontext.Decoder) Symbol: *shadow.Symbol, FiscalYear: *shadow.FiscalYear, Period: *shadow.Period, - ReportedCurrency: *shadow.ReportedCurrency, + ReportedCurrency: reportedCurrency, Date: *shadow.Date, Data: *shadow.Data, } @@ -2426,90 +2434,90 @@ func (m *IncomeStatementGrowth) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // IncomeStatement is one historical or trailing-twelve-month worldwide income // statement. type IncomeStatement struct { - Date Date `json:"date"` - Symbol string `json:"symbol"` - ReportedCurrency string `json:"reportedCurrency"` - CIK string `json:"cik"` - FilingDate Date `json:"filingDate"` - AcceptedDate DateTime `json:"acceptedDate"` - FiscalYear string `json:"fiscalYear"` - Period string `json:"period"` - Revenue float64 `json:"revenue"` - CostOfRevenue float64 `json:"costOfRevenue"` - GrossProfit float64 `json:"grossProfit"` - ResearchAndDevelopmentExpenses float64 `json:"researchAndDevelopmentExpenses"` - GeneralAndAdministrativeExpenses float64 `json:"generalAndAdministrativeExpenses"` - SellingAndMarketingExpenses float64 `json:"sellingAndMarketingExpenses"` - SellingGeneralAndAdministrativeExpenses float64 `json:"sellingGeneralAndAdministrativeExpenses"` - OtherExpenses float64 `json:"otherExpenses"` - OperatingExpenses float64 `json:"operatingExpenses"` - CostAndExpenses float64 `json:"costAndExpenses"` - NetInterestIncome float64 `json:"netInterestIncome"` - InterestIncome float64 `json:"interestIncome"` - InterestExpense float64 `json:"interestExpense"` - DepreciationAndAmortization float64 `json:"depreciationAndAmortization"` - Ebitda float64 `json:"ebitda"` - Ebit float64 `json:"ebit"` - NonOperatingIncomeExcludingInterest float64 `json:"nonOperatingIncomeExcludingInterest"` - OperatingIncome float64 `json:"operatingIncome"` - TotalOtherIncomeExpensesNet float64 `json:"totalOtherIncomeExpensesNet"` - IncomeBeforeTax float64 `json:"incomeBeforeTax"` - IncomeTaxExpense float64 `json:"incomeTaxExpense"` - NetIncomeFromContinuingOperations float64 `json:"netIncomeFromContinuingOperations"` - NetIncomeFromDiscontinuedOperations float64 `json:"netIncomeFromDiscontinuedOperations"` - OtherAdjustmentsToNetIncome float64 `json:"otherAdjustmentsToNetIncome"` - NetIncome float64 `json:"netIncome"` - NetIncomeDeductions float64 `json:"netIncomeDeductions"` - BottomLineNetIncome float64 `json:"bottomLineNetIncome"` - EPS float64 `json:"eps"` - EPSDiluted float64 `json:"epsDiluted"` - WeightedAverageShsOut float64 `json:"weightedAverageShsOut"` - WeightedAverageShsOutDil float64 `json:"weightedAverageShsOutDil"` + Date Date `json:"date"` + Symbol string `json:"symbol"` + ReportedCurrency string `json:"reportedCurrency"` + CIK string `json:"cik"` + FilingDate *Date `json:"filingDate"` + AcceptedDate *DateTime `json:"acceptedDate"` + FiscalYear string `json:"fiscalYear"` + Period string `json:"period"` + Revenue float64 `json:"revenue"` + CostOfRevenue float64 `json:"costOfRevenue"` + GrossProfit float64 `json:"grossProfit"` + ResearchAndDevelopmentExpenses float64 `json:"researchAndDevelopmentExpenses"` + GeneralAndAdministrativeExpenses float64 `json:"generalAndAdministrativeExpenses"` + SellingAndMarketingExpenses float64 `json:"sellingAndMarketingExpenses"` + SellingGeneralAndAdministrativeExpenses float64 `json:"sellingGeneralAndAdministrativeExpenses"` + OtherExpenses float64 `json:"otherExpenses"` + OperatingExpenses float64 `json:"operatingExpenses"` + CostAndExpenses float64 `json:"costAndExpenses"` + NetInterestIncome float64 `json:"netInterestIncome"` + InterestIncome *float64 `json:"interestIncome"` + InterestExpense float64 `json:"interestExpense"` + DepreciationAndAmortization float64 `json:"depreciationAndAmortization"` + Ebitda float64 `json:"ebitda"` + Ebit float64 `json:"ebit"` + NonOperatingIncomeExcludingInterest float64 `json:"nonOperatingIncomeExcludingInterest"` + OperatingIncome float64 `json:"operatingIncome"` + TotalOtherIncomeExpensesNet float64 `json:"totalOtherIncomeExpensesNet"` + IncomeBeforeTax float64 `json:"incomeBeforeTax"` + IncomeTaxExpense float64 `json:"incomeTaxExpense"` + NetIncomeFromContinuingOperations float64 `json:"netIncomeFromContinuingOperations"` + NetIncomeFromDiscontinuedOperations float64 `json:"netIncomeFromDiscontinuedOperations"` + OtherAdjustmentsToNetIncome float64 `json:"otherAdjustmentsToNetIncome"` + NetIncome float64 `json:"netIncome"` + NetIncomeDeductions float64 `json:"netIncomeDeductions"` + BottomLineNetIncome float64 `json:"bottomLineNetIncome"` + EPS float64 `json:"eps"` + EPSDiluted float64 `json:"epsDiluted"` + WeightedAverageShsOut float64 `json:"weightedAverageShsOut"` + WeightedAverageShsOutDil float64 `json:"weightedAverageShsOutDil"` } // incomeStatementShadow mirrors IncomeStatement with a pointer or raw value // for every required member so a missing or null member is observable after // decoding. type incomeStatementShadow struct { - Date *Date `json:"date"` - Symbol *string `json:"symbol"` - ReportedCurrency *string `json:"reportedCurrency"` - CIK *string `json:"cik"` - FilingDate *Date `json:"filingDate"` - AcceptedDate *DateTime `json:"acceptedDate"` - FiscalYear *string `json:"fiscalYear"` - Period *string `json:"period"` - Revenue *float64 `json:"revenue"` - CostOfRevenue *float64 `json:"costOfRevenue"` - GrossProfit *float64 `json:"grossProfit"` - ResearchAndDevelopmentExpenses *float64 `json:"researchAndDevelopmentExpenses"` - GeneralAndAdministrativeExpenses *float64 `json:"generalAndAdministrativeExpenses"` - SellingAndMarketingExpenses *float64 `json:"sellingAndMarketingExpenses"` - SellingGeneralAndAdministrativeExpenses *float64 `json:"sellingGeneralAndAdministrativeExpenses"` - OtherExpenses *float64 `json:"otherExpenses"` - OperatingExpenses *float64 `json:"operatingExpenses"` - CostAndExpenses *float64 `json:"costAndExpenses"` - NetInterestIncome *float64 `json:"netInterestIncome"` - InterestIncome *float64 `json:"interestIncome"` - InterestExpense *float64 `json:"interestExpense"` - DepreciationAndAmortization *float64 `json:"depreciationAndAmortization"` - Ebitda *float64 `json:"ebitda"` - Ebit *float64 `json:"ebit"` - NonOperatingIncomeExcludingInterest *float64 `json:"nonOperatingIncomeExcludingInterest"` - OperatingIncome *float64 `json:"operatingIncome"` - TotalOtherIncomeExpensesNet *float64 `json:"totalOtherIncomeExpensesNet"` - IncomeBeforeTax *float64 `json:"incomeBeforeTax"` - IncomeTaxExpense *float64 `json:"incomeTaxExpense"` - NetIncomeFromContinuingOperations *float64 `json:"netIncomeFromContinuingOperations"` - NetIncomeFromDiscontinuedOperations *float64 `json:"netIncomeFromDiscontinuedOperations"` - OtherAdjustmentsToNetIncome *float64 `json:"otherAdjustmentsToNetIncome"` - NetIncome *float64 `json:"netIncome"` - NetIncomeDeductions *float64 `json:"netIncomeDeductions"` - BottomLineNetIncome *float64 `json:"bottomLineNetIncome"` - EPS *float64 `json:"eps"` - EPSDiluted *float64 `json:"epsDiluted"` - WeightedAverageShsOut *float64 `json:"weightedAverageShsOut"` - WeightedAverageShsOutDil *float64 `json:"weightedAverageShsOutDil"` + Date *Date `json:"date"` + Symbol *string `json:"symbol"` + ReportedCurrency *string `json:"reportedCurrency"` + CIK *string `json:"cik"` + FilingDate jsontext.Value `json:"filingDate"` + AcceptedDate jsontext.Value `json:"acceptedDate"` + FiscalYear *string `json:"fiscalYear"` + Period *string `json:"period"` + Revenue *float64 `json:"revenue"` + CostOfRevenue *float64 `json:"costOfRevenue"` + GrossProfit *float64 `json:"grossProfit"` + ResearchAndDevelopmentExpenses *float64 `json:"researchAndDevelopmentExpenses"` + GeneralAndAdministrativeExpenses *float64 `json:"generalAndAdministrativeExpenses"` + SellingAndMarketingExpenses *float64 `json:"sellingAndMarketingExpenses"` + SellingGeneralAndAdministrativeExpenses *float64 `json:"sellingGeneralAndAdministrativeExpenses"` + OtherExpenses *float64 `json:"otherExpenses"` + OperatingExpenses *float64 `json:"operatingExpenses"` + CostAndExpenses *float64 `json:"costAndExpenses"` + NetInterestIncome *float64 `json:"netInterestIncome"` + InterestIncome jsontext.Value `json:"interestIncome"` + InterestExpense *float64 `json:"interestExpense"` + DepreciationAndAmortization *float64 `json:"depreciationAndAmortization"` + Ebitda *float64 `json:"ebitda"` + Ebit *float64 `json:"ebit"` + NonOperatingIncomeExcludingInterest *float64 `json:"nonOperatingIncomeExcludingInterest"` + OperatingIncome *float64 `json:"operatingIncome"` + TotalOtherIncomeExpensesNet *float64 `json:"totalOtherIncomeExpensesNet"` + IncomeBeforeTax *float64 `json:"incomeBeforeTax"` + IncomeTaxExpense *float64 `json:"incomeTaxExpense"` + NetIncomeFromContinuingOperations *float64 `json:"netIncomeFromContinuingOperations"` + NetIncomeFromDiscontinuedOperations *float64 `json:"netIncomeFromDiscontinuedOperations"` + OtherAdjustmentsToNetIncome *float64 `json:"otherAdjustmentsToNetIncome"` + NetIncome *float64 `json:"netIncome"` + NetIncomeDeductions *float64 `json:"netIncomeDeductions"` + BottomLineNetIncome *float64 `json:"bottomLineNetIncome"` + EPS *float64 `json:"eps"` + EPSDiluted *float64 `json:"epsDiluted"` + WeightedAverageShsOut *float64 `json:"weightedAverageShsOut"` + WeightedAverageShsOutDil *float64 `json:"weightedAverageShsOutDil"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -2529,9 +2537,9 @@ func (m *IncomeStatement) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("IncomeStatement", "reportedCurrency") case shadow.CIK == nil: return missingMemberError("IncomeStatement", "cik") - case shadow.FilingDate == nil: + case len(shadow.FilingDate) == 0: return missingMemberError("IncomeStatement", "filingDate") - case shadow.AcceptedDate == nil: + case len(shadow.AcceptedDate) == 0: return missingMemberError("IncomeStatement", "acceptedDate") case shadow.FiscalYear == nil: return missingMemberError("IncomeStatement", "fiscalYear") @@ -2559,7 +2567,7 @@ func (m *IncomeStatement) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("IncomeStatement", "costAndExpenses") case shadow.NetInterestIncome == nil: return missingMemberError("IncomeStatement", "netInterestIncome") - case shadow.InterestIncome == nil: + case len(shadow.InterestIncome) == 0: return missingMemberError("IncomeStatement", "interestIncome") case shadow.InterestExpense == nil: return missingMemberError("IncomeStatement", "interestExpense") @@ -2600,13 +2608,37 @@ func (m *IncomeStatement) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.WeightedAverageShsOutDil == nil: return missingMemberError("IncomeStatement", "weightedAverageShsOutDil") } + var filingDate *Date + if shadow.FilingDate.Kind() != 'n' { + var value Date + if err := json.Unmarshal(shadow.FilingDate, &value); err != nil { + return err + } + filingDate = &value + } + var acceptedDate *DateTime + if shadow.AcceptedDate.Kind() != 'n' { + var value DateTime + if err := json.Unmarshal(shadow.AcceptedDate, &value); err != nil { + return err + } + acceptedDate = &value + } + var interestIncome *float64 + if shadow.InterestIncome.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.InterestIncome, &value); err != nil { + return err + } + interestIncome = &value + } *m = IncomeStatement{ Date: *shadow.Date, Symbol: *shadow.Symbol, ReportedCurrency: *shadow.ReportedCurrency, CIK: *shadow.CIK, - FilingDate: *shadow.FilingDate, - AcceptedDate: *shadow.AcceptedDate, + FilingDate: filingDate, + AcceptedDate: acceptedDate, FiscalYear: *shadow.FiscalYear, Period: *shadow.Period, Revenue: *shadow.Revenue, @@ -2620,7 +2652,7 @@ func (m *IncomeStatement) UnmarshalJSONFrom(dec *jsontext.Decoder) error { OperatingExpenses: *shadow.OperatingExpenses, CostAndExpenses: *shadow.CostAndExpenses, NetInterestIncome: *shadow.NetInterestIncome, - InterestIncome: *shadow.InterestIncome, + InterestIncome: interestIncome, InterestExpense: *shadow.InterestExpense, DepreciationAndAmortization: *shadow.DepreciationAndAmortization, Ebitda: *shadow.Ebitda, @@ -2646,105 +2678,105 @@ func (m *IncomeStatement) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // KeyMetrics is one historical worldwide key-metrics row. type KeyMetrics struct { - Symbol string `json:"symbol"` - Date Date `json:"date"` - FiscalYear string `json:"fiscalYear"` - Period string `json:"period"` - ReportedCurrency string `json:"reportedCurrency"` - MarketCap float64 `json:"marketCap"` - EnterpriseValue float64 `json:"enterpriseValue"` - EvToSales float64 `json:"evToSales"` - EvToOperatingCashFlow float64 `json:"evToOperatingCashFlow"` - EvToFreeCashFlow float64 `json:"evToFreeCashFlow"` - EvToEbitda float64 `json:"evToEBITDA"` - NetDebtToEbitda float64 `json:"netDebtToEBITDA"` - CurrentRatio float64 `json:"currentRatio"` - IncomeQuality float64 `json:"incomeQuality"` - GrahamNumber float64 `json:"grahamNumber"` - GrahamNetNet float64 `json:"grahamNetNet"` - TaxBurden float64 `json:"taxBurden"` - InterestBurden float64 `json:"interestBurden"` - WorkingCapital float64 `json:"workingCapital"` - InvestedCapital float64 `json:"investedCapital"` - ReturnOnAssets float64 `json:"returnOnAssets"` - OperatingReturnOnAssets float64 `json:"operatingReturnOnAssets"` - ReturnOnTangibleAssets float64 `json:"returnOnTangibleAssets"` - ReturnOnEquity float64 `json:"returnOnEquity"` - ReturnOnInvestedCapital float64 `json:"returnOnInvestedCapital"` - ReturnOnCapitalEmployed float64 `json:"returnOnCapitalEmployed"` - EarningsYield float64 `json:"earningsYield"` - FreeCashFlowYield float64 `json:"freeCashFlowYield"` - CapexToOperatingCashFlow float64 `json:"capexToOperatingCashFlow"` - CapexToDepreciation float64 `json:"capexToDepreciation"` - CapexToRevenue float64 `json:"capexToRevenue"` - SalesGeneralAndAdministrativeToRevenue float64 `json:"salesGeneralAndAdministrativeToRevenue"` - ResearchAndDevelopementToRevenue float64 `json:"researchAndDevelopementToRevenue"` - StockBasedCompensationToRevenue float64 `json:"stockBasedCompensationToRevenue"` - IntangiblesToTotalAssets float64 `json:"intangiblesToTotalAssets"` - AverageReceivables float64 `json:"averageReceivables"` - AveragePayables float64 `json:"averagePayables"` - AverageInventory float64 `json:"averageInventory"` - DaysOfSalesOutstanding float64 `json:"daysOfSalesOutstanding"` - DaysOfPayablesOutstanding float64 `json:"daysOfPayablesOutstanding"` - DaysOfInventoryOutstanding float64 `json:"daysOfInventoryOutstanding"` - OperatingCycle float64 `json:"operatingCycle"` - CashConversionCycle float64 `json:"cashConversionCycle"` - FreeCashFlowToEquity float64 `json:"freeCashFlowToEquity"` - FreeCashFlowToFirm float64 `json:"freeCashFlowToFirm"` - TangibleAssetValue float64 `json:"tangibleAssetValue"` - NetCurrentAssetValue float64 `json:"netCurrentAssetValue"` + Symbol string `json:"symbol"` + Date Date `json:"date"` + FiscalYear string `json:"fiscalYear"` + Period string `json:"period"` + ReportedCurrency string `json:"reportedCurrency"` + MarketCap float64 `json:"marketCap"` + EnterpriseValue float64 `json:"enterpriseValue"` + EvToSales float64 `json:"evToSales"` + EvToOperatingCashFlow float64 `json:"evToOperatingCashFlow"` + EvToFreeCashFlow float64 `json:"evToFreeCashFlow"` + EvToEbitda float64 `json:"evToEBITDA"` + NetDebtToEbitda float64 `json:"netDebtToEBITDA"` + CurrentRatio float64 `json:"currentRatio"` + IncomeQuality float64 `json:"incomeQuality"` + GrahamNumber *float64 `json:"grahamNumber"` + GrahamNetNet float64 `json:"grahamNetNet"` + TaxBurden float64 `json:"taxBurden"` + InterestBurden float64 `json:"interestBurden"` + WorkingCapital float64 `json:"workingCapital"` + InvestedCapital float64 `json:"investedCapital"` + ReturnOnAssets float64 `json:"returnOnAssets"` + OperatingReturnOnAssets float64 `json:"operatingReturnOnAssets"` + ReturnOnTangibleAssets float64 `json:"returnOnTangibleAssets"` + ReturnOnEquity float64 `json:"returnOnEquity"` + ReturnOnInvestedCapital float64 `json:"returnOnInvestedCapital"` + ReturnOnCapitalEmployed float64 `json:"returnOnCapitalEmployed"` + EarningsYield float64 `json:"earningsYield"` + FreeCashFlowYield float64 `json:"freeCashFlowYield"` + CapexToOperatingCashFlow float64 `json:"capexToOperatingCashFlow"` + CapexToDepreciation float64 `json:"capexToDepreciation"` + CapexToRevenue float64 `json:"capexToRevenue"` + SalesGeneralAndAdministrativeToRevenue float64 `json:"salesGeneralAndAdministrativeToRevenue"` + ResearchAndDevelopementToRevenue float64 `json:"researchAndDevelopementToRevenue"` + StockBasedCompensationToRevenue float64 `json:"stockBasedCompensationToRevenue"` + IntangiblesToTotalAssets float64 `json:"intangiblesToTotalAssets"` + AverageReceivables float64 `json:"averageReceivables"` + AveragePayables float64 `json:"averagePayables"` + AverageInventory float64 `json:"averageInventory"` + DaysOfSalesOutstanding float64 `json:"daysOfSalesOutstanding"` + DaysOfPayablesOutstanding float64 `json:"daysOfPayablesOutstanding"` + DaysOfInventoryOutstanding float64 `json:"daysOfInventoryOutstanding"` + OperatingCycle float64 `json:"operatingCycle"` + CashConversionCycle float64 `json:"cashConversionCycle"` + FreeCashFlowToEquity float64 `json:"freeCashFlowToEquity"` + FreeCashFlowToFirm float64 `json:"freeCashFlowToFirm"` + TangibleAssetValue float64 `json:"tangibleAssetValue"` + NetCurrentAssetValue float64 `json:"netCurrentAssetValue"` } // keyMetricsShadow mirrors KeyMetrics with a pointer or raw value for every // required member so a missing or null member is observable after decoding. type keyMetricsShadow struct { - Symbol *string `json:"symbol"` - Date *Date `json:"date"` - FiscalYear *string `json:"fiscalYear"` - Period *string `json:"period"` - ReportedCurrency *string `json:"reportedCurrency"` - MarketCap *float64 `json:"marketCap"` - EnterpriseValue *float64 `json:"enterpriseValue"` - EvToSales *float64 `json:"evToSales"` - EvToOperatingCashFlow *float64 `json:"evToOperatingCashFlow"` - EvToFreeCashFlow *float64 `json:"evToFreeCashFlow"` - EvToEbitda *float64 `json:"evToEBITDA"` - NetDebtToEbitda *float64 `json:"netDebtToEBITDA"` - CurrentRatio *float64 `json:"currentRatio"` - IncomeQuality *float64 `json:"incomeQuality"` - GrahamNumber *float64 `json:"grahamNumber"` - GrahamNetNet *float64 `json:"grahamNetNet"` - TaxBurden *float64 `json:"taxBurden"` - InterestBurden *float64 `json:"interestBurden"` - WorkingCapital *float64 `json:"workingCapital"` - InvestedCapital *float64 `json:"investedCapital"` - ReturnOnAssets *float64 `json:"returnOnAssets"` - OperatingReturnOnAssets *float64 `json:"operatingReturnOnAssets"` - ReturnOnTangibleAssets *float64 `json:"returnOnTangibleAssets"` - ReturnOnEquity *float64 `json:"returnOnEquity"` - ReturnOnInvestedCapital *float64 `json:"returnOnInvestedCapital"` - ReturnOnCapitalEmployed *float64 `json:"returnOnCapitalEmployed"` - EarningsYield *float64 `json:"earningsYield"` - FreeCashFlowYield *float64 `json:"freeCashFlowYield"` - CapexToOperatingCashFlow *float64 `json:"capexToOperatingCashFlow"` - CapexToDepreciation *float64 `json:"capexToDepreciation"` - CapexToRevenue *float64 `json:"capexToRevenue"` - SalesGeneralAndAdministrativeToRevenue *float64 `json:"salesGeneralAndAdministrativeToRevenue"` - ResearchAndDevelopementToRevenue *float64 `json:"researchAndDevelopementToRevenue"` - StockBasedCompensationToRevenue *float64 `json:"stockBasedCompensationToRevenue"` - IntangiblesToTotalAssets *float64 `json:"intangiblesToTotalAssets"` - AverageReceivables *float64 `json:"averageReceivables"` - AveragePayables *float64 `json:"averagePayables"` - AverageInventory *float64 `json:"averageInventory"` - DaysOfSalesOutstanding *float64 `json:"daysOfSalesOutstanding"` - DaysOfPayablesOutstanding *float64 `json:"daysOfPayablesOutstanding"` - DaysOfInventoryOutstanding *float64 `json:"daysOfInventoryOutstanding"` - OperatingCycle *float64 `json:"operatingCycle"` - CashConversionCycle *float64 `json:"cashConversionCycle"` - FreeCashFlowToEquity *float64 `json:"freeCashFlowToEquity"` - FreeCashFlowToFirm *float64 `json:"freeCashFlowToFirm"` - TangibleAssetValue *float64 `json:"tangibleAssetValue"` - NetCurrentAssetValue *float64 `json:"netCurrentAssetValue"` + Symbol *string `json:"symbol"` + Date *Date `json:"date"` + FiscalYear *string `json:"fiscalYear"` + Period *string `json:"period"` + ReportedCurrency *string `json:"reportedCurrency"` + MarketCap *float64 `json:"marketCap"` + EnterpriseValue *float64 `json:"enterpriseValue"` + EvToSales *float64 `json:"evToSales"` + EvToOperatingCashFlow *float64 `json:"evToOperatingCashFlow"` + EvToFreeCashFlow *float64 `json:"evToFreeCashFlow"` + EvToEbitda *float64 `json:"evToEBITDA"` + NetDebtToEbitda *float64 `json:"netDebtToEBITDA"` + CurrentRatio *float64 `json:"currentRatio"` + IncomeQuality *float64 `json:"incomeQuality"` + GrahamNumber jsontext.Value `json:"grahamNumber"` + GrahamNetNet *float64 `json:"grahamNetNet"` + TaxBurden *float64 `json:"taxBurden"` + InterestBurden *float64 `json:"interestBurden"` + WorkingCapital *float64 `json:"workingCapital"` + InvestedCapital *float64 `json:"investedCapital"` + ReturnOnAssets *float64 `json:"returnOnAssets"` + OperatingReturnOnAssets *float64 `json:"operatingReturnOnAssets"` + ReturnOnTangibleAssets *float64 `json:"returnOnTangibleAssets"` + ReturnOnEquity *float64 `json:"returnOnEquity"` + ReturnOnInvestedCapital *float64 `json:"returnOnInvestedCapital"` + ReturnOnCapitalEmployed *float64 `json:"returnOnCapitalEmployed"` + EarningsYield *float64 `json:"earningsYield"` + FreeCashFlowYield *float64 `json:"freeCashFlowYield"` + CapexToOperatingCashFlow *float64 `json:"capexToOperatingCashFlow"` + CapexToDepreciation *float64 `json:"capexToDepreciation"` + CapexToRevenue *float64 `json:"capexToRevenue"` + SalesGeneralAndAdministrativeToRevenue *float64 `json:"salesGeneralAndAdministrativeToRevenue"` + ResearchAndDevelopementToRevenue *float64 `json:"researchAndDevelopementToRevenue"` + StockBasedCompensationToRevenue *float64 `json:"stockBasedCompensationToRevenue"` + IntangiblesToTotalAssets *float64 `json:"intangiblesToTotalAssets"` + AverageReceivables *float64 `json:"averageReceivables"` + AveragePayables *float64 `json:"averagePayables"` + AverageInventory *float64 `json:"averageInventory"` + DaysOfSalesOutstanding *float64 `json:"daysOfSalesOutstanding"` + DaysOfPayablesOutstanding *float64 `json:"daysOfPayablesOutstanding"` + DaysOfInventoryOutstanding *float64 `json:"daysOfInventoryOutstanding"` + OperatingCycle *float64 `json:"operatingCycle"` + CashConversionCycle *float64 `json:"cashConversionCycle"` + FreeCashFlowToEquity *float64 `json:"freeCashFlowToEquity"` + FreeCashFlowToFirm *float64 `json:"freeCashFlowToFirm"` + TangibleAssetValue *float64 `json:"tangibleAssetValue"` + NetCurrentAssetValue *float64 `json:"netCurrentAssetValue"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -2784,7 +2816,7 @@ func (m *KeyMetrics) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("KeyMetrics", "currentRatio") case shadow.IncomeQuality == nil: return missingMemberError("KeyMetrics", "incomeQuality") - case shadow.GrahamNumber == nil: + case len(shadow.GrahamNumber) == 0: return missingMemberError("KeyMetrics", "grahamNumber") case shadow.GrahamNetNet == nil: return missingMemberError("KeyMetrics", "grahamNetNet") @@ -2851,6 +2883,14 @@ func (m *KeyMetrics) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.NetCurrentAssetValue == nil: return missingMemberError("KeyMetrics", "netCurrentAssetValue") } + var grahamNumber *float64 + if shadow.GrahamNumber.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.GrahamNumber, &value); err != nil { + return err + } + grahamNumber = &value + } *m = KeyMetrics{ Symbol: *shadow.Symbol, Date: *shadow.Date, @@ -2866,7 +2906,7 @@ func (m *KeyMetrics) UnmarshalJSONFrom(dec *jsontext.Decoder) error { NetDebtToEbitda: *shadow.NetDebtToEbitda, CurrentRatio: *shadow.CurrentRatio, IncomeQuality: *shadow.IncomeQuality, - GrahamNumber: *shadow.GrahamNumber, + GrahamNumber: grahamNumber, GrahamNetNet: *shadow.GrahamNetNet, TaxBurden: *shadow.TaxBurden, InterestBurden: *shadow.InterestBurden, @@ -2905,98 +2945,98 @@ func (m *KeyMetrics) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // KeyMetricsTTM is one worldwide trailing-twelve-month key-metrics row. type KeyMetricsTTM struct { - Symbol string `json:"symbol"` - MarketCap float64 `json:"marketCap"` - EnterpriseValueTTM float64 `json:"enterpriseValueTTM"` - EvToSalesTTM float64 `json:"evToSalesTTM"` - EvToOperatingCashFlowTTM float64 `json:"evToOperatingCashFlowTTM"` - EvToFreeCashFlowTTM float64 `json:"evToFreeCashFlowTTM"` - EvToEbitdaTTM float64 `json:"evToEBITDATTM"` - NetDebtToEbitdaTTM float64 `json:"netDebtToEBITDATTM"` - CurrentRatioTTM float64 `json:"currentRatioTTM"` - IncomeQualityTTM float64 `json:"incomeQualityTTM"` - GrahamNumberTTM float64 `json:"grahamNumberTTM"` - GrahamNetNetTTM float64 `json:"grahamNetNetTTM"` - TaxBurdenTTM float64 `json:"taxBurdenTTM"` - InterestBurdenTTM float64 `json:"interestBurdenTTM"` - WorkingCapitalTTM float64 `json:"workingCapitalTTM"` - InvestedCapitalTTM float64 `json:"investedCapitalTTM"` - ReturnOnAssetsTTM float64 `json:"returnOnAssetsTTM"` - OperatingReturnOnAssetsTTM float64 `json:"operatingReturnOnAssetsTTM"` - ReturnOnTangibleAssetsTTM float64 `json:"returnOnTangibleAssetsTTM"` - ReturnOnEquityTTM float64 `json:"returnOnEquityTTM"` - ReturnOnInvestedCapitalTTM float64 `json:"returnOnInvestedCapitalTTM"` - ReturnOnCapitalEmployedTTM float64 `json:"returnOnCapitalEmployedTTM"` - EarningsYieldTTM float64 `json:"earningsYieldTTM"` - FreeCashFlowYieldTTM float64 `json:"freeCashFlowYieldTTM"` - CapexToOperatingCashFlowTTM float64 `json:"capexToOperatingCashFlowTTM"` - CapexToDepreciationTTM float64 `json:"capexToDepreciationTTM"` - CapexToRevenueTTM float64 `json:"capexToRevenueTTM"` - SalesGeneralAndAdministrativeToRevenueTTM float64 `json:"salesGeneralAndAdministrativeToRevenueTTM"` - ResearchAndDevelopementToRevenueTTM float64 `json:"researchAndDevelopementToRevenueTTM"` - StockBasedCompensationToRevenueTTM float64 `json:"stockBasedCompensationToRevenueTTM"` - IntangiblesToTotalAssetsTTM float64 `json:"intangiblesToTotalAssetsTTM"` - AverageReceivablesTTM float64 `json:"averageReceivablesTTM"` - AveragePayablesTTM float64 `json:"averagePayablesTTM"` - AverageInventoryTTM float64 `json:"averageInventoryTTM"` - DaysOfSalesOutstandingTTM float64 `json:"daysOfSalesOutstandingTTM"` - DaysOfPayablesOutstandingTTM float64 `json:"daysOfPayablesOutstandingTTM"` - DaysOfInventoryOutstandingTTM float64 `json:"daysOfInventoryOutstandingTTM"` - OperatingCycleTTM float64 `json:"operatingCycleTTM"` - CashConversionCycleTTM float64 `json:"cashConversionCycleTTM"` - FreeCashFlowToEquityTTM float64 `json:"freeCashFlowToEquityTTM"` - FreeCashFlowToFirmTTM float64 `json:"freeCashFlowToFirmTTM"` - TangibleAssetValueTTM float64 `json:"tangibleAssetValueTTM"` - NetCurrentAssetValueTTM float64 `json:"netCurrentAssetValueTTM"` + Symbol string `json:"symbol"` + MarketCap float64 `json:"marketCap"` + EnterpriseValueTTM float64 `json:"enterpriseValueTTM"` + EvToSalesTTM float64 `json:"evToSalesTTM"` + EvToOperatingCashFlowTTM float64 `json:"evToOperatingCashFlowTTM"` + EvToFreeCashFlowTTM float64 `json:"evToFreeCashFlowTTM"` + EvToEbitdaTTM float64 `json:"evToEBITDATTM"` + NetDebtToEbitdaTTM float64 `json:"netDebtToEBITDATTM"` + CurrentRatioTTM float64 `json:"currentRatioTTM"` + IncomeQualityTTM float64 `json:"incomeQualityTTM"` + GrahamNumberTTM *float64 `json:"grahamNumberTTM"` + GrahamNetNetTTM float64 `json:"grahamNetNetTTM"` + TaxBurdenTTM float64 `json:"taxBurdenTTM"` + InterestBurdenTTM float64 `json:"interestBurdenTTM"` + WorkingCapitalTTM float64 `json:"workingCapitalTTM"` + InvestedCapitalTTM float64 `json:"investedCapitalTTM"` + ReturnOnAssetsTTM float64 `json:"returnOnAssetsTTM"` + OperatingReturnOnAssetsTTM float64 `json:"operatingReturnOnAssetsTTM"` + ReturnOnTangibleAssetsTTM float64 `json:"returnOnTangibleAssetsTTM"` + ReturnOnEquityTTM float64 `json:"returnOnEquityTTM"` + ReturnOnInvestedCapitalTTM float64 `json:"returnOnInvestedCapitalTTM"` + ReturnOnCapitalEmployedTTM float64 `json:"returnOnCapitalEmployedTTM"` + EarningsYieldTTM float64 `json:"earningsYieldTTM"` + FreeCashFlowYieldTTM float64 `json:"freeCashFlowYieldTTM"` + CapexToOperatingCashFlowTTM float64 `json:"capexToOperatingCashFlowTTM"` + CapexToDepreciationTTM float64 `json:"capexToDepreciationTTM"` + CapexToRevenueTTM float64 `json:"capexToRevenueTTM"` + SalesGeneralAndAdministrativeToRevenueTTM float64 `json:"salesGeneralAndAdministrativeToRevenueTTM"` + ResearchAndDevelopementToRevenueTTM float64 `json:"researchAndDevelopementToRevenueTTM"` + StockBasedCompensationToRevenueTTM float64 `json:"stockBasedCompensationToRevenueTTM"` + IntangiblesToTotalAssetsTTM float64 `json:"intangiblesToTotalAssetsTTM"` + AverageReceivablesTTM float64 `json:"averageReceivablesTTM"` + AveragePayablesTTM float64 `json:"averagePayablesTTM"` + AverageInventoryTTM float64 `json:"averageInventoryTTM"` + DaysOfSalesOutstandingTTM float64 `json:"daysOfSalesOutstandingTTM"` + DaysOfPayablesOutstandingTTM float64 `json:"daysOfPayablesOutstandingTTM"` + DaysOfInventoryOutstandingTTM float64 `json:"daysOfInventoryOutstandingTTM"` + OperatingCycleTTM float64 `json:"operatingCycleTTM"` + CashConversionCycleTTM float64 `json:"cashConversionCycleTTM"` + FreeCashFlowToEquityTTM float64 `json:"freeCashFlowToEquityTTM"` + FreeCashFlowToFirmTTM float64 `json:"freeCashFlowToFirmTTM"` + TangibleAssetValueTTM float64 `json:"tangibleAssetValueTTM"` + NetCurrentAssetValueTTM float64 `json:"netCurrentAssetValueTTM"` } // keyMetricsTTMShadow mirrors KeyMetricsTTM with a pointer or raw value for // every required member so a missing or null member is observable after // decoding. type keyMetricsTTMShadow struct { - Symbol *string `json:"symbol"` - MarketCap *float64 `json:"marketCap"` - EnterpriseValueTTM *float64 `json:"enterpriseValueTTM"` - EvToSalesTTM *float64 `json:"evToSalesTTM"` - EvToOperatingCashFlowTTM *float64 `json:"evToOperatingCashFlowTTM"` - EvToFreeCashFlowTTM *float64 `json:"evToFreeCashFlowTTM"` - EvToEbitdaTTM *float64 `json:"evToEBITDATTM"` - NetDebtToEbitdaTTM *float64 `json:"netDebtToEBITDATTM"` - CurrentRatioTTM *float64 `json:"currentRatioTTM"` - IncomeQualityTTM *float64 `json:"incomeQualityTTM"` - GrahamNumberTTM *float64 `json:"grahamNumberTTM"` - GrahamNetNetTTM *float64 `json:"grahamNetNetTTM"` - TaxBurdenTTM *float64 `json:"taxBurdenTTM"` - InterestBurdenTTM *float64 `json:"interestBurdenTTM"` - WorkingCapitalTTM *float64 `json:"workingCapitalTTM"` - InvestedCapitalTTM *float64 `json:"investedCapitalTTM"` - ReturnOnAssetsTTM *float64 `json:"returnOnAssetsTTM"` - OperatingReturnOnAssetsTTM *float64 `json:"operatingReturnOnAssetsTTM"` - ReturnOnTangibleAssetsTTM *float64 `json:"returnOnTangibleAssetsTTM"` - ReturnOnEquityTTM *float64 `json:"returnOnEquityTTM"` - ReturnOnInvestedCapitalTTM *float64 `json:"returnOnInvestedCapitalTTM"` - ReturnOnCapitalEmployedTTM *float64 `json:"returnOnCapitalEmployedTTM"` - EarningsYieldTTM *float64 `json:"earningsYieldTTM"` - FreeCashFlowYieldTTM *float64 `json:"freeCashFlowYieldTTM"` - CapexToOperatingCashFlowTTM *float64 `json:"capexToOperatingCashFlowTTM"` - CapexToDepreciationTTM *float64 `json:"capexToDepreciationTTM"` - CapexToRevenueTTM *float64 `json:"capexToRevenueTTM"` - SalesGeneralAndAdministrativeToRevenueTTM *float64 `json:"salesGeneralAndAdministrativeToRevenueTTM"` - ResearchAndDevelopementToRevenueTTM *float64 `json:"researchAndDevelopementToRevenueTTM"` - StockBasedCompensationToRevenueTTM *float64 `json:"stockBasedCompensationToRevenueTTM"` - IntangiblesToTotalAssetsTTM *float64 `json:"intangiblesToTotalAssetsTTM"` - AverageReceivablesTTM *float64 `json:"averageReceivablesTTM"` - AveragePayablesTTM *float64 `json:"averagePayablesTTM"` - AverageInventoryTTM *float64 `json:"averageInventoryTTM"` - DaysOfSalesOutstandingTTM *float64 `json:"daysOfSalesOutstandingTTM"` - DaysOfPayablesOutstandingTTM *float64 `json:"daysOfPayablesOutstandingTTM"` - DaysOfInventoryOutstandingTTM *float64 `json:"daysOfInventoryOutstandingTTM"` - OperatingCycleTTM *float64 `json:"operatingCycleTTM"` - CashConversionCycleTTM *float64 `json:"cashConversionCycleTTM"` - FreeCashFlowToEquityTTM *float64 `json:"freeCashFlowToEquityTTM"` - FreeCashFlowToFirmTTM *float64 `json:"freeCashFlowToFirmTTM"` - TangibleAssetValueTTM *float64 `json:"tangibleAssetValueTTM"` - NetCurrentAssetValueTTM *float64 `json:"netCurrentAssetValueTTM"` + Symbol *string `json:"symbol"` + MarketCap *float64 `json:"marketCap"` + EnterpriseValueTTM *float64 `json:"enterpriseValueTTM"` + EvToSalesTTM *float64 `json:"evToSalesTTM"` + EvToOperatingCashFlowTTM *float64 `json:"evToOperatingCashFlowTTM"` + EvToFreeCashFlowTTM *float64 `json:"evToFreeCashFlowTTM"` + EvToEbitdaTTM *float64 `json:"evToEBITDATTM"` + NetDebtToEbitdaTTM *float64 `json:"netDebtToEBITDATTM"` + CurrentRatioTTM *float64 `json:"currentRatioTTM"` + IncomeQualityTTM *float64 `json:"incomeQualityTTM"` + GrahamNumberTTM jsontext.Value `json:"grahamNumberTTM"` + GrahamNetNetTTM *float64 `json:"grahamNetNetTTM"` + TaxBurdenTTM *float64 `json:"taxBurdenTTM"` + InterestBurdenTTM *float64 `json:"interestBurdenTTM"` + WorkingCapitalTTM *float64 `json:"workingCapitalTTM"` + InvestedCapitalTTM *float64 `json:"investedCapitalTTM"` + ReturnOnAssetsTTM *float64 `json:"returnOnAssetsTTM"` + OperatingReturnOnAssetsTTM *float64 `json:"operatingReturnOnAssetsTTM"` + ReturnOnTangibleAssetsTTM *float64 `json:"returnOnTangibleAssetsTTM"` + ReturnOnEquityTTM *float64 `json:"returnOnEquityTTM"` + ReturnOnInvestedCapitalTTM *float64 `json:"returnOnInvestedCapitalTTM"` + ReturnOnCapitalEmployedTTM *float64 `json:"returnOnCapitalEmployedTTM"` + EarningsYieldTTM *float64 `json:"earningsYieldTTM"` + FreeCashFlowYieldTTM *float64 `json:"freeCashFlowYieldTTM"` + CapexToOperatingCashFlowTTM *float64 `json:"capexToOperatingCashFlowTTM"` + CapexToDepreciationTTM *float64 `json:"capexToDepreciationTTM"` + CapexToRevenueTTM *float64 `json:"capexToRevenueTTM"` + SalesGeneralAndAdministrativeToRevenueTTM *float64 `json:"salesGeneralAndAdministrativeToRevenueTTM"` + ResearchAndDevelopementToRevenueTTM *float64 `json:"researchAndDevelopementToRevenueTTM"` + StockBasedCompensationToRevenueTTM *float64 `json:"stockBasedCompensationToRevenueTTM"` + IntangiblesToTotalAssetsTTM *float64 `json:"intangiblesToTotalAssetsTTM"` + AverageReceivablesTTM *float64 `json:"averageReceivablesTTM"` + AveragePayablesTTM *float64 `json:"averagePayablesTTM"` + AverageInventoryTTM *float64 `json:"averageInventoryTTM"` + DaysOfSalesOutstandingTTM *float64 `json:"daysOfSalesOutstandingTTM"` + DaysOfPayablesOutstandingTTM *float64 `json:"daysOfPayablesOutstandingTTM"` + DaysOfInventoryOutstandingTTM *float64 `json:"daysOfInventoryOutstandingTTM"` + OperatingCycleTTM *float64 `json:"operatingCycleTTM"` + CashConversionCycleTTM *float64 `json:"cashConversionCycleTTM"` + FreeCashFlowToEquityTTM *float64 `json:"freeCashFlowToEquityTTM"` + FreeCashFlowToFirmTTM *float64 `json:"freeCashFlowToFirmTTM"` + TangibleAssetValueTTM *float64 `json:"tangibleAssetValueTTM"` + NetCurrentAssetValueTTM *float64 `json:"netCurrentAssetValueTTM"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -3028,7 +3068,7 @@ func (m *KeyMetricsTTM) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("KeyMetricsTTM", "currentRatioTTM") case shadow.IncomeQualityTTM == nil: return missingMemberError("KeyMetricsTTM", "incomeQualityTTM") - case shadow.GrahamNumberTTM == nil: + case len(shadow.GrahamNumberTTM) == 0: return missingMemberError("KeyMetricsTTM", "grahamNumberTTM") case shadow.GrahamNetNetTTM == nil: return missingMemberError("KeyMetricsTTM", "grahamNetNetTTM") @@ -3095,6 +3135,14 @@ func (m *KeyMetricsTTM) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.NetCurrentAssetValueTTM == nil: return missingMemberError("KeyMetricsTTM", "netCurrentAssetValueTTM") } + var grahamNumberTTM *float64 + if shadow.GrahamNumberTTM.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.GrahamNumberTTM, &value); err != nil { + return err + } + grahamNumberTTM = &value + } *m = KeyMetricsTTM{ Symbol: *shadow.Symbol, MarketCap: *shadow.MarketCap, @@ -3106,7 +3154,7 @@ func (m *KeyMetricsTTM) UnmarshalJSONFrom(dec *jsontext.Decoder) error { NetDebtToEbitdaTTM: *shadow.NetDebtToEbitdaTTM, CurrentRatioTTM: *shadow.CurrentRatioTTM, IncomeQualityTTM: *shadow.IncomeQualityTTM, - GrahamNumberTTM: *shadow.GrahamNumberTTM, + GrahamNumberTTM: grahamNumberTTM, GrahamNetNetTTM: *shadow.GrahamNetNetTTM, TaxBurdenTTM: *shadow.TaxBurdenTTM, InterestBurdenTTM: *shadow.InterestBurdenTTM, @@ -4044,17 +4092,17 @@ func (m *LatestFinancialStatement) UnmarshalJSONFrom(dec *jsontext.Decoder) erro // FinancialScore is one company's documented financial-health scores and // source values. type FinancialScore struct { - Symbol string `json:"symbol"` - ReportedCurrency string `json:"reportedCurrency"` - AltmanZScore float64 `json:"altmanZScore"` - PiotroskiScore uint64 `json:"piotroskiScore"` - WorkingCapital float64 `json:"workingCapital"` - TotalAssets float64 `json:"totalAssets"` - RetainedEarnings float64 `json:"retainedEarnings"` - Ebit float64 `json:"ebit"` - MarketCap float64 `json:"marketCap"` - TotalLiabilities float64 `json:"totalLiabilities"` - Revenue float64 `json:"revenue"` + Symbol string `json:"symbol"` + ReportedCurrency string `json:"reportedCurrency"` + AltmanZScore *float64 `json:"altmanZScore"` + PiotroskiScore uint64 `json:"piotroskiScore"` + WorkingCapital float64 `json:"workingCapital"` + TotalAssets float64 `json:"totalAssets"` + RetainedEarnings float64 `json:"retainedEarnings"` + Ebit float64 `json:"ebit"` + MarketCap float64 `json:"marketCap"` + TotalLiabilities float64 `json:"totalLiabilities"` + Revenue float64 `json:"revenue"` } // financialScoreShadow mirrors FinancialScore with a pointer or raw value for @@ -4063,7 +4111,7 @@ type FinancialScore struct { type financialScoreShadow struct { Symbol *string `json:"symbol"` ReportedCurrency *string `json:"reportedCurrency"` - AltmanZScore *float64 `json:"altmanZScore"` + AltmanZScore jsontext.Value `json:"altmanZScore"` PiotroskiScore jsontext.Value `json:"piotroskiScore"` WorkingCapital *float64 `json:"workingCapital"` TotalAssets *float64 `json:"totalAssets"` @@ -4087,7 +4135,7 @@ func (m *FinancialScore) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("FinancialScore", "symbol") case shadow.ReportedCurrency == nil: return missingMemberError("FinancialScore", "reportedCurrency") - case shadow.AltmanZScore == nil: + case len(shadow.AltmanZScore) == 0: return missingMemberError("FinancialScore", "altmanZScore") case len(shadow.PiotroskiScore) == 0: return missingMemberError("FinancialScore", "piotroskiScore") @@ -4106,6 +4154,14 @@ func (m *FinancialScore) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.Revenue == nil: return missingMemberError("FinancialScore", "revenue") } + var altmanZScore *float64 + if shadow.AltmanZScore.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.AltmanZScore, &value); err != nil { + return err + } + altmanZScore = &value + } piotroskiScore, err := decodeCount("FinancialScore", "piotroskiScore", shadow.PiotroskiScore) if err != nil { return err @@ -4113,7 +4169,7 @@ func (m *FinancialScore) UnmarshalJSONFrom(dec *jsontext.Decoder) error { *m = FinancialScore{ Symbol: *shadow.Symbol, ReportedCurrency: *shadow.ReportedCurrency, - AltmanZScore: *shadow.AltmanZScore, + AltmanZScore: altmanZScore, PiotroskiScore: piotroskiScore, WorkingCapital: *shadow.WorkingCapital, TotalAssets: *shadow.TotalAssets, diff --git a/sdk/go/statements_parity_test.go b/sdk/go/statements_parity_test.go index d1d958e6..200389d3 100644 --- a/sdk/go/statements_parity_test.go +++ b/sdk/go/statements_parity_test.go @@ -3,6 +3,7 @@ package fmp import ( "encoding/json/jsontext" "encoding/json/v2" + "reflect" "testing" ) @@ -48,13 +49,13 @@ func TestDocumentedIncomeStatementDecodesAll39FieldsExactly(t *testing.T) { rows := assertFixtureParity[IncomeStatement](t, "income_statement.json") want := IncomeStatement{ Date: mustParseDate(t, "2025-09-27"), Symbol: "AAPL", ReportedCurrency: "USD", CIK: "0000320193", - FilingDate: mustParseDate(t, "2025-10-31"), AcceptedDate: mustParseDateTime(t, "2025-10-31 06:01:26"), + FilingDate: new(mustParseDate(t, "2025-10-31")), AcceptedDate: new(mustParseDateTime(t, "2025-10-31 06:01:26")), FiscalYear: "2025", Period: "FY", Revenue: 416_161_000_000, CostOfRevenue: 220_960_000_000, GrossProfit: 195_201_000_000, ResearchAndDevelopmentExpenses: 34_550_000_000, GeneralAndAdministrativeExpenses: 27_601_000_000, SellingAndMarketingExpenses: 0, SellingGeneralAndAdministrativeExpenses: 27_601_000_000, OtherExpenses: 0, OperatingExpenses: 62_151_000_000, CostAndExpenses: 283_111_000_000, - NetInterestIncome: 0, InterestIncome: 0, InterestExpense: 0, + NetInterestIncome: 0, InterestIncome: new(0.0), InterestExpense: 0, DepreciationAndAmortization: 11_698_000_000, Ebitda: 144_427_000_000, Ebit: 132_729_000_000, NonOperatingIncomeExcludingInterest: 321_000_000, OperatingIncome: 133_050_000_000, TotalOtherIncomeExpensesNet: -321_000_000, IncomeBeforeTax: 132_729_000_000, @@ -63,7 +64,7 @@ func TestDocumentedIncomeStatementDecodesAll39FieldsExactly(t *testing.T) { NetIncomeDeductions: 0, BottomLineNetIncome: 112_010_000_000, EPS: 7.49, EPSDiluted: 7.46, WeightedAverageShsOut: 14_948_500_000, WeightedAverageShsOutDil: 15_004_697_000, } - if len(rows) != 1 || rows[0] != want { + if len(rows) != 1 || !reflect.DeepEqual(rows[0], want) { t.Fatalf("income_statement = %+v, want %+v", rows, want) } if got := memberSet(t, rows[0]); len(got) != 39 { @@ -71,7 +72,7 @@ func TestDocumentedIncomeStatementDecodesAll39FieldsExactly(t *testing.T) { } ttm := assertFixtureParity[IncomeStatement](t, "income_statement_ttm.json") if len(ttm) != 1 || ttm[0].Date != mustParseDate(t, "2026-03-28") || ttm[0].Period != "Q2" || - ttm[0].Revenue != 451_442_000_000 || ttm[0].AcceptedDate != mustParseDateTime(t, "2026-05-01 10:01:00") { + ttm[0].Revenue != 451_442_000_000 || ttm[0].AcceptedDate == nil || *ttm[0].AcceptedDate != mustParseDateTime(t, "2026-05-01 10:01:00") { t.Fatalf("income_statement_ttm = %+v", ttm) } } @@ -287,3 +288,55 @@ func TestPreIPOStubRowsDecodeNullAmountsAsNil(t *testing.T) { } assertStubMembersReencodeNull(t, cash[0], cashFlowStubQuarterNullMembers) } + +// Members FMP sends as null outside stub quarters (issue #368 audit): each +// decodes to nil and re-encodes as null, mirroring the nullable-member tests +// in the income, metrics, summary and as-reported Rust response suites. +func TestNullableStatementMembersDecodeNullAsNil(t *testing.T) { + t.Parallel() + incomeMembers := []string{"interestIncome", "filingDate", "acceptedDate"} + var income []IncomeStatement + if err := json.Unmarshal(statementsWithNullMembers(t, "income_statement.json", incomeMembers), &income); err != nil { + t.Fatalf("income: %v", err) + } + if income[0].InterestIncome != nil || income[0].FilingDate != nil || income[0].AcceptedDate != nil { + t.Fatalf("income = %+v", income[0]) + } + assertStubMembersReencodeNull(t, income[0], incomeMembers) + + var metrics []KeyMetrics + if err := json.Unmarshal(statementsWithNullMembers(t, "key_metrics.json", []string{"grahamNumber"}), &metrics); err != nil { + t.Fatalf("key metrics: %v", err) + } + if metrics[0].GrahamNumber != nil { + t.Fatalf("key metrics = %+v", metrics[0]) + } + assertStubMembersReencodeNull(t, metrics[0], []string{"grahamNumber"}) + + var metricsTTM []KeyMetricsTTM + if err := json.Unmarshal(statementsWithNullMembers(t, "key_metrics_ttm.json", []string{"grahamNumberTTM"}), &metricsTTM); err != nil { + t.Fatalf("key metrics ttm: %v", err) + } + if metricsTTM[0].GrahamNumberTTM != nil { + t.Fatalf("key metrics ttm = %+v", metricsTTM[0]) + } + assertStubMembersReencodeNull(t, metricsTTM[0], []string{"grahamNumberTTM"}) + + var scores []FinancialScore + if err := json.Unmarshal(statementsWithNullMembers(t, "financial_scores.json", []string{"altmanZScore"}), &scores); err != nil { + t.Fatalf("financial score: %v", err) + } + if scores[0].AltmanZScore != nil { + t.Fatalf("financial score = %+v", scores[0]) + } + assertStubMembersReencodeNull(t, scores[0], []string{"altmanZScore"}) + + var asReported []AsReportedFinancialStatement + if err := json.Unmarshal(statementsWithNullMembers(t, "income_statement_as_reported.json", []string{"reportedCurrency"}), &asReported); err != nil { + t.Fatalf("as reported: %v", err) + } + if asReported[0].ReportedCurrency != nil { + t.Fatalf("as reported = %+v", asReported[0]) + } + assertStubMembersReencodeNull(t, asReported[0], []string{"reportedCurrency"}) +} diff --git a/sdk/go/statements_reports_parity_test.go b/sdk/go/statements_reports_parity_test.go index 9489cc7a..c86e87b8 100644 --- a/sdk/go/statements_reports_parity_test.go +++ b/sdk/go/statements_reports_parity_test.go @@ -3,6 +3,7 @@ package fmp import ( "encoding/json/jsontext" "encoding/json/v2" + "reflect" "strings" "testing" ) @@ -86,7 +87,7 @@ func TestDocumentedAsReportedAndSegmentationFixturesKeepDynamicData(t *testing.T } { rows := assertFixtureParity[AsReportedFinancialStatement](t, fixture) if len(rows) != 1 || rows[0].Symbol != "AAPL" || rows[0].FiscalYear != 2025 || rows[0].Period != "FY" || - rows[0].ReportedCurrency != "USD" || rows[0].Date != mustParseDate(t, "2025-09-26") { + rows[0].ReportedCurrency == nil || *rows[0].ReportedCurrency != "USD" || rows[0].Date != mustParseDate(t, "2025-09-26") { t.Fatalf("%s = %+v", fixture, rows) } if data := statementsObjectMembers(t, rows[0].Data); len(data) != keys { @@ -212,11 +213,11 @@ func TestDocumentedSummaryFixturesDecodeEveryFieldExactly(t *testing.T) { } scores := assertFixtureParity[FinancialScore](t, "financial_scores.json") wantScore := FinancialScore{ - Symbol: "AAPL", ReportedCurrency: "USD", AltmanZScore: 14.041374927993303, PiotroskiScore: 9, + Symbol: "AAPL", ReportedCurrency: "USD", AltmanZScore: new(14.041374927993303), PiotroskiScore: 9, WorkingCapital: 9_473_000_000, TotalAssets: 371_082_000_000, RetainedEarnings: 12_359_000_000, Ebit: 147_722_000_000, MarketCap: 5_042_169_135_511, TotalLiabilities: 264_591_000_000, Revenue: 451_442_000_000, } - if len(scores) != 1 || scores[0] != wantScore { + if len(scores) != 1 || !reflect.DeepEqual(scores[0], wantScore) { t.Fatalf("financial_scores = %+v, want %+v", scores, wantScore) } owner := assertFixtureParity[OwnerEarnings](t, "owner_earnings.json") diff --git a/sdk/go/statements_test.go b/sdk/go/statements_test.go index 09a3df06..738261e0 100644 --- a/sdk/go/statements_test.go +++ b/sdk/go/statements_test.go @@ -122,7 +122,9 @@ func TestStatementsMethodsUseExactPathsAndWireOrder(t *testing.T) { asBalance, err := s.AsReported.BalanceSheet(ctx, NewBalanceSheetStatementAsReportedQuery(statementsTicker).WithLimit(7).WithPeriod(RetrievalFrequencyQuarterly)) statementsCheck(t, "AsReported.BalanceSheet", asBalance, err, func(r AsReportedFinancialStatement) bool { return r.Period == "FY" }) asCash, err := s.AsReported.CashFlow(ctx, NewCashFlowStatementAsReportedQuery(statementsTicker).WithLimit(5).WithPeriod(RetrievalFrequencyAnnual)) - statementsCheck(t, "AsReported.CashFlow", asCash, err, func(r AsReportedFinancialStatement) bool { return r.ReportedCurrency == "USD" }) + statementsCheck(t, "AsReported.CashFlow", asCash, err, func(r AsReportedFinancialStatement) bool { + return r.ReportedCurrency != nil && *r.ReportedCurrency == "USD" + }) asFull, err := s.AsReported.Full(ctx, NewFinancialStatementFullAsReportedQuery(statementsTicker).WithLimit(7).WithPeriod(RetrievalFrequencyQuarterly)) statementsCheck(t, "AsReported.Full", asFull, err, func(r AsReportedFinancialStatement) bool { return r.Data.Kind() == '{' }) dates, err := s.Reports.Dates(ctx, NewFinancialReportsDatesQuery(statementsTicker))