# Valuation Tab — Design Spec **Date:** 2026-05-18 **Status:** Approved **Scope:** v2 read-only valuation models (DCF + multiples). No sliders, no subtabs. --- ## Context and Decisions ### Scope: Models only (no subtabs) v1 had subtabs under Valuation: Key Ratios, Historical Ratios, Models, Comps, Forward Estimates, Analyst Targets, Earnings History. In v2 these are decomposed into separate sidebar tabs (future work). This implementation covers **Models only** — the core DCF + multiples valuation content. v1 had WACC / terminal growth / FCF growth / horizon sliders. These are deferred — the backend schema is designed so they can be added as query params later without breaking changes. ### Layout: Summary strip + DCF detail (Option C) 4-model summary chips at the top for instant scanning, DCF detail panel below, multiples as compact rows. Chose over stacked-vertical (matches Financials but buries headline numbers) and 2-col + chart (more complex, less scannable). ### Multiples target: trailing market multiple Since v2 has no FMP integration, multiples use the company's own trailing multiple from yfinance `.info` (`ev_to_ebitda`, `ev_to_sales`, `price_to_book`) as the target. This produces "market-implied price at current multiple" — a legitimate cross-check against DCF. UI labels make this explicit. --- ## Backend ### New cache ```python VALUATION_CACHE = TTLCache(maxsize=128, ttl=3600) ``` Added to `backend/app/services/data_service.py` alongside the existing statement caches. Never share with other cached functions that have the same argument signature. ### Ported valuation logic Port `run_dcf`, `run_ev_ebitda`, `run_ev_revenue`, `run_price_to_book`, `compute_historical_growth_rate` from `../prism/services/valuation_service.py` directly into `data_service.py`. No new service file — keeps v2 flat. DCF defaults: `wacc=0.10`, `terminal_growth=0.03`, `projection_years=5`. Growth rate derived from historical FCF median (capped ±50%). ### `get_valuation(symbol)` data assembly 1. Annual cash flow → FCF series: `Operating Cash Flow + Capital Expenditure` (CapEx is negative in yfinance — addition, not subtraction) 2. Quarterly income → EBITDA TTM via `_statement_ttm(frame_q, "EBITDA", "Normalized EBITDA")` 3. Quarterly income → Revenue TTM via `_statement_ttm(frame_q, "Total Revenue")` 4. Quarterly balance → MRQ values via `_balance_value`: - `Total Debt` - `Cash And Cash Equivalents` or `Cash Cash Equivalents And Short Term Investments` - `Preferred Stock` - `Minority Interest` - `Stockholders Equity` (for P/B: book value per share = equity / shares) 5. Shares: `get_shares_outstanding(sym)` 6. Current price: `get_company_info(sym).get("currentPrice")` 7. Multiples targets: `info.get("enterpriseToEbitda")`, `info.get("enterpriseToRevenue")`, `info.get("priceToBook")` ### Route ```python @app.get("/api/tickers/{symbol}/valuation", response_model=ValuationResponse) def ticker_valuation(symbol: str) -> dict: return data_service.get_valuation(symbol) ``` No query params in v1 scope. WACC/growth overrides can be added as optional `Query` params later. ### Schema (`backend/app/schemas.py`) ```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 ``` --- ## Frontend ### New files **`frontend/components/prism/ValuationPage.tsx`** Mirrors `FinancialsPage.tsx` exactly: same props (`ticker`, `overview`, `isSaved`, `onToggleWatchlist`), same `useEffect` + cancellation flag pattern, same loading/error/ready states. Renders `TickerHeader` + `KPIStrip` + `ValuationCard`. **`frontend/components/prism/ValuationCard.tsx`** Three sections: 1. **Summary strip** — 5 chips in a row (current price + 4 model outputs). Each chip shows label, implied price (mono font), and ±% vs current (green/red). DCF error message shown inline if `dcf.error` is set. Unavailable chips show "—". 2. **DCF detail panel** — two-column layout: - Left: assumption metadata — growth rate used, WACC, terminal growth, base FCF (TTM), EV bridge table (EV → −net debt → −other claims → = equity value) - Right: per-share summary — intrinsic value, market price, gap 3. **Multiples rows** — compact table: EV/EBITDA, EV/Revenue, P/Book. Each row: multiple used, implied price, ±% vs current. Rows with `available: false` are hidden. CSS prefix: `.psm-val-*` in `frontend/app/prism-shell.css`. ### Wiring edits (existing files) | File | Change | |---|---| | `frontend/lib/overview.ts` | Remove `disabled: true` from valuation nav item | | `frontend/lib/api.ts` | Add `valuation(symbol)` → `request(...)` | | `frontend/types/api.ts` | Add `DcfResult`, `MultipleResult`, `ValuationResponse` types | | `frontend/app/page.tsx` | Add `tab === "valuation"` branch rendering `` | --- ## Error Handling | Condition | Backend | Frontend | |---|---|---| | DCF math error (negative FCF, WACC ≤ 0) | `dcf.error = ""`, `available: true` | Summary chip shows "—", DCF panel shows error message inline | | DCF insufficient data (<2 FCF points) | `dcf = DcfResult(available=False)` | Summary chip shows "—", DCF panel shows "Insufficient free cash flow history" | | Multiple unavailable (zero EBITDA, missing data) | `MultipleResult(available=False)` | Chip shows "—", multiples row hidden | | Full endpoint failure | 500 / exception propagates as 500 | `ValuationPage` error state: "Valuation data unavailable for {ticker}" | --- ## Testing **`backend/tests/test_api.py`** - One test: GET `/api/tickers/AAPL/valuation` with monkeypatched `get_valuation` returning a valid dict. Assert 200 + response shape. **`backend/tests/test_data_service.py`** - Happy path: valid FCF series + balance data → intrinsic value populated - Negative FCF: `dcf.error` is set, `available` is True - Insufficient history (<2 points): `dcf.available` is False - Zero shares: `dcf.available` is False - Missing multiples data: `MultipleResult.available` is False - All monkeypatched — no live yfinance calls **Cache cleanup:** `clear_service_caches()` updated to call `VALUATION_CACHE.clear()`. **Frontend:** `npm run build` + `npm run lint` (no frontend test runner per project convention).