# 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( `/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 (
{label} {price != null ? fmtCurrency(price) : "—"} {pct != null && ( {fmtPct(pct, 1, true)} )}
); } function MultipleRow({ label, multiple, price, current, }: { label: string; multiple?: number | null; price?: number | null; current?: number | null; }) { const pct = pctVsCurrent(price, current); return (
{label} {multiple != null ? `${multiple.toFixed(1)}×` : "—"} {price != null ? fmtCurrency(price) : "—"} {pct != null ? fmtPct(pct, 1, true) : "—"}
); } 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 (
{/* Summary strip */}
Market Price {current_price != null ? fmtCurrency(current_price) : "—"}
{/* DCF detail */}
Discounted Cash Flow WACC {(dcf.wacc * 100).toFixed(1)}% · Terminal {(dcf.terminal_growth * 100).toFixed(1)}%
{!dcf.available && (

Insufficient free cash flow history for DCF.

)} {dcf.available && dcf.error && (

{dcf.error}

)} {dcf.available && !dcf.error && (
Base FCF (TTM) {dcf.base_fcf != null ? fmtLarge(dcf.base_fcf) : "—"}
Historical growth {dcf.growth_rate_used != null ? fmtPct(dcf.growth_rate_used) : "—"}
Enterprise Value {dcf.enterprise_value != null ? fmtLarge(dcf.enterprise_value) : "—"}
Net Debt {dcf.net_debt != null ? fmtLarge(dcf.net_debt) : "—"}
Equity Value {dcf.equity_value != null ? fmtLarge(dcf.equity_value) : "—"}
Intrinsic Value {dcf.intrinsic_value_per_share != null ? fmtCurrency(dcf.intrinsic_value_per_share) : "—"} {data.shares_outstanding != null && ( {fmtLarge(data.shares_outstanding)} shares )}
)}
{/* Multiples */} {hasMultiples && (
Multiples — at current market multiple
{ev_ebitda.available && ( )} {ev_revenue.available && ( )} {price_to_book.available && ( )}
)}
); } ``` - [ ] **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(null); const [valState, setValState] = useState("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 ( <> {valState === "loading" && (
)} {valState === "error" && (

Valuation data unavailable for {ticker}.

)} {valState === "ready" && 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" ? ( ) : ( ``` Replace it with: ```tsx tab === "valuation" ? ( ) : tab === "financials" ? ( ) : ( ``` - [ ] **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" ```