From ab699a136169e8661b4f22fbce023ca7f033ce18 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Mon, 29 Jun 2026 01:41:24 -0700 Subject: feat: update ValuationPage and valuation cards --- frontend/components/prism/ValuationCard.tsx | 305 ++++++++++++++++++---------- 1 file changed, 194 insertions(+), 111 deletions(-) (limited to 'frontend/components/prism/ValuationCard.tsx') diff --git a/frontend/components/prism/ValuationCard.tsx b/frontend/components/prism/ValuationCard.tsx index a1b80c3..4c00a0c 100644 --- a/frontend/components/prism/ValuationCard.tsx +++ b/frontend/components/prism/ValuationCard.tsx @@ -1,10 +1,20 @@ "use client"; import { useMemo, useState } from "react"; -import type { ValuationResponse } from "@/types/api"; -import { deltaClass, fmtCurrency, fmtLarge, fmtPct } from "@/lib/format"; -import { computeDcf } from "@/lib/dcf"; +import type { ValuationResponse, WaccResponse } from "@/types/api"; +import { deltaClass, fmtCurrency, fmtPct } from "@/lib/format"; +import { + computeAdvancedDcf, + computeDcf, + computeSensitivityGrid, + type AdvancedDcfInputs, +} from "@/lib/dcf"; +import { AdvancedDcfPanel } from "@/components/prism/AdvancedDcfPanel"; +import { DcfResults } from "@/components/prism/DcfResults"; +import { SensitivityTable } from "@/components/prism/SensitivityTable"; -type Props = { data: ValuationResponse }; +type Props = { data: ValuationResponse; waccData?: WaccResponse | null; seedRevenue?: number | null }; + +type DcfMode = "simple" | "advanced"; function pctVsCurrent(implied?: number | null, current?: number | null): number | null { if (implied == null || current == null || current === 0) return null; @@ -15,12 +25,14 @@ function SummaryChip({ label, price, current, + currency, accent = false, customised = false, }: { label: string; price?: number | null; current?: number | null; + currency: string; accent?: boolean; customised?: boolean; }) { @@ -31,7 +43,7 @@ function SummaryChip({ {label} {customised && } - {price != null ? fmtCurrency(price) : "—"} + {price != null && price >= 0 ? fmtCurrency(price, 2, currency) : "—"} {pct != null && ( {fmtPct(pct, 1, true)} )} @@ -44,18 +56,20 @@ function MultipleRow({ multiple, price, current, + currency, }: { label: string; multiple?: number | null; price?: number | null; current?: number | null; + currency: string; }) { const pct = pctVsCurrent(price, current); return (
{label} {multiple != null ? `${multiple.toFixed(1)}×` : "—"} - {price != null ? fmtCurrency(price) : "—"} + {price != null ? fmtCurrency(price, 2, currency) : "—"} {pct != null ? fmtPct(pct, 1, true) : "—"} @@ -103,8 +117,24 @@ function DCFSlider({ ); } -export function ValuationCard({ data }: Props) { +function WaccNote({ waccData }: { waccData?: WaccResponse | null }) { + if (!waccData?.available || waccData.wacc == null) return null; + return ( +
+ Market-implied WACC + {fmtPct(waccData.wacc)} + + β {waccData.beta.toFixed(2)} · Rf {fmtPct(waccData.risk_free_rate)} · ERP{" "} + {fmtPct(waccData.equity_risk_premium)} · rd {fmtPct(waccData.cost_of_debt)} · t{" "} + {fmtPct(waccData.tax_rate)} + +
+ ); +} + +export function ValuationCard({ data, waccData, seedRevenue }: Props) { const { dcf, ev_ebitda, ev_revenue, price_to_book, current_price } = data; + const currency = data.currency || "USD"; // Slider defaults captured once from the initial API response const [defaults] = useState(() => ({ @@ -118,14 +148,26 @@ export function ValuationCard({ data }: Props) { const [terminalGrowth, setTerminalGrowth] = useState(defaults.terminalGrowth); const [projectionYears, setProjectionYears] = useState(defaults.projectionYears); const [growthRate, setGrowthRate] = useState(defaults.growthRate); + const [mode, setMode] = useState("simple"); + const [advancedInputs, setAdvancedInputs] = useState(() => ({ + baseRevenue: seedRevenue ?? 1_000, + revenueGrowth: [0.05, 0.05, 0.05, 0.05, 0.05], + ebitdaMargin: [0.25, 0.25, 0.25, 0.25, 0.25], + dnaPctRevenue: [0.05, 0.05, 0.05, 0.05, 0.05], + capexPctRevenue: [0.07, 0.07, 0.07, 0.07, 0.07], + nwcPctDeltaRev: [0.02, 0.02, 0.02, 0.02, 0.02], + taxRate: [0.21, 0.21, 0.21, 0.21, 0.21], + })); const isCustomised = + mode !== "simple" || wacc !== defaults.wacc || terminalGrowth !== defaults.terminalGrowth || projectionYears !== defaults.projectionYears || growthRate !== defaults.growthRate; function handleReset() { + setMode("simple"); setWacc(defaults.wacc); setTerminalGrowth(defaults.terminalGrowth); setProjectionYears(defaults.projectionYears); @@ -140,16 +182,58 @@ export function ValuationCard({ data }: Props) { : (dcf.net_debt ?? 0); const computed = useMemo(() => { - if (!dcf.available || dcf.error || !dcf.base_fcf || !data.shares_outstanding) return null; + if (!dcf.available || dcf.error || !data.shares_outstanding) return null; + if (mode === "advanced") { + return computeAdvancedDcf(advancedInputs, { + wacc, + terminalGrowth, + projectionYears: advancedInputs.revenueGrowth.length, + sharesOutstanding: data.shares_outstanding, + equityBridge, + }); + } + if (!dcf.base_fcf) return null; return computeDcf( { baseFcf: dcf.base_fcf, equityBridge, sharesOutstanding: data.shares_outstanding }, { wacc, terminalGrowth, projectionYears, growthRate } ); - }, [wacc, terminalGrowth, projectionYears, growthRate, equityBridge, dcf, data.shares_outstanding]); + }, [mode, advancedInputs, wacc, terminalGrowth, projectionYears, growthRate, equityBridge, dcf, data.shares_outstanding]); const livePrice = computed && !("error" in computed) ? computed.intrinsicValuePerShare : null; + const sensitivityMatrix = useMemo(() => { + if (!dcf.available || dcf.error || !data.shares_outstanding) return null; + if (mode === "simple" && dcf.sensitivity) return dcf.sensitivity; + if (mode === "simple" && !dcf.base_fcf) return null; + + const shares = data.shares_outstanding; + const grid = computeSensitivityGrid(wacc, terminalGrowth, (testWacc, testTerminalGrowth) => { + if (mode === "simple") { + if (!dcf.base_fcf) return null; + const result = computeDcf( + { baseFcf: dcf.base_fcf, equityBridge, sharesOutstanding: shares }, + { wacc: testWacc, terminalGrowth: testTerminalGrowth, projectionYears, growthRate } + ); + return "error" in result ? null : result.intrinsicValuePerShare; + } + const result = computeAdvancedDcf(advancedInputs, { + wacc: testWacc, + terminalGrowth: testTerminalGrowth, + projectionYears: advancedInputs.revenueGrowth.length, + sharesOutstanding: shares, + equityBridge, + }); + return "error" in result ? null : result.intrinsicValuePerShare; + }); + + return { + wacc: grid.wacc, + terminal_growth: grid.terminalGrowth, + implied_prices: grid.impliedPrices, + }; + }, [mode, dcf, advancedInputs, wacc, terminalGrowth, projectionYears, growthRate, equityBridge, data.shares_outstanding]); + const hasMultiples = ev_ebitda.available || ev_revenue.available || price_to_book.available; return ( @@ -159,41 +243,65 @@ export function ValuationCard({ data }: Props) {
Market Price - {current_price != null ? fmtCurrency(current_price) : "—"} + {current_price != null ? fmtCurrency(current_price, 2, currency) : "—"}
+ {data.currency_warning && ( +

{data.currency_warning}

+ )} + {/* DCF detail */}
Discounted Cash Flow - {dcf.available && !dcf.error && isCustomised && ( - - )} +
+
+ + +
+ {dcf.available && !dcf.error && isCustomised && ( + + )} +
{!dcf.available && ( @@ -204,112 +312,84 @@ export function ValuationCard({ data }: Props) { )} {dcf.available && !dcf.error && (
- {/* Left: sliders */} + {/* Left: assumptions */}
Assumptions
- `${(v * 100).toFixed(1)}%`} - onChange={setWacc} - /> - `${(v * 100).toFixed(2)}%`} - onChange={setTerminalGrowth} - /> - `${(v * 100).toFixed(0)}%`} - onChange={setGrowthRate} - /> - `${v} yr`} - onChange={(v) => setProjectionYears(Math.round(v))} - /> + + {mode === "simple" ? ( + <> + `${(v * 100).toFixed(1)}%`} + onChange={setWacc} + /> + `${(v * 100).toFixed(2)}%`} + onChange={setTerminalGrowth} + /> + `${(v * 100).toFixed(0)}%`} + onChange={setGrowthRate} + /> + `${v} yr`} + onChange={(v) => setProjectionYears(Math.round(v))} + /> + + ) : ( + + )}
{/* Divider */}
{/* Right: results */} -
-
Results
- - {computed && "error" in computed ? ( -

{computed.error}

- ) : ( - <> -
-
- Base FCF (TTM) - - {dcf.base_fcf != null ? fmtLarge(dcf.base_fcf) : "—"} - -
-
- Historical growth - - {dcf.growth_rate_used != null ? fmtPct(dcf.growth_rate_used) : "—"} - -
-
- FCF PV Sum - - {computed ? fmtLarge(computed.fcfPvSum) : "—"} - -
-
- Terminal Value PV - - {computed ? fmtLarge(computed.terminalValuePv) : "—"} - -
-
- Enterprise Value - - {computed ? fmtLarge(computed.enterpriseValue) : "—"} - -
-
- Net Debt - - {dcf.net_debt != null ? fmtLarge(dcf.net_debt) : "—"} - -
-
-
- Intrinsic Value - - {livePrice != null ? fmtCurrency(livePrice) : "—"} - - {data.shares_outstanding != null && ( - - {fmtLarge(data.shares_outstanding)} shares - - )} -
- - )} -
+
)} + {sensitivityMatrix && ( + + )}
{/* Multiples */} @@ -325,6 +405,7 @@ export function ValuationCard({ data }: Props) { multiple={ev_ebitda.multiple_used} price={ev_ebitda.implied_price_per_share} current={current_price} + currency={currency} /> )} {ev_revenue.available && ( @@ -333,6 +414,7 @@ export function ValuationCard({ data }: Props) { multiple={ev_revenue.multiple_used} price={ev_revenue.implied_price_per_share} current={current_price} + currency={currency} /> )} {price_to_book.available && ( @@ -341,6 +423,7 @@ export function ValuationCard({ data }: Props) { multiple={price_to_book.multiple_used} price={price_to_book.implied_price_per_share} current={current_price} + currency={currency} /> )}
-- cgit v1.3-2-g0d8e