summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/app/main.py7
-rw-r--r--backend/app/schemas.py21
-rw-r--r--backend/app/services/data_service.py176
-rw-r--r--backend/tests/test_api.py179
-rw-r--r--docs/superpowers/plans/2026-05-17-financials-tab.md1391
-rw-r--r--docs/superpowers/specs/2026-05-17-financials-tab-design.md186
-rw-r--r--frontend/app/page.tsx63
-rw-r--r--frontend/app/prism-shell.css184
-rw-r--r--frontend/components/prism/FinancialsCard.tsx136
-rw-r--r--frontend/components/prism/FinancialsPage.tsx73
-rw-r--r--frontend/components/prism/Sidebar.tsx5
-rw-r--r--frontend/lib/api.ts7
-rw-r--r--frontend/lib/overview.ts2
-rw-r--r--frontend/types/api.ts21
14 files changed, 2424 insertions, 27 deletions
diff --git a/backend/app/main.py b/backend/app/main.py
index fc98a5e..eb5fa40 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -9,7 +9,7 @@ from fastapi import FastAPI, HTTPException, Query, status
from fastapi.middleware.cors import CORSMiddleware
from app.db import watchlist
-from app.schemas import HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse
+from app.schemas import FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse
from app.services import data_service
load_dotenv()
@@ -65,6 +65,11 @@ def ticker_history(symbol: str, period: str = Query(default="1y", pattern="^(1m|
return data_service.get_price_history(symbol, period=period)
+@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)
+
+
@app.get("/api/watchlist", response_model=WatchlistResponse)
def get_watchlist() -> dict:
items = []
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 2ee6dac..86f586c 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -107,6 +107,27 @@ class HistoryPoint(BaseModel):
volume: float | None = None
+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
+
+
class WatchlistItem(BaseModel):
symbol: str
created_at: str
diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py
index da76bdb..f7188f7 100644
--- a/backend/app/services/data_service.py
+++ b/backend/app/services/data_service.py
@@ -19,6 +19,9 @@ HISTORY_CACHE = TTLCache(maxsize=256, ttl=300)
INTRADAY_CACHE = TTLCache(maxsize=128, ttl=60)
MARKET_CACHE = TTLCache(maxsize=8, ttl=300)
STATEMENT_CACHE = TTLCache(maxsize=256, ttl=3600)
+INCOME_CACHE = TTLCache(maxsize=256, ttl=3600)
+BALANCE_CACHE = TTLCache(maxsize=256, ttl=3600)
+CF_CACHE = TTLCache(maxsize=256, ttl=3600)
SHARES_CACHE = TTLCache(maxsize=256, ttl=3600)
RATIO_CACHE = TTLCache(maxsize=256, ttl=3600)
BETA_CACHE = TTLCache(maxsize=256, ttl=3600)
@@ -116,6 +119,173 @@ def _safe_ratio(num: float | None, den: float | None) -> float | None:
return num / den
+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),
+ }
+
+
def _balance_value(frame: pd.DataFrame, *labels: str) -> float | None:
if frame is None or frame.empty:
return None
@@ -281,7 +451,7 @@ def get_intraday_history(symbol: str, period: str, interval: str) -> list[dict[s
return []
-@cached(STATEMENT_CACHE)
+@cached(INCOME_CACHE)
def get_income_statement(symbol: str, quarterly: bool = False) -> pd.DataFrame:
try:
ticker = yf.Ticker(normalize_symbol(symbol))
@@ -291,7 +461,7 @@ def get_income_statement(symbol: str, quarterly: bool = False) -> pd.DataFrame:
return pd.DataFrame()
-@cached(STATEMENT_CACHE)
+@cached(BALANCE_CACHE)
def get_balance_sheet(symbol: str, quarterly: bool = False) -> pd.DataFrame:
try:
ticker = yf.Ticker(normalize_symbol(symbol))
@@ -301,7 +471,7 @@ def get_balance_sheet(symbol: str, quarterly: bool = False) -> pd.DataFrame:
return pd.DataFrame()
-@cached(STATEMENT_CACHE)
+@cached(CF_CACHE)
def get_cash_flow(symbol: str, quarterly: bool = False) -> pd.DataFrame:
try:
ticker = yf.Ticker(normalize_symbol(symbol))
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index b136f23..0054c49 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -12,10 +12,12 @@ def clear_service_caches() -> None:
data_service.PRICE_CACHE.clear()
data_service.HISTORY_CACHE.clear()
data_service.STATEMENT_CACHE.clear()
+ data_service.INCOME_CACHE.clear()
+ data_service.BALANCE_CACHE.clear()
+ data_service.CF_CACHE.clear()
data_service.SHARES_CACHE.clear()
data_service.RATIO_CACHE.clear()
- data_service.BETA_CACHE.clear()
- data_service.SHORT_CACHE.clear()
+ data_service.FINANCIALS_CACHE.clear()
def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame:
@@ -23,6 +25,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"}
@@ -336,6 +343,25 @@ def test_compute_ttm_ratios_guardrails_suppress_outliers(monkeypatch) -> None:
assert "ev_to_ebitda" not in ratios
+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"
+
+
def test_overview_uses_computed_sources_and_ratios(monkeypatch) -> None:
clear_service_caches()
monkeypatch.setattr(
@@ -388,6 +414,155 @@ def test_overview_uses_computed_sources_and_ratios(monkeypatch) -> None:
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.INCOME_CACHE.clear()
+ data_service.BALANCE_CACHE.clear()
+ data_service.CF_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.INCOME_CACHE.clear()
+ data_service.BALANCE_CACHE.clear()
+ data_service.CF_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.INCOME_CACHE.clear()
+ data_service.BALANCE_CACHE.clear()
+ data_service.CF_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.INCOME_CACHE.clear()
+ data_service.BALANCE_CACHE.clear()
+ data_service.CF_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.INCOME_CACHE.clear()
+ data_service.BALANCE_CACHE.clear()
+ data_service.CF_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"] == []
+
+
def test_financials_route_returns_structure(monkeypatch) -> None:
monkeypatch.setattr(
main.data_service,
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"
+```
diff --git a/docs/superpowers/specs/2026-05-17-financials-tab-design.md b/docs/superpowers/specs/2026-05-17-financials-tab-design.md
new file mode 100644
index 0000000..2e7ad3f
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-17-financials-tab-design.md
@@ -0,0 +1,186 @@
+# Financials Tab — Design Spec
+
+**Date:** 2026-05-17
+**Status:** Approved
+
+---
+
+## Overview
+
+Add a Financials tab to Prism v2 that surfaces Income Statement, Balance Sheet, and Cash Flow for the selected ticker. Annual (4 years + TTM) and Quarterly (last 8 quarters) periods are available via a toggle. The tab reuses the existing AppShell and follows the Prism design system exactly.
+
+---
+
+## Routing
+
+The app uses `?ticker=SYMBOL` today. Tab state is added as a second query param: `?ticker=AAPL&tab=financials`.
+
+- Clicking "Financials" in the Sidebar pushes `?ticker=AAPL&tab=financials`
+- `page.tsx` reads both `ticker` and `tab` from `useSearchParams`
+- When `tab === "financials"` and a ticker is loaded, render `<FinancialsPage>` in place of the Overview columns
+- When `tab` is absent or `"overview"`, existing behavior is unchanged
+- `OVERVIEW_NAV_ITEMS` in `lib/overview.ts` gains a `financials` entry with the `ledger` icon
+
+This approach keeps `page.tsx` as a thin shell. The financials content lives in its own component file. When more tabs are built out, each gets the same treatment — a natural migration to proper Next.js routes can happen once 4+ tabs exist.
+
+---
+
+## Backend
+
+### New endpoint
+
+```
+GET /api/tickers/{symbol}/financials?period=annual|quarterly
+```
+
+- `period` defaults to `annual`
+- TTL cache: 1 hour (matching v1 financials TTL)
+- Returns `FinancialsResponse`
+
+### Schemas (`backend/app/schemas.py`)
+
+```python
+class FinancialRow(BaseModel):
+ label: str
+ indent: int = 0 # 0 = top-level/total, 1 = sub-item
+ is_total: bool = False # bold, fg-0 color
+ is_section: bool = False # small muted eyebrow label (no values)
+ is_margin: bool = False # small italic % row
+ values: list[float | None]
+
+class FinancialStatement(BaseModel):
+ columns: list[str] # e.g. ["FY 2021", "FY 2022", ..., "TTM"] or ["Q1 2024", ...]
+ rows: list[FinancialRow]
+
+class FinancialsResponse(BaseModel):
+ period: Literal["annual", "quarterly"]
+ income: FinancialStatement
+ balance: FinancialStatement
+ cash_flow: FinancialStatement
+```
+
+### Data service (`backend/app/services/data_service.py`)
+
+New function `get_financials(ticker, period)` using yfinance:
+
+- **Annual:** `t.income_stmt`, `t.balance_sheet`, `t.cashflow` (4 fiscal years). TTM column computed as sum of last 4 quarters from quarterly statements. Balance sheet last column is MRQ (most recent quarter), not TTM.
+- **Quarterly:** `t.quarterly_income_stmt`, `t.quarterly_balance_sheet`, `t.quarterly_cashflow` (last 8 quarters). No TTM column.
+- Follows v1 `get_income_statement` / `get_balance_sheet` / `get_cash_flow` pattern with empty-DataFrame fallback on error.
+- Margin rows (gross margin, net margin, FCF margin) are computed server-side and included as `is_margin=True` rows. FCF margin = FCF / Total Revenue; Revenue is pulled from the income statement so both statements share the same fetch call.
+
+---
+
+## Row Definitions
+
+### Income Statement
+
+| Label | indent | is_total | is_margin | yfinance key |
+|-------|--------|----------|-----------|--------------|
+| Total Revenue | 0 | true | | `Total Revenue` |
+| Cost of Revenue | 1 | | | `Cost Of Revenue` |
+| Gross Profit | 0 | true | | `Gross Profit` |
+| gross margin | 1 | | true | derived |
+| Operating Expenses | 1 | | | `Operating Expense` |
+| Operating Income | 0 | true | | `Operating Income` |
+| EBITDA | 1 | | | `EBITDA` or `Normalized EBITDA` |
+| Interest Expense | 1 | | | `Interest Expense` |
+| Pretax Income | 0 | | | `Pretax Income` |
+| Tax Provision | 1 | | | `Tax Provision` |
+| Net Income | 0 | true | | `Net Income` |
+| net margin | 1 | | true | derived |
+| EPS Basic | 1 | | | `Basic EPS` |
+
+### Balance Sheet
+
+Section eyebrows: ASSETS, LIABILITIES, EQUITY (is_section=True, no values)
+
+| Label | indent | is_total | Section |
+|-------|--------|----------|---------|
+| Current Assets | 0 | true | ASSETS |
+| Cash & Equivalents | 1 | | |
+| Short Term Investments | 1 | | |
+| Receivables | 1 | | |
+| Inventory | 1 | | |
+| Total Assets | 0 | true | |
+| Current Liabilities | 0 | true | LIABILITIES |
+| Accounts Payable | 1 | | |
+| Short Term Debt | 1 | | |
+| Long Term Debt | 1 | | |
+| Total Liabilities | 0 | true | |
+| Stockholders Equity | 0 | true | EQUITY |
+
+Balance sheet columns: 4 fiscal years + MRQ (most recent quarter). MRQ is always from `quarterly_balance_sheet.iloc[:, 0]`.
+
+### Cash Flow
+
+Section eyebrows: OPERATING, INVESTING, FINANCING (is_section=True)
+
+| Label | indent | is_total | is_margin | Section |
+|-------|--------|----------|-----------|---------|
+| Net Income | 1 | | | OPERATING |
+| D&A | 1 | | | |
+| Changes in Working Capital | 1 | | | |
+| Operating Cash Flow | 0 | true | | |
+| CapEx | 1 | | | INVESTING |
+| Free Cash Flow | 0 | true | | |
+| FCF margin | 1 | | true | |
+| Investing Cash Flow | 0 | true | | |
+| Dividends Paid | 1 | | | FINANCING |
+| Buybacks | 1 | | | |
+| Financing Cash Flow | 0 | true | | |
+| Net Change in Cash | 0 | true | | |
+
+---
+
+## Frontend
+
+### New files
+
+- `frontend/components/prism/FinancialsCard.tsx` — the card component with tabbed statements and period toggle
+- `frontend/components/prism/FinancialsPage.tsx` — thin wrapper rendered by `page.tsx` when `tab === "financials"`, handles data fetching state
+
+### Modified files
+
+- `frontend/types/api.ts` — add `FinancialRow`, `FinancialStatement`, `FinancialsResponse`
+- `frontend/lib/api.ts` — add `api.financials(symbol, period)`
+- `frontend/lib/overview.ts` — add `financials` to `OVERVIEW_NAV_ITEMS`
+- `frontend/app/page.tsx` — read `tab` from `useSearchParams`, render `<FinancialsPage>` when appropriate
+
+### Types
+
+```ts
+type StatementKey = "income" | "balance" | "cash_flow"
+type PeriodKey = "annual" | "quarterly"
+```
+
+### FinancialsCard
+
+Props: `{ data: FinancialsResponse, statement: StatementKey, period: PeriodKey, onChangeStatement, onChangePeriod }`
+
+- Card header: statement tabs (INCOME / BALANCE / CASH FLOW) left-aligned, underline active indicator. Period toggle (ANNUAL / QUARTERLY) right-aligned. Same row, `border-bottom: 1px solid var(--hairline)`.
+- Table: `<table>` with sticky first column (label). Columns right-aligned, IBM Plex Mono.
+- Row rendering by type:
+ - `is_section`: full-width muted eyebrow cell, `colspan` across all columns, no value cells
+ - `is_total`: `color: var(--fg-0)`, `font-weight: 500`
+ - `is_margin`: smaller font, italic, muted color (`var(--fg-3)`)
+ - `indent=1`: left padding `26px` vs `14px`
+ - Negative values: `color: var(--loss)`
+ - TTM / MRQ column header: `color: var(--brass)`; value cells: `color: var(--brass)`
+- Loading state: skeleton rows (3 pulse placeholders)
+- Error state: inline muted copy "Statement data unavailable"
+- Missing values (`null`): render as `—`
+
+### FinancialsPage
+
+Props: `{ overview: TickerOverview, ticker: string }` — `page.tsx` already fetches `overview` for the header and passes it down.
+
+Handles three states: `loading`, `ready`, `error` for the financials fetch. Renders `<TickerHeader>` and `<KPIStrip>` (same components as Overview, fed from the `overview` prop), then `<FinancialsCard>`. Fetches financials on mount and when `ticker` or `period` changes.
+
+---
+
+## What Is Not In Scope
+
+- Charts / sparklines on financial rows (future)
+- Peer comparison columns (future)
+- Export to CSV (future)
+- Key Ratios section — already covered on the Overview tab's Reference card
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 44428aa..3bec411 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -4,6 +4,7 @@ import { FormEvent, Suspense, startTransition, useCallback, useEffect, useMemo,
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { AppShell } from "@/components/prism/AppShell";
+import { FinancialsPage } from "@/components/prism/FinancialsPage";
import { ChartCard } from "@/components/prism/ChartCard";
import { KPIStrip } from "@/components/prism/KPIStrip";
import { Sidebar } from "@/components/prism/Sidebar";
@@ -29,6 +30,7 @@ function OverviewClient() {
const router = useRouter();
const searchParams = useSearchParams();
const selectedTicker = (searchParams.get("ticker") || "").toUpperCase();
+ const tab = searchParams.get("tab") || "overview";
const lastTickerRef = useRef("");
const [query, setQuery] = useState("");
@@ -72,11 +74,26 @@ function OverviewClient() {
setOverviewState("loading");
setChartState("loading");
+ const params = new URLSearchParams();
+ params.set("ticker", normalized);
+ if (tab !== "overview") params.set("tab", tab);
startTransition(() => {
- router.push(`/?ticker=${encodeURIComponent(normalized)}`);
+ router.push(`/?${params.toString()}`);
});
},
- [router]
+ [router, tab]
+ );
+
+ 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]
);
const clearTicker = useCallback(() => {
@@ -245,12 +262,13 @@ function OverviewClient() {
sidebar={
<Sidebar
navItems={OVERVIEW_NAV_ITEMS}
- selectedKey="overview"
+ selectedKey={tab}
currentTicker={selectedTicker}
watchlist={watchlist}
watchlistError={watchlistError}
onSelectTicker={navigateToTicker}
onRemoveTicker={removeFromWatchlist}
+ onSelectTab={navigateToTab}
/>
}
topbar={
@@ -271,22 +289,31 @@ function OverviewClient() {
{selectedTicker && overviewState === "invalid" ? <InvalidTickerState symbol={selectedTicker} onClear={clearTicker} /> : null}
{selectedTicker && overviewState === "error" ? <ErrorOverviewState message={overviewError || "Ticker data unavailable"} /> : null}
{overview && overviewState === "ready" ? (
- <>
- <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} />
+ 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>
- </div>
- </>
+ </>
+ )
) : null}
</AppShell>
);
diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css
index 6ccf9ca..cd0023a 100644
--- a/frontend/app/prism-shell.css
+++ b/frontend/app/prism-shell.css
@@ -1105,3 +1105,187 @@
grid-column: 3;
}
}
+
+/* ── 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);
+}
diff --git a/frontend/components/prism/FinancialsCard.tsx b/frontend/components/prism/FinancialsCard.tsx
new file mode 100644
index 0000000..94a6618
--- /dev/null
+++ b/frontend/components/prism/FinancialsCard.tsx
@@ -0,0 +1,136 @@
+"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>
+ );
+}
diff --git a/frontend/components/prism/FinancialsPage.tsx b/frontend/components/prism/FinancialsPage.tsx
new file mode 100644
index 0000000..fcd2763
--- /dev/null
+++ b/frontend/components/prism/FinancialsPage.tsx
@@ -0,0 +1,73 @@
+"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}
+ />
+ )}
+ </>
+ );
+}
diff --git a/frontend/components/prism/Sidebar.tsx b/frontend/components/prism/Sidebar.tsx
index 7f106d8..fb8ebcf 100644
--- a/frontend/components/prism/Sidebar.tsx
+++ b/frontend/components/prism/Sidebar.tsx
@@ -12,6 +12,7 @@ type Props = {
watchlistError: string | null;
onSelectTicker: (symbol: string) => void;
onRemoveTicker: (symbol: string) => void;
+ onSelectTab: (key: string) => void;
};
export function Sidebar({
@@ -21,7 +22,8 @@ export function Sidebar({
watchlist,
watchlistError,
onSelectTicker,
- onRemoveTicker
+ onRemoveTicker,
+ onSelectTab
}: Props) {
return (
<aside className="psm-side">
@@ -46,6 +48,7 @@ export function Sidebar({
type="button"
className={`psm-nav-item${active ? " active" : ""}${item.disabled ? " disabled" : ""}`}
disabled={item.disabled}
+ onClick={item.disabled ? undefined : () => onSelectTab(item.key)}
>
<span className={`psm-icon icon-${item.icon}`} aria-hidden />
<span className="psm-nav-copy">
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index b2f0dea..05af5ae 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -1,4 +1,4 @@
-import type { HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse } from "@/types/api";
+import type { FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse } from "@/types/api";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
@@ -48,5 +48,10 @@ export const api = {
},
removeWatchlist(symbol: string) {
return request<WatchlistResponse>(`/api/watchlist/${encodeURIComponent(symbol)}`, { method: "DELETE" });
+ },
+ financials(symbol: string, period: "annual" | "quarterly" = "annual") {
+ return request<FinancialsResponse>(
+ `/api/tickers/${encodeURIComponent(symbol)}/financials?period=${encodeURIComponent(period)}`
+ );
}
};
diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts
index d02c5d3..1ec793a 100644
--- a/frontend/lib/overview.ts
+++ b/frontend/lib/overview.ts
@@ -17,7 +17,7 @@ export type NavItem = {
export const OVERVIEW_NAV_ITEMS: NavItem[] = [
{ key: "overview", label: "Overview", icon: "chart" },
- { key: "financials", label: "Financials", icon: "ledger", disabled: true },
+ { key: "financials", label: "Financials", icon: "ledger" },
{ key: "valuation", label: "Valuation", icon: "dollar", disabled: true },
{ key: "options", label: "Options", icon: "window", disabled: true },
{ key: "insiders", label: "Insiders", icon: "pulse", disabled: true },
diff --git a/frontend/types/api.ts b/frontend/types/api.ts
index 84dfd19..3cc93e4 100644
--- a/frontend/types/api.ts
+++ b/frontend/types/api.ts
@@ -99,3 +99,24 @@ export type WatchlistResponse = {
items: WatchlistItem[];
limit: number;
};
+
+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;
+};