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))