summaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-05-18-valuation-tab-design.md
blob: 3400029071e962fb0e09049a1d29f1f082262e05 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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).