diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 00:46:27 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 00:46:27 -0700 |
| commit | 45f3eff514a7b4c2f03e801b204920f13671dd5d (patch) | |
| tree | a71e4cd06a4af8357003c9942760e019f52414dc /docs/superpowers/plans/2026-05-17-financials-tab.md | |
| parent | 147664128fa0281333ba3150e007ed8e2f6a616a (diff) | |
| parent | 1e349b8904c6fa52c6f0925453513354c1a4e392 (diff) | |
Merge worktree-financials-tab: add financials tab (income/balance/cashflow)
Diffstat (limited to 'docs/superpowers/plans/2026-05-17-financials-tab.md')
| -rw-r--r-- | docs/superpowers/plans/2026-05-17-financials-tab.md | 1391 |
1 files changed, 1391 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-05-17-financials-tab.md b/docs/superpowers/plans/2026-05-17-financials-tab.md new file mode 100644 index 0000000..3641165 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-financials-tab.md @@ -0,0 +1,1391 @@ +# Financials Tab Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Financials tab to Prism v2 that surfaces Income Statement, Balance Sheet, and Cash Flow for any ticker, with Annual (4 years + TTM/MRQ) and Quarterly (8 quarters) period toggle. + +**Architecture:** New backend endpoint `/api/tickers/{symbol}/financials?period=annual|quarterly` builds structured row/column data from yfinance statements. Frontend adds `FinancialsCard` (the table component) and `FinancialsPage` (data-fetching wrapper) rendered when `?tab=financials` is present in the URL. Sidebar nav item wired up via new `onSelectTab` prop. + +**Tech Stack:** Python/FastAPI/yfinance/pydantic (backend); Next.js/TypeScript/React (frontend); cachetools TTLCache at 1 hour for financials. + +--- + +## File Map + +**Create:** +- `frontend/components/prism/FinancialsCard.tsx` +- `frontend/components/prism/FinancialsPage.tsx` + +**Modify:** +- `backend/app/schemas.py` — add FinancialRow, FinancialStatement, FinancialsResponse +- `backend/app/services/data_service.py` — add FINANCIALS_CACHE, helper fns, get_financials() +- `backend/app/main.py` — add /financials route +- `backend/tests/test_api.py` — add tests for get_financials + route +- `frontend/types/api.ts` — add FinancialRow, FinancialStatement, FinancialsResponse +- `frontend/lib/api.ts` — add api.financials() +- `frontend/lib/overview.ts` — enable financials nav item (remove disabled) +- `frontend/app/prism-shell.css` — add financials card CSS +- `frontend/components/prism/Sidebar.tsx` — add onSelectTab prop +- `frontend/app/page.tsx` — read tab param, render FinancialsPage, pass onSelectTab + +--- + +## Task 1: Backend Schemas + +**Files:** +- Modify: `backend/app/schemas.py` + +- [ ] **Step 1: Write the failing import test** + +Add to `backend/tests/test_api.py` (after existing imports): + +```python +def test_financials_schema_structure() -> None: + from app.schemas import FinancialRow, FinancialStatement, FinancialsResponse + row = FinancialRow(label="Revenue", indent=0, is_total=True, values=[1.0, 2.0, None]) + assert row.label == "Revenue" + assert row.is_total is True + assert row.values[2] is None + + stmt = FinancialStatement(columns=["FY 2024", "TTM"], rows=[row]) + assert len(stmt.columns) == 2 + + resp = FinancialsResponse( + period="annual", + income=stmt, + balance=FinancialStatement(columns=[], rows=[]), + cash_flow=FinancialStatement(columns=[], rows=[]), + ) + assert resp.period == "annual" +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_financials_schema_structure -v +``` + +Expected: `ImportError` — FinancialRow not defined yet. + +- [ ] **Step 3: Add schemas to `backend/app/schemas.py`** + +Add after the `HistoryPoint` class: + +```python +class FinancialRow(BaseModel): + label: str + indent: int = 0 + is_total: bool = False + is_section: bool = False + is_margin: bool = False + values: list[float | None] = Field(default_factory=list) + + +class FinancialStatement(BaseModel): + columns: list[str] = Field(default_factory=list) + rows: list[FinancialRow] = Field(default_factory=list) + + +class FinancialsResponse(BaseModel): + period: Literal["annual", "quarterly"] + income: FinancialStatement + balance: FinancialStatement + cash_flow: FinancialStatement +``` + +- [ ] **Step 4: Run test to confirm it passes** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_financials_schema_structure -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas.py backend/tests/test_api.py +git commit -m "feat: add FinancialRow/Statement/Response schemas" +``` + +--- + +## Task 2: Backend Data Service — Helpers + +**Files:** +- Modify: `backend/app/services/data_service.py` + +- [ ] **Step 1: Add FINANCIALS_CACHE and helper functions** + +In `data_service.py`, add after the existing cache declarations (after `RATIO_CACHE`): + +```python +FINANCIALS_CACHE: TTLCache = TTLCache(maxsize=128, ttl=3600) +``` + +Then add these helper functions after the existing `_cap_ratio` function: + +```python +def _fmt_col(ts: Any, quarterly: bool) -> str: + t = pd.Timestamp(ts) + if quarterly: + q = (t.month - 1) // 3 + 1 + return f"Q{q} {t.year}" + return f"FY {t.year}" + + +def _row_vals(frame: pd.DataFrame, label: str, n: int) -> list[float | None]: + if frame is None or frame.empty or label not in frame.index: + return [None] * n + series = pd.to_numeric(frame.loc[label], errors="coerce") + return [_safe_float(series.iloc[i]) if i < len(series) else None for i in range(n)] + + +def _row_vals_multi(frame: pd.DataFrame, n: int, *labels: str) -> list[float | None]: + for label in labels: + vals = _row_vals(frame, label, n) + if any(v is not None for v in vals): + return vals + return [None] * n + + +def _fin_row(label: str, indent: int, is_total: bool, values: list[float | None]) -> dict: + return {"label": label, "indent": indent, "is_total": is_total, "is_section": False, "is_margin": False, "values": values} + + +def _fin_section(label: str) -> dict: + return {"label": label, "indent": 0, "is_total": False, "is_section": True, "is_margin": False, "values": []} + + +def _fin_margin(label: str, values: list[float | None]) -> dict: + return {"label": label, "indent": 1, "is_total": False, "is_section": False, "is_margin": True, "values": values} + + +def _safe_ratio(num: float | None, den: float | None) -> float | None: + if num is None or den is None or den == 0: + return None + return num / den +``` + +- [ ] **Step 2: Verify compile-check passes** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/python -m py_compile backend/app/services/data_service.py +``` + +Expected: no output (clean compile). + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/services/data_service.py +git commit -m "feat: add financials cache and row-builder helper functions" +``` + +--- + +## Task 3: Backend Data Service — Statement Builders + +**Files:** +- Modify: `backend/app/services/data_service.py` + +- [ ] **Step 1: Write failing tests for statement builders** + +Add to `backend/tests/test_api.py`: + +```python +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_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"] == [] +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_build_income_annual_columns_and_ttm backend/tests/test_api.py::test_build_income_quarterly_eight_columns backend/tests/test_api.py::test_build_balance_mrq_column backend/tests/test_api.py::test_build_cash_flow_fcf backend/tests/test_api.py::test_get_financials_empty_statements -v +``` + +Expected: all FAIL with `AttributeError: module ... has no attribute 'get_financials'`. + +- [ ] **Step 3: Add statement builders and get_financials to data_service.py** + +Add after the `_safe_ratio` helper (end of the helper block added in Task 2): + +```python +def _build_income(frame: pd.DataFrame, frame_q: pd.DataFrame, quarterly: bool) -> dict: + if frame is None or frame.empty: + return {"columns": [], "rows": []} + n = min(len(frame.columns), 8 if quarterly else 4) + col_labels = [_fmt_col(c, quarterly) for c in frame.columns[:n]] + if not quarterly: + col_labels.append("TTM") + + def v(label: str) -> list[float | None]: + base = _row_vals(frame, label, n) + return base + ([_statement_ttm(frame_q, label)] if not quarterly else []) + + def vm(*labels: str) -> list[float | None]: + base = _row_vals_multi(frame, n, *labels) + if not quarterly: + ttm = None + for lbl in labels: + ttm = _statement_ttm(frame_q, lbl) + if ttm is not None: + break + base = base + [ttm] + return base + + rev = v("Total Revenue") + gross = v("Gross Profit") + net = v("Net Income") + + return { + "columns": col_labels, + "rows": [ + _fin_row("Total Revenue", 0, True, rev), + _fin_row("Cost of Revenue", 1, False, v("Cost Of Revenue")), + _fin_row("Gross Profit", 0, True, gross), + _fin_margin("gross margin", [_safe_ratio(g, r) for g, r in zip(gross, rev)]), + _fin_row("Operating Expenses", 1, False, v("Operating Expense")), + _fin_row("Operating Income", 0, True, v("Operating Income")), + _fin_row("EBITDA", 1, False, vm("EBITDA", "Normalized EBITDA")), + _fin_row("Interest Expense", 1, False, v("Interest Expense")), + _fin_row("Pretax Income", 0, False, v("Pretax Income")), + _fin_row("Tax Provision", 1, False, v("Tax Provision")), + _fin_row("Net Income", 0, True, net), + _fin_margin("net margin", [_safe_ratio(ni, r) for ni, r in zip(net, rev)]), + _fin_row("EPS Basic", 1, False, v("Basic EPS")), + ], + } + + +def _build_balance(frame: pd.DataFrame, frame_q: pd.DataFrame, quarterly: bool) -> dict: + if frame is None or frame.empty: + return {"columns": [], "rows": []} + n = min(len(frame.columns), 8 if quarterly else 4) + col_labels = [_fmt_col(c, quarterly) for c in frame.columns[:n]] + if not quarterly: + col_labels.append("MRQ") + + def v(*labels: str) -> list[float | None]: + base = _row_vals_multi(frame, n, *labels) + if not quarterly: + val = None + for lbl in labels: + val = _balance_value(frame_q, lbl) + if val is not None: + break + base = base + [val] + return base + + return { + "columns": col_labels, + "rows": [ + _fin_section("ASSETS"), + _fin_row("Current Assets", 0, True, v("Current Assets")), + _fin_row("Cash & Equivalents", 1, False, v("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments")), + _fin_row("Short Term Investments", 1, False, v("Other Short Term Investments", "Short Term Investments")), + _fin_row("Receivables", 1, False, v("Receivables", "Net Receivables")), + _fin_row("Inventory", 1, False, v("Inventory")), + _fin_row("Total Assets", 0, True, v("Total Assets")), + _fin_section("LIABILITIES"), + _fin_row("Current Liabilities", 0, True, v("Current Liabilities")), + _fin_row("Accounts Payable", 1, False, v("Payables And Accrued Expenses", "Accounts Payable")), + _fin_row("Short Term Debt", 1, False, v("Current Debt", "Short Term Debt And Capital Lease Obligation")), + _fin_row("Long Term Debt", 1, False, v("Long Term Debt", "Long Term Debt And Capital Lease Obligation")), + _fin_row("Total Liabilities", 0, True, v("Total Liabilities Net Minority Interest", "Total Liabilities")), + _fin_section("EQUITY"), + _fin_row("Stockholders Equity", 0, True, v("Stockholders Equity", "Common Stock Equity")), + ], + } + + +def _build_cash_flow(cf: pd.DataFrame, cf_q: pd.DataFrame, inc: pd.DataFrame, inc_q: pd.DataFrame, quarterly: bool) -> dict: + if cf is None or cf.empty: + return {"columns": [], "rows": []} + n = min(len(cf.columns), 8 if quarterly else 4) + col_labels = [_fmt_col(c, quarterly) for c in cf.columns[:n]] + if not quarterly: + col_labels.append("TTM") + + def cv(*labels: str) -> list[float | None]: + base = _row_vals_multi(cf, n, *labels) + if not quarterly: + ttm = None + for lbl in labels: + ttm = _statement_ttm(cf_q, lbl) + if ttm is not None: + break + base = base + [ttm] + return base + + def iv(*labels: str) -> list[float | None]: + base = _row_vals_multi(inc, n, *labels) + if not quarterly: + ttm = None + for lbl in labels: + ttm = _statement_ttm(inc_q, lbl) + if ttm is not None: + break + base = base + [ttm] + return base + + op_cf = cv("Operating Cash Flow", "Cash Flow From Continuing Operating Activities") + capex = cv("Capital Expenditure") + # CapEx is negative in yfinance; FCF = Operating CF + CapEx + fcf = [a + b if a is not None and b is not None else None for a, b in zip(op_cf, capex)] + rev = iv("Total Revenue") + + return { + "columns": col_labels, + "rows": [ + _fin_section("OPERATING"), + _fin_row("Net Income", 1, False, iv("Net Income")), + _fin_row("D&A", 1, False, cv("Depreciation And Amortization", "Reconciled Depreciation")), + _fin_row("Changes in Working Capital", 1, False, cv("Change In Working Capital")), + _fin_row("Operating Cash Flow", 0, True, op_cf), + _fin_section("INVESTING"), + _fin_row("CapEx", 1, False, capex), + _fin_row("Free Cash Flow", 0, True, fcf), + _fin_margin("FCF margin", [_safe_ratio(f, r) for f, r in zip(fcf, rev)]), + _fin_row("Investing Cash Flow", 0, True, cv("Investing Cash Flow", "Cash Flow From Continuing Investing Activities")), + _fin_section("FINANCING"), + _fin_row("Dividends Paid", 1, False, cv("Cash Dividends Paid", "Common Stock Dividend Paid")), + _fin_row("Buybacks", 1, False, cv("Repurchase Of Capital Stock", "Common Stock Repurchase")), + _fin_row("Financing Cash Flow", 0, True, cv("Financing Cash Flow", "Cash Flow From Continuing Financing Activities")), + _fin_row("Net Change in Cash", 0, True, cv("Changes In Cash", "End Cash Position")), + ], + } + + +@cached(FINANCIALS_CACHE) +def get_financials(symbol: str, period: str = "annual") -> dict: + sym = normalize_symbol(symbol) + quarterly = period == "quarterly" + + inc = get_income_statement(sym, quarterly=quarterly) + bal = get_balance_sheet(sym, quarterly=quarterly) + cf = get_cash_flow(sym, quarterly=quarterly) + + inc_q = get_income_statement(sym, quarterly=True) if not quarterly else inc + bal_q = get_balance_sheet(sym, quarterly=True) if not quarterly else bal + cf_q = get_cash_flow(sym, quarterly=True) if not quarterly else cf + + return { + "period": period, + "income": _build_income(inc, inc_q, quarterly), + "balance": _build_balance(bal, bal_q, quarterly), + "cash_flow": _build_cash_flow(cf, cf_q, inc, inc_q, quarterly), + } +``` + +Also update `clear_service_caches()` in `backend/tests/test_api.py` to clear FINANCIALS_CACHE: + +```python +def clear_service_caches() -> None: + data_service.INFO_CACHE.clear() + data_service.FAST_INFO_CACHE.clear() + data_service.PRICE_CACHE.clear() + data_service.HISTORY_CACHE.clear() + data_service.STATEMENT_CACHE.clear() + data_service.SHARES_CACHE.clear() + data_service.RATIO_CACHE.clear() + data_service.FINANCIALS_CACHE.clear() +``` + +- [ ] **Step 4: Run the new tests** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_build_income_annual_columns_and_ttm backend/tests/test_api.py::test_build_income_quarterly_eight_columns backend/tests/test_api.py::test_build_balance_mrq_column backend/tests/test_api.py::test_build_cash_flow_fcf backend/tests/test_api.py::test_get_financials_empty_statements -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Run full test suite to confirm no regressions** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q +``` + +Expected: all existing tests + 5 new = 24 passed, 0 failures. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/data_service.py backend/tests/test_api.py +git commit -m "feat: add get_financials() with income/balance/cashflow builders" +``` + +--- + +## Task 4: Backend Route + +**Files:** +- Modify: `backend/app/main.py` +- Modify: `backend/tests/test_api.py` + +- [ ] **Step 1: Write the failing route test** + +Add to `backend/tests/test_api.py`: + +```python +def test_financials_route_returns_structure(monkeypatch) -> None: + monkeypatch.setattr( + main.data_service, + "get_financials", + lambda symbol, period="annual": { + "period": "annual", + "income": {"columns": ["FY 2024", "TTM"], "rows": [ + {"label": "Total Revenue", "indent": 0, "is_total": True, + "is_section": False, "is_margin": False, "values": [391_000.0, 394_500.0]}, + ]}, + "balance": {"columns": [], "rows": []}, + "cash_flow": {"columns": [], "rows": []}, + }, + ) + result = main.ticker_financials("AAPL", period="annual") + assert result["period"] == "annual" + assert result["income"]["columns"][0] == "FY 2024" + assert result["income"]["rows"][0]["label"] == "Total Revenue" + + +def test_financials_route_period_param(monkeypatch) -> None: + captured: list[str] = [] + + def mock_get_financials(symbol, period="annual"): + captured.append(period) + return { + "period": period, + "income": {"columns": [], "rows": []}, + "balance": {"columns": [], "rows": []}, + "cash_flow": {"columns": [], "rows": []}, + } + + monkeypatch.setattr(main.data_service, "get_financials", mock_get_financials) + main.ticker_financials("AAPL", period="quarterly") + assert captured == ["quarterly"] +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_financials_route_returns_structure backend/tests/test_api.py::test_financials_route_period_param -v +``` + +Expected: FAIL — `AttributeError: module ... has no attribute 'ticker_financials'`. + +- [ ] **Step 3: Add route to main.py** + +Update the import line at the top of `main.py`: + +```python +from app.schemas import FinancialRow, FinancialStatement, FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse +``` + +Add route after `ticker_history`: + +```python +@app.get("/api/tickers/{symbol}/financials", response_model=FinancialsResponse) +def ticker_financials(symbol: str, period: str = Query(default="annual", pattern="^(annual|quarterly)$")) -> dict: + return data_service.get_financials(symbol, period=period) +``` + +- [ ] **Step 4: Run route tests** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_financials_route_returns_structure backend/tests/test_api.py::test_financials_route_period_param -v +``` + +Expected: both PASS. + +- [ ] **Step 5: Run full suite** + +```bash +/home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q +``` + +Expected: 26 passed, 0 failures. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/main.py backend/tests/test_api.py +git commit -m "feat: add /api/tickers/{symbol}/financials endpoint" +``` + +--- + +## Task 5: Frontend Types + +**Files:** +- Modify: `frontend/types/api.ts` + +- [ ] **Step 1: Add types** + +Append to `frontend/types/api.ts`: + +```typescript +export type FinancialRow = { + label: string; + indent: number; + is_total: boolean; + is_section: boolean; + is_margin: boolean; + values: (number | null)[]; +}; + +export type FinancialStatement = { + columns: string[]; + rows: FinancialRow[]; +}; + +export type FinancialsResponse = { + period: "annual" | "quarterly"; + income: FinancialStatement; + balance: FinancialStatement; + cash_flow: FinancialStatement; +}; +``` + +- [ ] **Step 2: Validate with build** + +```bash +cd /home/tyler/Work/prism-v2/frontend && npm run build 2>&1 | tail -20 +``` + +Expected: no TypeScript errors related to the new types. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/types/api.ts +git commit -m "feat: add FinancialRow/Statement/Response frontend types" +``` + +--- + +## Task 6: Frontend API Client + Nav + +**Files:** +- Modify: `frontend/lib/api.ts` +- Modify: `frontend/lib/overview.ts` + +- [ ] **Step 1: Add api.financials() to lib/api.ts** + +Update the import in `api.ts`: + +```typescript +import type { FinancialRow, FinancialStatement, FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse } from "@/types/api"; +``` + +Add to the `api` object: + +```typescript +financials(symbol: string, period: "annual" | "quarterly" = "annual") { + return request<FinancialsResponse>( + `/api/tickers/${encodeURIComponent(symbol)}/financials?period=${encodeURIComponent(period)}` + ); +}, +``` + +- [ ] **Step 2: Enable financials nav item in lib/overview.ts** + +Find this line in `OVERVIEW_NAV_ITEMS`: + +```typescript +{ key: "financials", label: "Financials", icon: "ledger", disabled: true }, +``` + +Replace with: + +```typescript +{ key: "financials", label: "Financials", icon: "ledger" }, +``` + +- [ ] **Step 3: Validate with lint + build** + +```bash +cd /home/tyler/Work/prism-v2/frontend && npm run lint 2>&1 | tail -10 && npm run build 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/lib/api.ts frontend/lib/overview.ts +git commit -m "feat: add api.financials() and enable financials nav item" +``` + +--- + +## Task 7: Frontend FinancialsCard Component + +**Files:** +- Create: `frontend/components/prism/FinancialsCard.tsx` +- Modify: `frontend/app/prism-shell.css` + +- [ ] **Step 1: Create FinancialsCard.tsx** + +```typescript +"use client"; +import type { FinancialRow, FinancialsResponse } from "@/types/api"; +import { fmtLarge } from "@/lib/format"; + +type StatementKey = "income" | "balance" | "cash_flow"; +type PeriodKey = "annual" | "quarterly"; + +type Props = { + data: FinancialsResponse; + statement: StatementKey; + period: PeriodKey; + onChangeStatement: (s: StatementKey) => void; + onChangePeriod: (p: PeriodKey) => void; +}; + +const STMT_LABELS: Record<StatementKey, string> = { + income: "INCOME", + balance: "BALANCE", + cash_flow: "CASH FLOW", +}; + +function fmtFinVal(val: number | null | undefined, isMargin: boolean): string { + if (val === null || val === undefined) return "—"; + if (isMargin) return `${(val * 100).toFixed(1)}%`; + return fmtLarge(val); +} + +function FinRow({ row, lastColIdx }: { row: FinancialRow; lastColIdx: number }) { + if (row.is_section) { + return ( + <tr className="psm-fin-section-row"> + <td className="psm-fin-section-label" colSpan={lastColIdx + 2}> + {row.label} + </td> + </tr> + ); + } + + const cls = [ + "psm-fin-row", + row.is_total ? "is-total" : "", + row.is_margin ? "is-margin" : "", + row.indent === 1 ? "is-indent" : "", + ] + .filter(Boolean) + .join(" "); + + return ( + <tr className={cls}> + <td className="psm-fin-label">{row.label}</td> + {row.values.map((val, i) => ( + <td + key={i} + className={[ + "psm-fin-val", + i === lastColIdx ? "accent" : "", + val !== null && val < 0 && !row.is_margin ? "neg" : "", + ] + .filter(Boolean) + .join(" ")} + > + {fmtFinVal(val, row.is_margin)} + </td> + ))} + </tr> + ); +} + +export function FinancialsCard({ data, statement, period, onChangeStatement, onChangePeriod }: Props) { + const stmt = data[statement]; + const lastColIdx = stmt.columns.length - 1; + + return ( + <section className="psm-card psm-financials-card"> + <div className="psm-fin-header"> + <div className="psm-fin-tabs"> + {(["income", "balance", "cash_flow"] as StatementKey[]).map((key) => ( + <button + key={key} + type="button" + className={`psm-fin-tab${statement === key ? " active" : ""}`} + onClick={() => onChangeStatement(key)} + > + {STMT_LABELS[key]} + </button> + ))} + </div> + <div className="psm-fin-period"> + {(["annual", "quarterly"] as PeriodKey[]).map((p) => ( + <button + key={p} + type="button" + className={`psm-fin-period-btn${period === p ? " active" : ""}`} + onClick={() => onChangePeriod(p)} + > + {p === "annual" ? "ANNUAL" : "QUARTERLY"} + </button> + ))} + </div> + </div> + + {stmt.columns.length === 0 ? ( + <p className="psm-muted-copy psm-fin-empty">Statement data unavailable.</p> + ) : ( + <div className="psm-fin-table-wrap"> + <table className="psm-fin-table"> + <thead> + <tr> + <th className="psm-fin-label-col">USD (millions)</th> + {stmt.columns.map((col, i) => ( + <th key={i} className={`psm-fin-val-col${i === lastColIdx ? " accent" : ""}`}> + {col} + </th> + ))} + </tr> + </thead> + <tbody> + {stmt.rows.map((row, i) => ( + <FinRow key={i} row={row} lastColIdx={lastColIdx} /> + ))} + </tbody> + </table> + </div> + )} + </section> + ); +} +``` + +- [ ] **Step 2: Add CSS to prism-shell.css** + +Append to `frontend/app/prism-shell.css`: + +```css +/* ── Financials Card ─────────────────────────────── */ + +.psm-financials-card { + padding: 0; + display: flex; + flex-direction: column; + min-height: 480px; + overflow: hidden; +} + +.psm-fin-header { + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--line-1); + padding: 0 var(--sp-4); + flex-shrink: 0; +} + +.psm-fin-tabs { + display: flex; + margin-right: auto; +} + +.psm-fin-tab { + padding: var(--sp-3) var(--sp-3); + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); + letter-spacing: 0.04em; + cursor: pointer; + transition: color 150ms ease; + margin-bottom: -1px; +} + +.psm-fin-tab:hover { + color: var(--fg-2); +} + +.psm-fin-tab.active { + border-bottom-color: var(--brass); + color: var(--brass); +} + +.psm-fin-period { + display: flex; + align-items: center; + gap: var(--sp-1); +} + +.psm-fin-period-btn { + padding: 3px var(--sp-2); + background: none; + border: 1px solid var(--line-1); + border-radius: var(--r-1); + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); + letter-spacing: 0.04em; + cursor: pointer; + transition: all 150ms ease; +} + +.psm-fin-period-btn:hover { + color: var(--fg-2); + border-color: var(--line-2); +} + +.psm-fin-period-btn.active { + background: rgba(194, 170, 122, 0.1); + border-color: rgba(194, 170, 122, 0.3); + color: var(--brass); +} + +.psm-fin-table-wrap { + overflow: auto; + flex: 1; +} + +.psm-fin-table { + width: 100%; + border-collapse: collapse; + font-size: var(--fs-13); +} + +.psm-fin-table thead tr { + border-bottom: 1px solid var(--line-1); +} + +.psm-fin-label-col { + text-align: left; + padding: var(--sp-2) var(--sp-4); + color: var(--fg-4); + font-family: var(--font-sans); + font-weight: 400; + min-width: 180px; +} + +.psm-fin-val-col { + text-align: right; + padding: var(--sp-2) var(--sp-3); + color: var(--fg-4); + font-family: var(--font-mono); + font-weight: 400; + white-space: nowrap; +} + +.psm-fin-val-col.accent { + color: var(--brass); +} + +.psm-fin-section-row td { + padding: var(--sp-3) var(--sp-4) var(--sp-1); +} + +.psm-fin-section-label { + color: var(--fg-4); + font-family: var(--font-sans); + font-size: var(--fs-12); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.psm-fin-row td { + border-bottom: 1px solid var(--ink-2); +} + +.psm-fin-row.is-total td { + border-bottom-color: var(--line-1); +} + +.psm-fin-label { + padding: var(--sp-2) var(--sp-4); + color: var(--fg-3); + font-family: var(--font-sans); + white-space: nowrap; +} + +.psm-fin-row.is-indent .psm-fin-label { + padding-left: calc(var(--sp-4) + 12px); +} + +.psm-fin-row.is-total .psm-fin-label { + color: var(--fg-1); + font-weight: 500; +} + +.psm-fin-row.is-margin .psm-fin-label { + font-style: italic; + color: var(--fg-4); + font-size: var(--fs-12); +} + +.psm-fin-val { + text-align: right; + padding: var(--sp-2) var(--sp-3); + color: var(--fg-2); + font-family: var(--font-mono); + white-space: nowrap; +} + +.psm-fin-val.accent { + color: var(--brass); +} + +.psm-fin-val.neg { + color: var(--negative); +} + +.psm-fin-row.is-total .psm-fin-val { + color: var(--fg-1); +} + +.psm-fin-row.is-margin .psm-fin-val { + color: var(--fg-4); + font-size: var(--fs-12); +} + +.psm-fin-empty { + padding: var(--sp-4); +} +``` + +- [ ] **Step 3: Validate with lint + build** + +```bash +cd /home/tyler/Work/prism-v2/frontend && npm run lint 2>&1 | tail -10 && npm run build 2>&1 | tail -10 +``` + +Expected: clean. If unused import errors appear, remove the unused `FinancialRow` or `FinancialStatement` imports from FinancialsCard.tsx. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/components/prism/FinancialsCard.tsx frontend/app/prism-shell.css +git commit -m "feat: add FinancialsCard component with statement tabs and period toggle" +``` + +--- + +## Task 8: Frontend FinancialsPage Component + +**Files:** +- Create: `frontend/components/prism/FinancialsPage.tsx` + +- [ ] **Step 1: Create FinancialsPage.tsx** + +```typescript +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import { buildKpis } from "@/lib/overview"; +import { FinancialsCard } from "@/components/prism/FinancialsCard"; +import { KPIStrip } from "@/components/prism/KPIStrip"; +import { TickerHeader } from "@/components/prism/TickerHeader"; +import type { FinancialsResponse, TickerOverview } from "@/types/api"; + +type StatementKey = "income" | "balance" | "cash_flow"; +type PeriodKey = "annual" | "quarterly"; +type FinState = "loading" | "ready" | "error"; + +type Props = { + ticker: string; + overview: TickerOverview; + isSaved: boolean; + onToggleWatchlist: () => void; +}; + +export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) { + const [statement, setStatement] = useState<StatementKey>("income"); + const [period, setPeriod] = useState<PeriodKey>("annual"); + const [data, setData] = useState<FinancialsResponse | null>(null); + const [finState, setFinState] = useState<FinState>("loading"); + const kpis = buildKpis(overview); + + useEffect(() => { + let cancelled = false; + setFinState("loading"); + setData(null); + + api + .financials(ticker, period) + .then((res) => { + if (!cancelled) { + setData(res); + setFinState("ready"); + } + }) + .catch(() => { + if (!cancelled) setFinState("error"); + }); + + return () => { + cancelled = true; + }; + }, [ticker, period]); + + return ( + <> + <TickerHeader overview={overview} isSaved={isSaved} onToggleWatchlist={onToggleWatchlist} /> + <KPIStrip items={kpis} /> + {finState === "loading" && ( + <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} /> + )} + {finState === "error" && ( + <section className="psm-card"> + <p className="psm-muted-copy">Financial statements unavailable for {ticker}.</p> + </section> + )} + {finState === "ready" && data && ( + <FinancialsCard + data={data} + statement={statement} + period={period} + onChangeStatement={setStatement} + onChangePeriod={setPeriod} + /> + )} + </> + ); +} +``` + +- [ ] **Step 2: Validate with lint + build** + +```bash +cd /home/tyler/Work/prism-v2/frontend && npm run lint 2>&1 | tail -10 && npm run build 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/components/prism/FinancialsPage.tsx +git commit -m "feat: add FinancialsPage data-fetching wrapper" +``` + +--- + +## Task 9: Frontend Routing — Sidebar + page.tsx + +**Files:** +- Modify: `frontend/components/prism/Sidebar.tsx` +- Modify: `frontend/app/page.tsx` + +- [ ] **Step 1: Add onSelectTab to Sidebar** + +In `Sidebar.tsx`, update `Props` type: + +```typescript +type Props = { + navItems: NavItem[]; + selectedKey: string; + currentTicker: string; + watchlist: WatchlistResponse; + watchlistError: string | null; + onSelectTicker: (symbol: string) => void; + onRemoveTicker: (symbol: string) => void; + onSelectTab: (key: string) => void; +}; +``` + +Update the function signature: + +```typescript +export function Sidebar({ + navItems, + selectedKey, + currentTicker, + watchlist, + watchlistError, + onSelectTicker, + onRemoveTicker, + onSelectTab, +}: Props) { +``` + +Update the nav button to call onSelectTab when not disabled: + +```typescript +<button + key={item.key} + type="button" + className={`psm-nav-item${active ? " active" : ""}${item.disabled ? " disabled" : ""}`} + disabled={item.disabled} + onClick={item.disabled ? undefined : () => onSelectTab(item.key)} +> +``` + +- [ ] **Step 2: Update page.tsx — add tab routing** + +In `page.tsx`, update the `OverviewClient` function: + +**Add tab to searchParams reads** (after `selectedTicker` line): +```typescript +const selectedTicker = (searchParams.get("ticker") || "").toUpperCase(); +const tab = searchParams.get("tab") || "overview"; +``` + +**Add import** at top of file: +```typescript +import { FinancialsPage } from "@/components/prism/FinancialsPage"; +``` + +**Add navigateToTab function** (after `navigateToTicker`): +```typescript +const navigateToTab = useCallback( + (key: string) => { + const params = new URLSearchParams(); + if (selectedTicker) params.set("ticker", selectedTicker); + if (key !== "overview") params.set("tab", key); + startTransition(() => { + router.push(`/?${params.toString()}`); + }); + }, + [router, selectedTicker] +); +``` + +**Update navigateToTicker to preserve tab** — replace the `router.push` call inside `navigateToTicker`: +```typescript +const navigateToTicker = useCallback( + (symbol: string) => { + const normalized = symbol.trim().toUpperCase(); + if (!normalized) return; + + setResults([]); + setQuery(""); + setOverview(null); + setHistory([]); + setOverviewError(null); + setChartError(null); + setWatchlistError(null); + setOverviewState("loading"); + setChartState("loading"); + + const params = new URLSearchParams(); + params.set("ticker", normalized); + if (tab !== "overview") params.set("tab", tab); + startTransition(() => { + router.push(`/?${params.toString()}`); + }); + }, + [router, tab] +); +``` + +**Update Sidebar usage** — add `onSelectTab={navigateToTab}` and `selectedKey={tab}`: +```typescript +sidebar={ + <Sidebar + navItems={OVERVIEW_NAV_ITEMS} + selectedKey={tab} + currentTicker={selectedTicker} + watchlist={watchlist} + watchlistError={watchlistError} + onSelectTicker={navigateToTicker} + onRemoveTicker={removeFromWatchlist} + onSelectTab={navigateToTab} + /> +} +``` + +**Replace the overview ready block** — find the block starting with `{overview && overviewState === "ready" ? (` and replace it: +```typescript +{overview && overviewState === "ready" ? ( + tab === "financials" ? ( + <FinancialsPage + ticker={selectedTicker} + overview={overview} + isSaved={isSaved} + onToggleWatchlist={addOrRemoveCurrentTicker} + /> + ) : ( + <> + <TickerHeader overview={overview} onToggleWatchlist={addOrRemoveCurrentTicker} isSaved={isSaved} /> + <KPIStrip items={kpis} /> + <div className="psm-main-grid"> + <div className="psm-column"> + <ChartCard symbol={overview.profile.symbol} period={period} points={history} chartState={chartState} chartError={chartError} onChangePeriod={setPeriod} /> + <SignalCard overview={overview} /> + </div> + <div className="psm-column"> + <DataStatusCard overview={overview} missingFields={missingFields} /> + <ProfileCard overview={overview} /> + <ShortInterestCard overview={overview} /> + <StatsCard overview={overview} /> + </div> + </div> + </> + ) +) : null} +``` + +- [ ] **Step 3: Validate with lint + build** + +```bash +cd /home/tyler/Work/prism-v2/frontend && npm run lint 2>&1 | tail -15 && npm run build 2>&1 | tail -15 +``` + +Expected: clean. If there are `tab` dependency warnings in `useEffect`, add `tab` to the `clearTicker` `useCallback` dependency array. + +- [ ] **Step 4: Smoke test in browser** + +Start the backend and frontend: +```bash +# Terminal 1 — backend +/home/tyler/Work/prism-v2/backend/.venv/bin/uvicorn app.main:app --reload --host 127.0.0.1 --port 8001 --app-dir /home/tyler/Work/prism-v2 + +# Terminal 2 — frontend +cd /home/tyler/Work/prism-v2/frontend && NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8001 npm run dev -- --hostname 127.0.0.1 --port 3001 +``` + +Navigate to `http://127.0.0.1:3001`, search for `AAPL`, then click **Financials** in the sidebar. + +Verify: +- URL changes to `?ticker=AAPL&tab=financials` +- TickerHeader and KPIStrip render +- FinancialsCard loads with Income Statement, Annual period +- Tab switching (Income / Balance / Cash Flow) works +- Period toggle (Annual / Quarterly) triggers a new fetch and re-renders +- Clicking Overview in sidebar returns to `?ticker=AAPL` +- Switching tickers from the Financials tab preserves the `?tab=financials` param + +- [ ] **Step 5: Commit** + +```bash +git add frontend/components/prism/Sidebar.tsx frontend/app/page.tsx +git commit -m "feat: wire financials tab routing in Sidebar and page.tsx" +``` |
