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).
|