From 1df80dc1f868b906c5540b66a211947c6886484d Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Mon, 18 May 2026 00:10:00 -0700 Subject: feat: add get_financials() with income/balance/cashflow builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements _build_income, _build_balance, _build_cash_flow, and get_financials() (cached) in data_service.py. Annual mode appends TTM (income/CF) and MRQ (balance) columns; quarterly mode returns up to 8 periods. Adds annual_frame helper and 5 TDD tests covering column labels, TTM sums, MRQ values, FCF computation, and empty-statement graceful returns. Test count: 19 → 24. Co-Authored-By: Claude Sonnet 4.6 --- backend/tests/test_api.py | 140 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) (limited to 'backend/tests') diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5d76dfd..f98b582 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -14,6 +14,7 @@ def clear_service_caches() -> None: data_service.STATEMENT_CACHE.clear() data_service.SHARES_CACHE.clear() data_service.RATIO_CACHE.clear() + data_service.FINANCIALS_CACHE.clear() def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame: @@ -21,6 +22,11 @@ def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame: return pd.DataFrame(rows, index=columns).T +def annual_frame(rows: dict[str, list[float]]) -> pd.DataFrame: + columns = pd.to_datetime(["2024-09-30", "2023-09-30", "2022-09-30", "2021-09-30"]) + return pd.DataFrame(rows, index=columns).T + + def test_health() -> None: assert main.health() == {"status": "ok"} @@ -403,3 +409,137 @@ def test_overview_uses_computed_sources_and_ratios(monkeypatch) -> None: assert overview["meta"]["sources"]["ratios.price_to_book"] == "computed" assert overview["meta"]["field_availability"]["ratios.ev_to_ebitda"] is True assert any(signal["key"] == "Valuation" and "24.5x" in signal["value"] for signal in overview["signals"]) + + +def test_build_income_annual_columns_and_ttm(monkeypatch) -> None: + data_service.STATEMENT_CACHE.clear() + data_service.FINANCIALS_CACHE.clear() + + inc_annual = annual_frame({ + "Total Revenue": [391_000.0, 383_300.0, 394_300.0, 365_800.0], + "Gross Profit": [180_700.0, 169_100.0, 170_800.0, 152_800.0], + "Net Income": [ 93_700.0, 97_000.0, 99_800.0, 94_700.0], + }) + inc_q = quarterly_frame({ + "Total Revenue": [100_000.0, 95_000.0, 98_000.0, 97_000.0], + "Gross Profit": [ 46_000.0, 44_000.0, 45_000.0, 43_000.0], + "Net Income": [ 24_000.0, 23_000.0, 24_000.0, 22_000.0], + }) + + monkeypatch.setattr(data_service, "get_income_statement", + lambda sym, quarterly=False: inc_q if quarterly else inc_annual) + monkeypatch.setattr(data_service, "get_balance_sheet", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_cash_flow", + lambda sym, quarterly=False: pd.DataFrame()) + + result = data_service.get_financials("AAPL", "annual") + income = result["income"] + + assert income["columns"] == ["FY 2024", "FY 2023", "FY 2022", "FY 2021", "TTM"] + rev_row = next(r for r in income["rows"] if r["label"] == "Total Revenue") + assert rev_row["is_total"] is True + assert rev_row["values"][4] == 390_000.0 # sum of 4 quarters + margin_row = next(r for r in income["rows"] if r["label"] == "gross margin") + assert margin_row["is_margin"] is True + assert margin_row["values"][0] is not None # FY 2024 gross margin computed + + +def test_build_income_quarterly_eight_columns(monkeypatch) -> None: + data_service.STATEMENT_CACHE.clear() + data_service.FINANCIALS_CACHE.clear() + + cols = pd.to_datetime([ + "2025-12-31","2025-09-30","2025-06-30","2025-03-31", + "2024-12-31","2024-09-30","2024-06-30","2024-03-31", + ]) + inc_q8 = pd.DataFrame( + {"Total Revenue": [100_000.0]*8, "Net Income": [25_000.0]*8}, + index=cols, + ).T + + monkeypatch.setattr(data_service, "get_income_statement", + lambda sym, quarterly=False: inc_q8) + monkeypatch.setattr(data_service, "get_balance_sheet", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_cash_flow", + lambda sym, quarterly=False: pd.DataFrame()) + + result = data_service.get_financials("AAPL", "quarterly") + income = result["income"] + assert len(income["columns"]) == 8 + assert income["columns"][0] == "Q4 2025" + assert "TTM" not in income["columns"] + + +def test_build_balance_mrq_column(monkeypatch) -> None: + data_service.STATEMENT_CACHE.clear() + data_service.FINANCIALS_CACHE.clear() + + bal_annual = annual_frame({"Total Assets": [364_900.0, 335_000.0, 352_800.0, 351_000.0]}) + bal_q = quarterly_frame({"Total Assets": [371_900.0, 368_000.0, 360_000.0, 355_000.0]}) + + monkeypatch.setattr(data_service, "get_income_statement", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_balance_sheet", + lambda sym, quarterly=False: bal_q if quarterly else bal_annual) + monkeypatch.setattr(data_service, "get_cash_flow", + lambda sym, quarterly=False: pd.DataFrame()) + + result = data_service.get_financials("AAPL", "annual") + balance = result["balance"] + assert balance["columns"][-1] == "MRQ" + assets_row = next(r for r in balance["rows"] if r["label"] == "Total Assets") + assert assets_row["values"][-1] == 371_900.0 # MRQ value + + +def test_build_cash_flow_fcf(monkeypatch) -> None: + data_service.STATEMENT_CACHE.clear() + data_service.FINANCIALS_CACHE.clear() + + cf_annual = annual_frame({ + "Operating Cash Flow": [118_300.0, 110_500.0, 122_200.0, 104_000.0], + "Capital Expenditure": [ -9_500.0, -10_900.0, -10_700.0, -8_600.0], + }) + cf_q = quarterly_frame({ + "Operating Cash Flow": [30_000.0, 29_000.0, 31_000.0, 28_000.0], + "Capital Expenditure": [-2_500.0, -2_400.0, -2_500.0, -2_400.0], + }) + inc_annual = annual_frame({"Total Revenue": [391_000.0, 383_300.0, 394_300.0, 365_800.0]}) + inc_q = quarterly_frame({"Total Revenue": [100_000.0, 95_000.0, 98_000.0, 97_000.0]}) + + def mock_cf(sym, quarterly=False): + return cf_q if quarterly else cf_annual + + def mock_inc(sym, quarterly=False): + return inc_q if quarterly else inc_annual + + monkeypatch.setattr(data_service, "get_income_statement", mock_inc) + monkeypatch.setattr(data_service, "get_balance_sheet", lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_cash_flow", mock_cf) + + result = data_service.get_financials("AAPL", "annual") + cf = result["cash_flow"] + + fcf_row = next(r for r in cf["rows"] if r["label"] == "Free Cash Flow") + assert fcf_row["is_total"] is True + # FY 2024: 118300 + (-9500) = 108800 + assert fcf_row["values"][0] == 108_800.0 + fcf_margin = next(r for r in cf["rows"] if r["label"] == "FCF margin") + assert fcf_margin["is_margin"] is True + assert fcf_margin["values"][0] is not None + + +def test_get_financials_empty_statements(monkeypatch) -> None: + data_service.STATEMENT_CACHE.clear() + data_service.FINANCIALS_CACHE.clear() + + monkeypatch.setattr(data_service, "get_income_statement", lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_balance_sheet", lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_cash_flow", lambda sym, quarterly=False: pd.DataFrame()) + + result = data_service.get_financials("AAPL", "annual") + assert result["income"]["columns"] == [] + assert result["income"]["rows"] == [] + assert result["balance"]["columns"] == [] + assert result["cash_flow"]["columns"] == [] -- cgit v1.3-2-g0d8e