diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-05-18-valuation-tab.md | 1673 |
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" + ``` |
