summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-05-18-valuation-tab.md1673
1 files changed, 1673 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-05-18-valuation-tab.md b/docs/superpowers/plans/2026-05-18-valuation-tab.md
new file mode 100644
index 0000000..490902d
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-18-valuation-tab.md
@@ -0,0 +1,1673 @@
+# Valuation 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 read-only Valuation tab to Prism v2 that shows a DCF intrinsic value, three multiples-based implied prices (EV/EBITDA, EV/Revenue, P/B), and a summary strip comparing all four models against the current market price.
+
+**Architecture:** Port the valuation math from `../prism/services/valuation_service.py` directly into `backend/app/services/data_service.py` as private helper functions. A new `get_valuation()` service function assembles data from existing helpers (`get_cash_flow`, `get_balance_sheet`, `get_income_statement`, `get_company_info`, `get_shares_outstanding`) and calls the math functions. The frontend mirrors the Financials tab pattern: `ValuationPage.tsx` (data fetch + states) wraps `ValuationCard.tsx` (pure display), wired into `page.tsx` with a `tab === "valuation"` branch.
+
+**Tech Stack:** Python/FastAPI + Pydantic (backend), Next.js/TypeScript (frontend), yfinance (data), cachetools TTLCache (caching), pytest + monkeypatch (testing)
+
+---
+
+## Repository context
+
+- **Working directory / worktree:** `/home/tyler/Work/prism-v2/.claude/worktrees/valuation-tab`
+- **Branch:** `worktree-valuation-tab`
+- **Run backend tests from repo root:** `/home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q`
+- **Run a single test:** `/home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_name -v`
+- **Run frontend build check:** `cd frontend && NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8001 npm run build`
+- **Run frontend lint:** `cd frontend && npm run lint`
+
+## Design decisions
+
+- Multiples (EV/EBITDA, EV/Revenue, P/B) use the company's **own trailing market multiple** from yfinance `.info` (`enterpriseToEbitda`, `enterpriseToRevenue`, `priceToBook`) as the target. Result is "market-implied price at current multiple" — a cross-check against DCF. UI labels say "at current market multiple."
+- DCF defaults: `wacc=0.10`, `terminal_growth=0.03`, `projection_years=5`. No slider overrides in this scope.
+- FCF series: `Operating Cash Flow + Capital Expenditure` from **annual** cash flow statement. CapEx is negative in yfinance — this is addition not subtraction.
+- Layout: summary strip (5 chips) → DCF detail panel → multiples rows. No subtabs.
+- Design spec: `docs/superpowers/specs/2026-05-18-valuation-tab-design.md`
+
+## File map
+
+| Action | File | Purpose |
+|--------|------|---------|
+| Modify | `backend/app/schemas.py` | Add `DcfResult`, `MultipleResult`, `ValuationResponse` |
+| Modify | `backend/app/services/data_service.py` | Add `VALUATION_CACHE`, math helpers, `get_valuation()` |
+| Modify | `backend/app/main.py` | Add `/api/tickers/{symbol}/valuation` route |
+| Modify | `backend/tests/test_api.py` | Add all new tests; update `clear_service_caches()` |
+| Modify | `frontend/types/api.ts` | Add `DcfResult`, `MultipleResult`, `ValuationResponse` types |
+| Modify | `frontend/lib/api.ts` | Add `valuation(symbol)` method |
+| Modify | `frontend/lib/overview.ts` | Remove `disabled: true` from valuation nav item |
+| Create | `frontend/components/prism/ValuationCard.tsx` | Display component (summary strip + DCF detail + multiples) |
+| Create | `frontend/components/prism/ValuationPage.tsx` | Data-fetch wrapper (mirrors FinancialsPage) |
+| Modify | `frontend/app/prism-shell.css` | Add `.psm-val-*` styles |
+| Modify | `frontend/app/page.tsx` | Add `tab === "valuation"` branch |
+
+---
+
+## Task 1: Backend schemas
+
+**Files:**
+- Modify: `backend/app/schemas.py`
+- Test: `backend/tests/test_api.py`
+
+- [ ] **Step 1: Write the failing test**
+
+ Append to `backend/tests/test_api.py`:
+
+ ```python
+ def test_valuation_schema_structure() -> None:
+ from app.schemas import DcfResult, MultipleResult, ValuationResponse
+
+ dcf_unavail = DcfResult(available=False)
+ assert dcf_unavail.available is False
+ assert dcf_unavail.wacc == 0.10
+ assert dcf_unavail.terminal_growth == 0.03
+ assert dcf_unavail.error is None
+ assert dcf_unavail.intrinsic_value_per_share is None
+
+ mult_unavail = MultipleResult(available=False)
+ assert mult_unavail.available is False
+ assert mult_unavail.implied_price_per_share is None
+
+ resp = ValuationResponse(
+ symbol="AAPL",
+ current_price=150.0,
+ shares_outstanding=15_000_000_000.0,
+ dcf=DcfResult(available=True, intrinsic_value_per_share=182.0, growth_rate_used=0.082),
+ ev_ebitda=MultipleResult(available=True, implied_price_per_share=178.0, multiple_used=20.0),
+ ev_revenue=MultipleResult(available=False),
+ price_to_book=MultipleResult(available=False),
+ )
+ assert resp.symbol == "AAPL"
+ assert resp.dcf.intrinsic_value_per_share == 182.0
+ assert resp.ev_ebitda.multiple_used == 20.0
+ assert resp.ev_revenue.available is False
+ ```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_valuation_schema_structure -v
+ ```
+
+ Expected: `FAILED` — `ImportError: cannot import name 'DcfResult' from 'app.schemas'`
+
+- [ ] **Step 3: Add schemas to `backend/app/schemas.py`**
+
+ Append after `class FinancialsResponse`:
+
+ ```python
+ class DcfResult(BaseModel):
+ available: bool = True
+ error: str | None = None
+ intrinsic_value_per_share: float | None = None
+ enterprise_value: float | None = None
+ equity_value: float | None = None
+ net_debt: float | None = None
+ cash_and_equivalents: float | None = None
+ total_debt: float | None = None
+ terminal_value_pv: float | None = None
+ fcf_pv_sum: float | None = None
+ growth_rate_used: float | None = None
+ base_fcf: float | None = None
+ wacc: float = 0.10
+ terminal_growth: float = 0.03
+
+
+ class MultipleResult(BaseModel):
+ available: bool = True
+ implied_price_per_share: float | None = None
+ implied_ev: float | None = None
+ equity_value: float | None = None
+ net_debt: float | None = None
+ multiple_used: float | None = None
+
+
+ class ValuationResponse(BaseModel):
+ symbol: str
+ current_price: float | None = None
+ shares_outstanding: float | None = None
+ dcf: DcfResult
+ ev_ebitda: MultipleResult
+ ev_revenue: MultipleResult
+ price_to_book: MultipleResult
+ ```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_valuation_schema_structure -v
+ ```
+
+ Expected: `PASSED`
+
+- [ ] **Step 5: Commit**
+
+ ```bash
+ git add backend/app/schemas.py backend/tests/test_api.py
+ git commit -m "feat: add DcfResult, MultipleResult, ValuationResponse schemas"
+ ```
+
+---
+
+## Task 2: Valuation math helpers
+
+**Files:**
+- Modify: `backend/app/services/data_service.py`
+- Test: `backend/tests/test_api.py`
+
+These are the internal helpers: FCF series builder, growth rate computation, multiple result builder.
+
+- [ ] **Step 1: Write the failing tests**
+
+ Append to `backend/tests/test_api.py`:
+
+ ```python
+ def test_build_fcf_series_happy_path() -> None:
+ cf = annual_frame({
+ "Operating Cash Flow": [100.0, 90.0, 80.0, 70.0],
+ "Capital Expenditure": [-10.0, -9.0, -8.0, -7.0],
+ })
+ result = data_service._build_fcf_series(cf)
+ assert result is not None
+ assert len(result) == 4
+ # most recent year FCF = 100 + (-10) = 90
+ assert result.iloc[-1] == 90.0
+
+
+ def test_build_fcf_series_empty_df() -> None:
+ import pandas as pd
+ result = data_service._build_fcf_series(pd.DataFrame())
+ assert result is None
+
+
+ def test_build_fcf_series_missing_capex() -> None:
+ cf = annual_frame({"Operating Cash Flow": [100.0, 90.0, 80.0, 70.0]})
+ result = data_service._build_fcf_series(cf)
+ assert result is None
+
+
+ def test_build_multiple_result_empty() -> None:
+ result = data_service._build_multiple_result({})
+ assert result == {"available": False}
+
+
+ def test_build_multiple_result_valid() -> None:
+ raw = {
+ "implied_price_per_share": 178.0,
+ "implied_ev": 1_000.0,
+ "equity_value": 900.0,
+ "net_debt": 100.0,
+ "target_multiple_used": 20.0,
+ }
+ result = data_service._build_multiple_result(raw)
+ assert result["available"] is True
+ assert result["implied_price_per_share"] == 178.0
+ assert result["multiple_used"] == 20.0
+
+
+ def test_dcf_capped_growth_rate_caps_extremes() -> None:
+ import pandas as pd
+ # growth of 200% should be capped at 50%
+ series = pd.Series([10.0, 30.0], index=pd.to_datetime(["2022", "2023"]))
+ result = data_service._dcf_capped_growth_rate(series)
+ assert result == 0.50
+
+
+ def test_dcf_capped_growth_rate_skips_sign_flip() -> None:
+ import pandas as pd
+ # negative to positive is a sign flip — should skip and return None (no usable periods)
+ series = pd.Series([-10.0, 20.0], index=pd.to_datetime(["2022", "2023"]))
+ result = data_service._dcf_capped_growth_rate(series)
+ assert result is None
+ ```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_build_fcf_series_happy_path backend/tests/test_api.py::test_build_multiple_result_empty -v
+ ```
+
+ Expected: `FAILED` — `AttributeError: module ... has no attribute '_build_fcf_series'`
+
+- [ ] **Step 3: Add helpers to `backend/app/services/data_service.py`**
+
+ Add after the existing `FINANCIALS_CACHE` line:
+
+ ```python
+ VALUATION_CACHE = TTLCache(maxsize=128, ttl=3600)
+ ```
+
+ Add after the `_build_cash_flow` function (before `get_financials`):
+
+ ```python
+ _GROWTH_FLOOR = -0.50
+ _GROWTH_CAP = 0.50
+ _GROWTH_MIN_BASE = 1e-9
+
+
+ def _cap_growth(value: float) -> float:
+ return max(_GROWTH_FLOOR, min(_GROWTH_CAP, float(value)))
+
+
+ def _dcf_capped_growth_rate(fcf_series: "pd.Series") -> float | None:
+ historical = fcf_series.sort_index().dropna().astype(float).values
+ if len(historical) < 2:
+ return None
+ rates = []
+ for i in range(1, len(historical)):
+ prev, curr = float(historical[i - 1]), float(historical[i])
+ if abs(prev) < _GROWTH_MIN_BASE:
+ continue
+ if prev <= 0 or curr <= 0:
+ continue
+ rates.append((curr - prev) / prev)
+ if not rates:
+ return None
+ raw = float(pd.Series(rates).median())
+ return _cap_growth(raw)
+
+
+ def _build_fcf_series(cf_annual: "pd.DataFrame") -> "pd.Series | None":
+ if cf_annual is None or cf_annual.empty:
+ return None
+ op_labels = ("Operating Cash Flow", "Cash Flow From Continuing Operating Activities")
+ op_row = None
+ for label in op_labels:
+ if label in cf_annual.index:
+ op_row = pd.to_numeric(cf_annual.loc[label], errors="coerce")
+ break
+ if op_row is None or "Capital Expenditure" not in cf_annual.index:
+ return None
+ capex_row = pd.to_numeric(cf_annual.loc["Capital Expenditure"], errors="coerce")
+ fcf = (op_row + capex_row).dropna().sort_index()
+ return fcf if not fcf.empty else None
+
+
+ def _build_multiple_result(raw: dict) -> dict:
+ if not raw:
+ return {"available": False}
+ return {
+ "available": True,
+ "implied_price_per_share": raw.get("implied_price_per_share"),
+ "implied_ev": raw.get("implied_ev"),
+ "equity_value": raw.get("equity_value"),
+ "net_debt": raw.get("net_debt"),
+ "multiple_used": raw.get("target_multiple_used"),
+ }
+ ```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py -k "fcf_series or multiple_result or dcf_capped" -v
+ ```
+
+ Expected: all `PASSED`
+
+- [ ] **Step 5: Commit**
+
+ ```bash
+ git add backend/app/services/data_service.py backend/tests/test_api.py
+ git commit -m "feat: add valuation math helpers and VALUATION_CACHE"
+ ```
+
+---
+
+## Task 3: DCF and multiples run functions
+
+**Files:**
+- Modify: `backend/app/services/data_service.py`
+- Test: `backend/tests/test_api.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+ Append to `backend/tests/test_api.py`:
+
+ ```python
+ def test_run_dcf_happy_path() -> None:
+ import pandas as pd
+ fcf = pd.Series(
+ [70.0, 80.0, 90.0, 100.0],
+ index=pd.to_datetime(["2021", "2022", "2023", "2024"]),
+ )
+ result = data_service._run_dcf(fcf, shares_outstanding=1_000_000_000.0)
+ assert "intrinsic_value_per_share" in result
+ assert result["intrinsic_value_per_share"] > 0
+ assert "growth_rate_used" in result
+ assert "enterprise_value" in result
+ assert "net_debt" in result
+
+
+ def test_run_dcf_negative_base_fcf() -> None:
+ import pandas as pd
+ # last (most recent) FCF is negative
+ fcf = pd.Series(
+ [100.0, 90.0, 80.0, -50.0],
+ index=pd.to_datetime(["2021", "2022", "2023", "2024"]),
+ )
+ result = data_service._run_dcf(fcf, shares_outstanding=1_000_000_000.0)
+ assert "error" in result
+ assert result["error"]
+
+
+ def test_run_dcf_insufficient_history() -> None:
+ import pandas as pd
+ fcf = pd.Series([100.0], index=pd.to_datetime(["2024"]))
+ result = data_service._run_dcf(fcf, shares_outstanding=1_000_000_000.0)
+ assert result == {}
+
+
+ def test_run_dcf_zero_shares() -> None:
+ import pandas as pd
+ fcf = pd.Series([100.0, 110.0], index=pd.to_datetime(["2023", "2024"]))
+ result = data_service._run_dcf(fcf, shares_outstanding=0.0)
+ assert result == {}
+
+
+ def test_run_ev_ebitda_happy_path() -> None:
+ result = data_service._run_ev_ebitda(
+ ebitda=100.0,
+ total_debt=50.0,
+ total_cash=20.0,
+ preferred_equity=0.0,
+ minority_interest=0.0,
+ shares_outstanding=10.0,
+ target_multiple=15.0,
+ )
+ # implied_ev = 100 * 15 = 1500; net_debt = 50-20 = 30; equity = 1470; per_share = 147
+ assert result["implied_price_per_share"] == 147.0
+ assert result["implied_ev"] == 1500.0
+ assert result["net_debt"] == 30.0
+
+
+ def test_run_ev_ebitda_zero_ebitda() -> None:
+ result = data_service._run_ev_ebitda(
+ ebitda=0.0, total_debt=0.0, total_cash=0.0,
+ preferred_equity=0.0, minority_interest=0.0,
+ shares_outstanding=10.0, target_multiple=15.0,
+ )
+ assert result == {}
+
+
+ def test_run_ev_revenue_happy_path() -> None:
+ result = data_service._run_ev_revenue(
+ revenue=500.0,
+ total_debt=50.0,
+ total_cash=20.0,
+ preferred_equity=0.0,
+ minority_interest=0.0,
+ shares_outstanding=10.0,
+ target_multiple=5.0,
+ )
+ # implied_ev = 500*5 = 2500; net_debt = 30; equity = 2470; per_share = 247
+ assert result["implied_price_per_share"] == 247.0
+
+
+ def test_run_ev_revenue_zero_revenue() -> None:
+ result = data_service._run_ev_revenue(
+ revenue=0.0, total_debt=0.0, total_cash=0.0,
+ preferred_equity=0.0, minority_interest=0.0,
+ shares_outstanding=10.0, target_multiple=5.0,
+ )
+ assert result == {}
+
+
+ def test_run_price_to_book_happy_path() -> None:
+ result = data_service._run_price_to_book(
+ book_value_per_share=20.0, target_multiple=3.0
+ )
+ assert result["implied_price_per_share"] == 60.0
+ assert result["target_multiple_used"] == 3.0
+ assert result["book_value_per_share"] == 20.0
+
+
+ def test_run_price_to_book_zero_bvps() -> None:
+ result = data_service._run_price_to_book(
+ book_value_per_share=0.0, target_multiple=3.0
+ )
+ assert result == {}
+ ```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py -k "run_dcf or run_ev or run_price_to_book" -v
+ ```
+
+ Expected: `FAILED` — `AttributeError: module ... has no attribute '_run_dcf'`
+
+- [ ] **Step 3: Add run functions to `backend/app/services/data_service.py`**
+
+ Add after `_build_multiple_result`:
+
+ ```python
+ def _run_dcf(
+ fcf_series: "pd.Series",
+ shares_outstanding: float,
+ wacc: float = 0.10,
+ terminal_growth: float = 0.03,
+ projection_years: int = 5,
+ total_debt: float = 0.0,
+ cash_and_equivalents: float = 0.0,
+ preferred_equity: float = 0.0,
+ minority_interest: float = 0.0,
+ ) -> dict:
+ if fcf_series.empty or shares_outstanding <= 0:
+ return {}
+ historical = fcf_series.sort_index().dropna().astype(float).values
+ if len(historical) < 2:
+ return {}
+ if wacc <= 0:
+ return {"error": "WACC must be greater than 0%."}
+ if terminal_growth >= wacc:
+ return {"error": "Terminal growth must be lower than WACC."}
+
+ growth_rate = _dcf_capped_growth_rate(fcf_series)
+ if growth_rate is None:
+ growth_rate = 0.05
+
+ base_fcf = float(historical[-1])
+ if base_fcf <= 0:
+ return {
+ "error": (
+ "DCF is not meaningful with zero or negative base free cash flow. "
+ "Use comps, EV/EBITDA, or adjust the model after underwriting a credible FCF turnaround."
+ )
+ }
+
+ projected = [base_fcf * ((1 + growth_rate) ** yr) for yr in range(1, projection_years + 1)]
+ discounted = [fcf / ((1 + wacc) ** i) for i, fcf in enumerate(projected, start=1)]
+ fcf_pv_sum = float(sum(discounted))
+
+ terminal_fcf = float(projected[-1]) * (1 + terminal_growth)
+ terminal_value = terminal_fcf / (wacc - terminal_growth)
+ terminal_value_pv = terminal_value / ((1 + wacc) ** projection_years)
+
+ enterprise_value = fcf_pv_sum + terminal_value_pv
+ total_debt = float(total_debt or 0.0)
+ cash_and_equivalents = float(cash_and_equivalents or 0.0)
+ preferred_equity = float(preferred_equity or 0.0)
+ minority_interest = float(minority_interest or 0.0)
+
+ net_debt = total_debt - cash_and_equivalents
+ equity_value = enterprise_value - net_debt - preferred_equity - minority_interest
+ intrinsic_value_per_share = equity_value / shares_outstanding
+
+ return {
+ "intrinsic_value_per_share": intrinsic_value_per_share,
+ "enterprise_value": enterprise_value,
+ "equity_value": equity_value,
+ "net_debt": net_debt,
+ "cash_and_equivalents": cash_and_equivalents,
+ "total_debt": total_debt,
+ "terminal_value_pv": terminal_value_pv,
+ "fcf_pv_sum": fcf_pv_sum,
+ "growth_rate_used": growth_rate,
+ "base_fcf": base_fcf,
+ }
+
+
+ def _run_ev_ebitda(
+ ebitda: float,
+ total_debt: float,
+ total_cash: float,
+ preferred_equity: float,
+ minority_interest: float,
+ shares_outstanding: float,
+ target_multiple: float,
+ ) -> dict:
+ if not ebitda or ebitda <= 0:
+ return {}
+ if not shares_outstanding or shares_outstanding <= 0:
+ return {}
+ if not target_multiple or target_multiple <= 0:
+ return {}
+ implied_ev = ebitda * target_multiple
+ net_debt = (total_debt or 0.0) - (total_cash or 0.0)
+ other_claims = (preferred_equity or 0.0) + (minority_interest or 0.0)
+ equity_value = implied_ev - net_debt - other_claims
+ return {
+ "implied_ev": implied_ev,
+ "net_debt": net_debt,
+ "equity_value": equity_value,
+ "implied_price_per_share": equity_value / shares_outstanding,
+ "target_multiple_used": target_multiple,
+ }
+
+
+ def _run_ev_revenue(
+ revenue: float,
+ total_debt: float,
+ total_cash: float,
+ preferred_equity: float,
+ minority_interest: float,
+ shares_outstanding: float,
+ target_multiple: float,
+ ) -> dict:
+ if not revenue or revenue <= 0:
+ return {}
+ if not shares_outstanding or shares_outstanding <= 0:
+ return {}
+ if not target_multiple or target_multiple <= 0:
+ return {}
+ implied_ev = revenue * target_multiple
+ net_debt = (total_debt or 0.0) - (total_cash or 0.0)
+ other_claims = (preferred_equity or 0.0) + (minority_interest or 0.0)
+ equity_value = implied_ev - net_debt - other_claims
+ return {
+ "implied_ev": implied_ev,
+ "net_debt": net_debt,
+ "equity_value": equity_value,
+ "implied_price_per_share": equity_value / shares_outstanding,
+ "target_multiple_used": target_multiple,
+ }
+
+
+ def _run_price_to_book(book_value_per_share: float, target_multiple: float) -> dict:
+ if not book_value_per_share or book_value_per_share <= 0:
+ return {}
+ if not target_multiple or target_multiple <= 0:
+ return {}
+ return {
+ "implied_price_per_share": float(book_value_per_share) * float(target_multiple),
+ "target_multiple_used": float(target_multiple),
+ "book_value_per_share": float(book_value_per_share),
+ }
+ ```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py -k "run_dcf or run_ev or run_price_to_book" -v
+ ```
+
+ Expected: all `PASSED`
+
+- [ ] **Step 5: Run full suite to check for regressions**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q
+ ```
+
+ Expected: all existing tests + new tests passing
+
+- [ ] **Step 6: Commit**
+
+ ```bash
+ git add backend/app/services/data_service.py backend/tests/test_api.py
+ git commit -m "feat: add _run_dcf, _run_ev_ebitda, _run_ev_revenue, _run_price_to_book"
+ ```
+
+---
+
+## Task 4: `get_valuation()` service function
+
+**Files:**
+- Modify: `backend/app/services/data_service.py`
+- Modify: `backend/tests/test_api.py` (also update `clear_service_caches`)
+
+- [ ] **Step 1: Update `clear_service_caches` and write failing tests**
+
+ In `backend/tests/test_api.py`, add `data_service.VALUATION_CACHE.clear()` to the existing `clear_service_caches` function:
+
+ ```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.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.FINANCIALS_CACHE.clear()
+ data_service.VALUATION_CACHE.clear() # add this line
+ ```
+
+ Then append the new tests:
+
+ ```python
+ def test_get_valuation_happy_path(monkeypatch) -> None:
+ clear_service_caches()
+ cf_a = annual_frame({
+ "Operating Cash Flow": [100.0, 90.0, 80.0, 70.0],
+ "Capital Expenditure": [-10.0, -9.0, -8.0, -7.0],
+ })
+ bal_q = quarterly_frame({
+ "Total Debt": [50.0, 0.0, 0.0, 0.0],
+ "Cash And Cash Equivalents": [30.0, 0.0, 0.0, 0.0],
+ "Stockholders Equity": [400.0, 0.0, 0.0, 0.0],
+ })
+ inc_q = quarterly_frame({
+ "EBITDA": [30.0, 28.0, 32.0, 25.0],
+ "Total Revenue": [100.0, 90.0, 95.0, 85.0],
+ })
+ monkeypatch.setattr(data_service, "get_cash_flow",
+ lambda sym, quarterly=False: pd.DataFrame() if quarterly else cf_a)
+ monkeypatch.setattr(data_service, "get_income_statement",
+ lambda sym, quarterly=False: inc_q)
+ monkeypatch.setattr(data_service, "get_balance_sheet",
+ lambda sym, quarterly=False: bal_q)
+ monkeypatch.setattr(data_service, "get_company_info",
+ lambda sym: {"currentPrice": 150.0, "enterpriseToEbitda": 15.0,
+ "enterpriseToRevenue": 5.0, "priceToBook": 3.0})
+ monkeypatch.setattr(data_service, "get_shares_outstanding",
+ lambda sym: 1_000_000_000.0)
+
+ result = data_service.get_valuation("AAPL")
+ assert result["symbol"] == "AAPL"
+ assert result["current_price"] == 150.0
+ assert result["dcf"]["available"] is True
+ assert result["dcf"]["intrinsic_value_per_share"] is not None
+ assert result["dcf"]["wacc"] == 0.10
+ assert result["dcf"]["terminal_growth"] == 0.03
+ assert result["ev_ebitda"]["available"] is True
+ assert result["ev_ebitda"]["multiple_used"] == 15.0
+ assert result["ev_revenue"]["available"] is True
+ assert result["price_to_book"]["available"] is True
+
+
+ def test_get_valuation_negative_base_fcf(monkeypatch) -> None:
+ clear_service_caches()
+ # Most recent year (2024) FCF is negative
+ cf_a = annual_frame({
+ "Operating Cash Flow": [-50.0, 100.0, 90.0, 80.0],
+ "Capital Expenditure": [-5.0, -5.0, -5.0, -5.0],
+ })
+ monkeypatch.setattr(data_service, "get_cash_flow",
+ lambda sym, quarterly=False: pd.DataFrame() if quarterly else cf_a)
+ 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_company_info",
+ lambda sym: {"currentPrice": 100.0})
+ monkeypatch.setattr(data_service, "get_shares_outstanding",
+ lambda sym: 1_000_000_000.0)
+
+ result = data_service.get_valuation("AAPL")
+ assert result["dcf"]["available"] is True
+ assert result["dcf"]["error"] is not None
+ assert "negative" in result["dcf"]["error"].lower() or "zero" in result["dcf"]["error"].lower()
+
+
+ def test_get_valuation_no_cf_data(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.setattr(data_service, "get_cash_flow",
+ lambda sym, quarterly=False: pd.DataFrame())
+ 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_company_info",
+ lambda sym: {})
+ monkeypatch.setattr(data_service, "get_shares_outstanding",
+ lambda sym: None)
+
+ result = data_service.get_valuation("AAPL")
+ assert result["dcf"]["available"] is False
+ assert result["ev_ebitda"]["available"] is False
+ assert result["ev_revenue"]["available"] is False
+ assert result["price_to_book"]["available"] is False
+
+
+ def test_get_valuation_missing_multiples_data(monkeypatch) -> None:
+ clear_service_caches()
+ cf_a = annual_frame({
+ "Operating Cash Flow": [100.0, 90.0, 80.0, 70.0],
+ "Capital Expenditure": [-10.0, -9.0, -8.0, -7.0],
+ })
+ monkeypatch.setattr(data_service, "get_cash_flow",
+ lambda sym, quarterly=False: pd.DataFrame() if quarterly else cf_a)
+ 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())
+ # no enterpriseToEbitda / enterpriseToRevenue / priceToBook in info
+ monkeypatch.setattr(data_service, "get_company_info",
+ lambda sym: {"currentPrice": 150.0})
+ monkeypatch.setattr(data_service, "get_shares_outstanding",
+ lambda sym: 1_000_000_000.0)
+
+ result = data_service.get_valuation("AAPL")
+ assert result["dcf"]["available"] is True
+ assert result["ev_ebitda"]["available"] is False
+ assert result["ev_revenue"]["available"] is False
+ assert result["price_to_book"]["available"] is False
+ ```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py -k "get_valuation" -v
+ ```
+
+ Expected: `FAILED` — `AttributeError: module ... has no attribute 'get_valuation'`
+
+- [ ] **Step 3: Add `get_valuation()` to `backend/app/services/data_service.py`**
+
+ Add after `_run_price_to_book`:
+
+ ```python
+ @cached(VALUATION_CACHE)
+ def get_valuation(symbol: str) -> dict:
+ sym = normalize_symbol(symbol)
+
+ cf_annual = get_cash_flow(sym, quarterly=False)
+ inc_q = get_income_statement(sym, quarterly=True)
+ bal_q = get_balance_sheet(sym, quarterly=True)
+ info = get_company_info(sym)
+ shares = get_shares_outstanding(sym)
+
+ current_price = _safe_float(info.get("currentPrice"))
+
+ total_debt = _balance_value(bal_q, "Total Debt") or 0.0
+ cash = _balance_value(
+ bal_q, "Cash And Cash Equivalents",
+ "Cash Cash Equivalents And Short Term Investments"
+ ) or 0.0
+ preferred = _balance_value(bal_q, "Preferred Stock") or 0.0
+ minority = _balance_value(bal_q, "Minority Interest") or 0.0
+ equity = _balance_value(bal_q, "Stockholders Equity", "Common Stock Equity")
+
+ ebitda_ttm = _statement_ttm(inc_q, "EBITDA", "Normalized EBITDA")
+ revenue_ttm = _statement_ttm(inc_q, "Total Revenue")
+
+ book_value_per_share: float | None = None
+ if equity is not None and shares is not None and shares > 0:
+ book_value_per_share = equity / shares
+
+ ev_ebitda_multiple = _safe_float(info.get("enterpriseToEbitda"))
+ ev_revenue_multiple = _safe_float(info.get("enterpriseToRevenue"))
+ pb_multiple = _safe_float(info.get("priceToBook"))
+
+ fcf_series = _build_fcf_series(cf_annual)
+ dcf_raw: dict = {}
+ if fcf_series is not None and shares is not None and shares > 0:
+ dcf_raw = _run_dcf(
+ fcf_series=fcf_series,
+ shares_outstanding=shares,
+ total_debt=total_debt,
+ cash_and_equivalents=cash,
+ preferred_equity=preferred,
+ minority_interest=minority,
+ )
+
+ if not dcf_raw:
+ dcf_out: dict = {"available": False, "wacc": 0.10, "terminal_growth": 0.03}
+ elif "error" in dcf_raw:
+ dcf_out = {"available": True, "error": dcf_raw["error"], "wacc": 0.10, "terminal_growth": 0.03}
+ else:
+ dcf_out = {
+ "available": True,
+ "intrinsic_value_per_share": dcf_raw.get("intrinsic_value_per_share"),
+ "enterprise_value": dcf_raw.get("enterprise_value"),
+ "equity_value": dcf_raw.get("equity_value"),
+ "net_debt": dcf_raw.get("net_debt"),
+ "cash_and_equivalents": dcf_raw.get("cash_and_equivalents"),
+ "total_debt": dcf_raw.get("total_debt"),
+ "terminal_value_pv": dcf_raw.get("terminal_value_pv"),
+ "fcf_pv_sum": dcf_raw.get("fcf_pv_sum"),
+ "growth_rate_used": dcf_raw.get("growth_rate_used"),
+ "base_fcf": dcf_raw.get("base_fcf"),
+ "wacc": 0.10,
+ "terminal_growth": 0.03,
+ }
+
+ common = dict(
+ total_debt=total_debt,
+ total_cash=cash,
+ preferred_equity=preferred,
+ minority_interest=minority,
+ shares_outstanding=shares or 0.0,
+ )
+
+ ev_ebitda_out = _build_multiple_result(
+ _run_ev_ebitda(ebitda=ebitda_ttm, target_multiple=ev_ebitda_multiple, **common)
+ if ebitda_ttm and ev_ebitda_multiple and shares
+ else {}
+ )
+ ev_revenue_out = _build_multiple_result(
+ _run_ev_revenue(revenue=revenue_ttm, target_multiple=ev_revenue_multiple, **common)
+ if revenue_ttm and ev_revenue_multiple and shares
+ else {}
+ )
+ pb_out = _build_multiple_result(
+ _run_price_to_book(
+ book_value_per_share=book_value_per_share,
+ target_multiple=pb_multiple,
+ )
+ if book_value_per_share and pb_multiple
+ else {}
+ )
+
+ return {
+ "symbol": sym,
+ "current_price": current_price,
+ "shares_outstanding": shares,
+ "dcf": dcf_out,
+ "ev_ebitda": ev_ebitda_out,
+ "ev_revenue": ev_revenue_out,
+ "price_to_book": pb_out,
+ }
+ ```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py -k "get_valuation" -v
+ ```
+
+ Expected: all `PASSED`
+
+- [ ] **Step 5: Run full suite**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q
+ ```
+
+ Expected: all passing
+
+- [ ] **Step 6: Commit**
+
+ ```bash
+ git add backend/app/services/data_service.py backend/tests/test_api.py
+ git commit -m "feat: add get_valuation() service function"
+ ```
+
+---
+
+## Task 5: Backend route
+
+**Files:**
+- Modify: `backend/app/main.py`
+- Modify: `backend/app/schemas.py` (already has the schema — just update imports in main.py)
+- Test: `backend/tests/test_api.py`
+
+- [ ] **Step 1: Write the failing test**
+
+ Append to `backend/tests/test_api.py`:
+
+ ```python
+ def test_valuation_route_returns_structure(monkeypatch) -> None:
+ monkeypatch.setattr(
+ main.data_service,
+ "get_valuation",
+ lambda symbol: {
+ "symbol": "AAPL",
+ "current_price": 150.0,
+ "shares_outstanding": 15_000_000_000.0,
+ "dcf": {
+ "available": True,
+ "intrinsic_value_per_share": 182.0,
+ "enterprise_value": 2_800_000_000_000.0,
+ "equity_value": 2_750_000_000_000.0,
+ "net_debt": 50_000_000_000.0,
+ "cash_and_equivalents": 100_000_000_000.0,
+ "total_debt": 150_000_000_000.0,
+ "terminal_value_pv": 2_000_000_000_000.0,
+ "fcf_pv_sum": 800_000_000_000.0,
+ "growth_rate_used": 0.082,
+ "base_fcf": 110_000_000_000.0,
+ "wacc": 0.10,
+ "terminal_growth": 0.03,
+ "error": None,
+ },
+ "ev_ebitda": {
+ "available": True,
+ "implied_price_per_share": 178.0,
+ "implied_ev": 2_700_000_000_000.0,
+ "equity_value": 2_650_000_000_000.0,
+ "net_debt": 50_000_000_000.0,
+ "multiple_used": 20.0,
+ },
+ "ev_revenue": {"available": False},
+ "price_to_book": {"available": False},
+ },
+ )
+ result = main.ticker_valuation("AAPL")
+ assert result["symbol"] == "AAPL"
+ assert result["dcf"]["intrinsic_value_per_share"] == 182.0
+ assert result["ev_ebitda"]["multiple_used"] == 20.0
+ assert result["ev_revenue"]["available"] is False
+ ```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_valuation_route_returns_structure -v
+ ```
+
+ Expected: `FAILED` — `AttributeError: module ... has no attribute 'ticker_valuation'`
+
+- [ ] **Step 3: Add route to `backend/app/main.py`**
+
+ Update the import line at the top of `main.py` to include the new schemas:
+
+ ```python
+ from app.schemas import FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse
+ ```
+
+ Add the route after `ticker_financials`:
+
+ ```python
+ @app.get("/api/tickers/{symbol}/valuation", response_model=ValuationResponse)
+ def ticker_valuation(symbol: str) -> dict:
+ return data_service.get_valuation(symbol)
+ ```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_valuation_route_returns_structure -v
+ ```
+
+ Expected: `PASSED`
+
+- [ ] **Step 5: Run full suite**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q
+ ```
+
+ Expected: all passing
+
+- [ ] **Step 6: Commit**
+
+ ```bash
+ git add backend/app/main.py
+ git commit -m "feat: add GET /api/tickers/{symbol}/valuation route"
+ ```
+
+---
+
+## Task 6: Frontend types and API client
+
+**Files:**
+- Modify: `frontend/types/api.ts`
+- Modify: `frontend/lib/api.ts`
+
+No test runner for frontend — verification is `npm run build` (run at end of Task 9 for full check). These changes are additive and won't break the existing build.
+
+- [ ] **Step 1: Add types to `frontend/types/api.ts`**
+
+ Append after the `FinancialsResponse` type:
+
+ ```typescript
+ export type DcfResult = {
+ available: boolean;
+ error?: string | null;
+ intrinsic_value_per_share?: number | null;
+ enterprise_value?: number | null;
+ equity_value?: number | null;
+ net_debt?: number | null;
+ cash_and_equivalents?: number | null;
+ total_debt?: number | null;
+ terminal_value_pv?: number | null;
+ fcf_pv_sum?: number | null;
+ growth_rate_used?: number | null;
+ base_fcf?: number | null;
+ wacc: number;
+ terminal_growth: number;
+ };
+
+ export type MultipleResult = {
+ available: boolean;
+ implied_price_per_share?: number | null;
+ implied_ev?: number | null;
+ equity_value?: number | null;
+ net_debt?: number | null;
+ multiple_used?: number | null;
+ };
+
+ export type ValuationResponse = {
+ symbol: string;
+ current_price?: number | null;
+ shares_outstanding?: number | null;
+ dcf: DcfResult;
+ ev_ebitda: MultipleResult;
+ ev_revenue: MultipleResult;
+ price_to_book: MultipleResult;
+ };
+ ```
+
+- [ ] **Step 2: Add `valuation()` method to `frontend/lib/api.ts`**
+
+ Update the import at the top of `api.ts`:
+
+ ```typescript
+ import type { FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api";
+ ```
+
+ Add `valuation` to the `api` object (after `financials`):
+
+ ```typescript
+ valuation(symbol: string) {
+ return request<ValuationResponse>(
+ `/api/tickers/${encodeURIComponent(symbol)}/valuation`
+ );
+ }
+ ```
+
+- [ ] **Step 3: Run lint to check for type errors**
+
+ ```bash
+ cd /home/tyler/Work/prism-v2/frontend && npm run lint
+ ```
+
+ Expected: no errors
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add frontend/types/api.ts frontend/lib/api.ts
+ git commit -m "feat: add ValuationResponse types and api.valuation() method"
+ ```
+
+---
+
+## Task 7: ValuationCard component and CSS
+
+**Files:**
+- Create: `frontend/components/prism/ValuationCard.tsx`
+- Modify: `frontend/app/prism-shell.css`
+
+- [ ] **Step 1: Create `frontend/components/prism/ValuationCard.tsx`**
+
+ ```typescript
+ "use client";
+ import type { ValuationResponse } from "@/types/api";
+ import { deltaClass, fmtCurrency, fmtLarge, fmtPct } from "@/lib/format";
+
+ type Props = { data: ValuationResponse };
+
+ function pctVsCurrent(implied?: number | null, current?: number | null): number | null {
+ if (implied == null || current == null || current === 0) return null;
+ return (implied - current) / current;
+ }
+
+ function SummaryChip({
+ label,
+ price,
+ current,
+ accent = false,
+ }: {
+ label: string;
+ price?: number | null;
+ current?: number | null;
+ accent?: boolean;
+ }) {
+ const pct = pctVsCurrent(price, current);
+ return (
+ <div className={`psm-val-chip${accent ? " accent" : ""}`}>
+ <span className="psm-val-chip-label">{label}</span>
+ <span className="psm-val-chip-price">{price != null ? fmtCurrency(price) : "—"}</span>
+ {pct != null && (
+ <span className={`psm-val-chip-pct ${deltaClass(pct)}`}>{fmtPct(pct, 1, true)}</span>
+ )}
+ </div>
+ );
+ }
+
+ function MultipleRow({
+ label,
+ multiple,
+ price,
+ current,
+ }: {
+ label: string;
+ multiple?: number | null;
+ price?: number | null;
+ current?: number | null;
+ }) {
+ const pct = pctVsCurrent(price, current);
+ return (
+ <div className="psm-val-mult-row">
+ <span className="psm-val-mult-label">{label}</span>
+ <span className="psm-val-mult-x">{multiple != null ? `${multiple.toFixed(1)}×` : "—"}</span>
+ <span className="psm-val-mult-price">{price != null ? fmtCurrency(price) : "—"}</span>
+ <span className={`psm-val-mult-pct ${pct != null ? deltaClass(pct) : "neutral"}`}>
+ {pct != null ? fmtPct(pct, 1, true) : "—"}
+ </span>
+ </div>
+ );
+ }
+
+ export function ValuationCard({ data }: Props) {
+ const { dcf, ev_ebitda, ev_revenue, price_to_book, current_price } = data;
+ const dcfPrice = dcf.available && !dcf.error ? dcf.intrinsic_value_per_share : null;
+ const hasMultiples = ev_ebitda.available || ev_revenue.available || price_to_book.available;
+
+ return (
+ <section className="psm-card psm-val-card">
+ {/* Summary strip */}
+ <div className="psm-val-strip">
+ <div className="psm-val-chip accent">
+ <span className="psm-val-chip-label">Market Price</span>
+ <span className="psm-val-chip-price">
+ {current_price != null ? fmtCurrency(current_price) : "—"}
+ </span>
+ </div>
+ <SummaryChip label="DCF" price={dcfPrice} current={current_price} />
+ <SummaryChip
+ label="EV / EBITDA"
+ price={ev_ebitda.available ? ev_ebitda.implied_price_per_share : null}
+ current={current_price}
+ />
+ <SummaryChip
+ label="EV / Revenue"
+ price={ev_revenue.available ? ev_revenue.implied_price_per_share : null}
+ current={current_price}
+ />
+ <SummaryChip
+ label="P / Book"
+ price={price_to_book.available ? price_to_book.implied_price_per_share : null}
+ current={current_price}
+ />
+ </div>
+
+ {/* DCF detail */}
+ <div className="psm-val-section">
+ <div className="psm-val-section-head">
+ <span className="psm-eyebrow">Discounted Cash Flow</span>
+ <span className="psm-val-wacc-note">
+ WACC {(dcf.wacc * 100).toFixed(1)}% · Terminal {(dcf.terminal_growth * 100).toFixed(1)}%
+ </span>
+ </div>
+
+ {!dcf.available && (
+ <p className="psm-muted-copy">Insufficient free cash flow history for DCF.</p>
+ )}
+ {dcf.available && dcf.error && (
+ <p className="psm-val-dcf-error">{dcf.error}</p>
+ )}
+ {dcf.available && !dcf.error && (
+ <div className="psm-val-dcf-body">
+ <div className="psm-val-kv-list">
+ <div className="psm-val-kv-row">
+ <span className="psm-val-kv-label">Base FCF (TTM)</span>
+ <span className="psm-val-kv-val">{dcf.base_fcf != null ? fmtLarge(dcf.base_fcf) : "—"}</span>
+ </div>
+ <div className="psm-val-kv-row">
+ <span className="psm-val-kv-label">Historical growth</span>
+ <span className="psm-val-kv-val">
+ {dcf.growth_rate_used != null ? fmtPct(dcf.growth_rate_used) : "—"}
+ </span>
+ </div>
+ <div className="psm-val-kv-row is-divider">
+ <span className="psm-val-kv-label">Enterprise Value</span>
+ <span className="psm-val-kv-val">
+ {dcf.enterprise_value != null ? fmtLarge(dcf.enterprise_value) : "—"}
+ </span>
+ </div>
+ <div className="psm-val-kv-row">
+ <span className="psm-val-kv-label">Net Debt</span>
+ <span className="psm-val-kv-val">
+ {dcf.net_debt != null ? fmtLarge(dcf.net_debt) : "—"}
+ </span>
+ </div>
+ <div className="psm-val-kv-row is-total">
+ <span className="psm-val-kv-label">Equity Value</span>
+ <span className="psm-val-kv-val">
+ {dcf.equity_value != null ? fmtLarge(dcf.equity_value) : "—"}
+ </span>
+ </div>
+ </div>
+ <div className="psm-val-intrinsic">
+ <span className="psm-val-intrinsic-label">Intrinsic Value</span>
+ <span className="psm-val-intrinsic-price">
+ {dcf.intrinsic_value_per_share != null ? fmtCurrency(dcf.intrinsic_value_per_share) : "—"}
+ </span>
+ {data.shares_outstanding != null && (
+ <span className="psm-val-intrinsic-shares">
+ {fmtLarge(data.shares_outstanding)} shares
+ </span>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* Multiples */}
+ {hasMultiples && (
+ <div className="psm-val-section">
+ <div className="psm-val-section-head">
+ <span className="psm-eyebrow">Multiples — at current market multiple</span>
+ </div>
+ <div className="psm-val-mult-list">
+ {ev_ebitda.available && (
+ <MultipleRow
+ label="EV / EBITDA"
+ multiple={ev_ebitda.multiple_used}
+ price={ev_ebitda.implied_price_per_share}
+ current={current_price}
+ />
+ )}
+ {ev_revenue.available && (
+ <MultipleRow
+ label="EV / Revenue"
+ multiple={ev_revenue.multiple_used}
+ price={ev_revenue.implied_price_per_share}
+ current={current_price}
+ />
+ )}
+ {price_to_book.available && (
+ <MultipleRow
+ label="P / Book"
+ multiple={price_to_book.multiple_used}
+ price={price_to_book.implied_price_per_share}
+ current={current_price}
+ />
+ )}
+ </div>
+ </div>
+ )}
+ </section>
+ );
+ }
+ ```
+
+- [ ] **Step 2: Append valuation CSS to `frontend/app/prism-shell.css`**
+
+ Append to the end of `frontend/app/prism-shell.css`:
+
+ ```css
+ /* ── Valuation tab ──────────────────────────────── */
+
+ .psm-val-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-5);
+ }
+
+ .psm-val-strip {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: var(--sp-3);
+ }
+
+ .psm-val-chip {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-1);
+ padding: var(--sp-3) var(--sp-4);
+ background: var(--ink-2);
+ border-radius: var(--r-2);
+ border: 1px solid var(--line-1);
+ }
+
+ .psm-val-chip.accent {
+ border-color: var(--brass);
+ }
+
+ .psm-val-chip-label {
+ font-family: var(--font-sans);
+ font-size: var(--fs-12);
+ font-weight: 600;
+ letter-spacing: var(--tr-wider);
+ text-transform: uppercase;
+ color: var(--fg-3);
+ }
+
+ .psm-val-chip-price {
+ font-family: var(--font-mono);
+ font-size: var(--fs-18);
+ color: var(--fg-1);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .psm-val-chip-pct {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .psm-val-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-3);
+ padding-top: var(--sp-4);
+ border-top: 1px solid var(--line-1);
+ }
+
+ .psm-val-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .psm-val-wacc-note {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ color: var(--fg-4);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .psm-val-dcf-error {
+ font-family: var(--font-sans);
+ font-size: var(--fs-13);
+ color: var(--warning);
+ padding: var(--sp-3) var(--sp-4);
+ background: var(--ink-2);
+ border-radius: var(--r-2);
+ border-left: 2px solid var(--warning);
+ }
+
+ .psm-val-dcf-body {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--sp-5);
+ }
+
+ .psm-val-kv-list {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .psm-val-kv-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--sp-2) 0;
+ border-bottom: 1px solid var(--ink-2);
+ }
+
+ .psm-val-kv-row.is-divider {
+ margin-top: var(--sp-2);
+ border-top: 1px solid var(--line-1);
+ }
+
+ .psm-val-kv-row.is-total .psm-val-kv-val {
+ color: var(--fg-1);
+ font-weight: 600;
+ }
+
+ .psm-val-kv-label {
+ font-family: var(--font-sans);
+ font-size: var(--fs-13);
+ color: var(--fg-3);
+ }
+
+ .psm-val-kv-val {
+ font-family: var(--font-mono);
+ font-size: var(--fs-13);
+ color: var(--fg-2);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .psm-val-intrinsic {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ padding: var(--sp-4);
+ background: var(--ink-2);
+ border-radius: var(--r-2);
+ border: 1px solid var(--line-1);
+ align-self: start;
+ }
+
+ .psm-val-intrinsic-label {
+ font-family: var(--font-sans);
+ font-size: var(--fs-12);
+ font-weight: 600;
+ letter-spacing: var(--tr-wider);
+ text-transform: uppercase;
+ color: var(--fg-3);
+ }
+
+ .psm-val-intrinsic-price {
+ font-family: var(--font-mono);
+ font-size: var(--fs-38);
+ color: var(--brass);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+ }
+
+ .psm-val-intrinsic-shares {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ color: var(--fg-4);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .psm-val-mult-list {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .psm-val-mult-row {
+ display: grid;
+ grid-template-columns: 1fr 56px 80px 64px;
+ gap: var(--sp-4);
+ align-items: center;
+ padding: var(--sp-3) 0;
+ border-bottom: 1px solid var(--ink-2);
+ }
+
+ .psm-val-mult-label {
+ font-family: var(--font-sans);
+ font-size: var(--fs-13);
+ color: var(--fg-2);
+ }
+
+ .psm-val-mult-x {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ color: var(--fg-3);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ }
+
+ .psm-val-mult-price {
+ font-family: var(--font-mono);
+ font-size: var(--fs-13);
+ color: var(--fg-1);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ }
+
+ .psm-val-mult-pct {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ }
+ ```
+
+- [ ] **Step 3: Run lint**
+
+ ```bash
+ cd /home/tyler/Work/prism-v2/frontend && npm run lint
+ ```
+
+ Expected: no errors
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add frontend/components/prism/ValuationCard.tsx frontend/app/prism-shell.css
+ git commit -m "feat: add ValuationCard component and psm-val-* CSS"
+ ```
+
+---
+
+## Task 8: ValuationPage component
+
+**Files:**
+- Create: `frontend/components/prism/ValuationPage.tsx`
+
+- [ ] **Step 1: Create `frontend/components/prism/ValuationPage.tsx`**
+
+ ```typescript
+ "use client";
+ import { useEffect, useState } from "react";
+ import { api } from "@/lib/api";
+ import { buildKpis } from "@/lib/overview";
+ import { ValuationCard } from "@/components/prism/ValuationCard";
+ import { KPIStrip } from "@/components/prism/KPIStrip";
+ import { TickerHeader } from "@/components/prism/TickerHeader";
+ import type { TickerOverview, ValuationResponse } from "@/types/api";
+
+ type ValState = "loading" | "ready" | "error";
+
+ type Props = {
+ ticker: string;
+ overview: TickerOverview;
+ isSaved: boolean;
+ onToggleWatchlist: () => void;
+ };
+
+ export function ValuationPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) {
+ const [data, setData] = useState<ValuationResponse | null>(null);
+ const [valState, setValState] = useState<ValState>("loading");
+ const kpis = buildKpis(overview);
+
+ useEffect(() => {
+ let cancelled = false;
+ setValState("loading");
+ setData(null);
+
+ api
+ .valuation(ticker)
+ .then((res) => {
+ if (!cancelled) {
+ setData(res);
+ setValState("ready");
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setValState("error");
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ticker]);
+
+ return (
+ <>
+ <TickerHeader overview={overview} isSaved={isSaved} onToggleWatchlist={onToggleWatchlist} />
+ <KPIStrip items={kpis} />
+ {valState === "loading" && (
+ <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} />
+ )}
+ {valState === "error" && (
+ <section className="psm-card">
+ <p className="psm-muted-copy">Valuation data unavailable for {ticker}.</p>
+ </section>
+ )}
+ {valState === "ready" && data && <ValuationCard data={data} />}
+ </>
+ );
+ }
+ ```
+
+- [ ] **Step 2: Run lint**
+
+ ```bash
+ cd /home/tyler/Work/prism-v2/frontend && npm run lint
+ ```
+
+ Expected: no errors
+
+- [ ] **Step 3: Commit**
+
+ ```bash
+ git add frontend/components/prism/ValuationPage.tsx
+ git commit -m "feat: add ValuationPage data-fetch wrapper"
+ ```
+
+---
+
+## Task 9: Wire routing, enable nav, final build check
+
+**Files:**
+- Modify: `frontend/lib/overview.ts`
+- Modify: `frontend/app/page.tsx`
+
+- [ ] **Step 1: Enable valuation in the nav — `frontend/lib/overview.ts`**
+
+ Change line:
+ ```typescript
+ { key: "valuation", label: "Valuation", icon: "dollar", disabled: true },
+ ```
+ To:
+ ```typescript
+ { key: "valuation", label: "Valuation", icon: "dollar" },
+ ```
+
+- [ ] **Step 2: Add import and routing branch — `frontend/app/page.tsx`**
+
+ Add `ValuationPage` to the imports at the top of `page.tsx` (after `FinancialsPage`):
+
+ ```typescript
+ import { ValuationPage } from "@/components/prism/ValuationPage";
+ ```
+
+ Find the existing `tab === "financials"` block (around line 292):
+
+ ```tsx
+ tab === "financials" ? (
+ <FinancialsPage
+ ticker={selectedTicker}
+ overview={overview}
+ isSaved={isSaved}
+ onToggleWatchlist={addOrRemoveCurrentTicker}
+ />
+ ) : (
+ ```
+
+ Replace it with:
+
+ ```tsx
+ tab === "valuation" ? (
+ <ValuationPage
+ ticker={selectedTicker}
+ overview={overview}
+ isSaved={isSaved}
+ onToggleWatchlist={addOrRemoveCurrentTicker}
+ />
+ ) : tab === "financials" ? (
+ <FinancialsPage
+ ticker={selectedTicker}
+ overview={overview}
+ isSaved={isSaved}
+ onToggleWatchlist={addOrRemoveCurrentTicker}
+ />
+ ) : (
+ ```
+
+- [ ] **Step 3: Run lint**
+
+ ```bash
+ cd /home/tyler/Work/prism-v2/frontend && npm run lint
+ ```
+
+ Expected: no errors
+
+- [ ] **Step 4: Run production build (full TypeScript + Next.js validation)**
+
+ ```bash
+ cd /home/tyler/Work/prism-v2/frontend && NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8001 npm run build
+ ```
+
+ Expected: `✓ Compiled successfully` with no TypeScript errors
+
+- [ ] **Step 5: Run full backend test suite one final time**
+
+ ```bash
+ /home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q
+ ```
+
+ Expected: all passing
+
+- [ ] **Step 6: Commit**
+
+ ```bash
+ git add frontend/lib/overview.ts frontend/app/page.tsx
+ git commit -m "feat: wire valuation tab routing and enable nav item"
+ ```