diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/specs/2026-05-18-valuation-tab-design.md | 164 |
1 files changed, 164 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-05-18-valuation-tab-design.md b/docs/superpowers/specs/2026-05-18-valuation-tab-design.md new file mode 100644 index 0000000..3400029 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-valuation-tab-design.md @@ -0,0 +1,164 @@ +# 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<ValuationResponse>(...)` | +| `frontend/types/api.ts` | Add `DcfResult`, `MultipleResult`, `ValuationResponse` types | +| `frontend/app/page.tsx` | Add `tab === "valuation"` branch rendering `<ValuationPage>` | + +--- + +## Error Handling + +| Condition | Backend | Frontend | +|---|---|---| +| DCF math error (negative FCF, WACC ≤ 0) | `dcf.error = "<message>"`, `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). |
