summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-05-17-financials-tab.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-05-17-financials-tab.md')
-rw-r--r--docs/superpowers/plans/2026-05-17-financials-tab.md1391
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"
+```