diff options
Diffstat (limited to 'frontend/components/prism/ValuationCard.tsx')
| -rw-r--r-- | frontend/components/prism/ValuationCard.tsx | 305 |
1 files changed, 194 insertions, 111 deletions
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 && <span className="psm-val-chip-dot" />} </span> - <span className="psm-val-chip-price">{price != null ? fmtCurrency(price) : "—"}</span> + <span className="psm-val-chip-price">{price != null && price >= 0 ? fmtCurrency(price, 2, currency) : "—"}</span> {pct != null && ( <span className={`psm-val-chip-pct ${deltaClass(pct)}`}>{fmtPct(pct, 1, true)}</span> )} @@ -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 ( <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-price">{price != null ? fmtCurrency(price, 2, currency) : "—"}</span> <span className={`psm-val-mult-pct ${pct != null ? deltaClass(pct) : "neutral"}`}> {pct != null ? fmtPct(pct, 1, true) : "—"} </span> @@ -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 ( + <div className="psm-val-wacc-note"> + <span className="psm-val-wacc-label">Market-implied WACC</span> + <span className="psm-val-wacc-value">{fmtPct(waccData.wacc)}</span> + <span className="psm-val-wacc-detail"> + β {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)} + </span> + </div> + ); +} + +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<DcfMode>("simple"); + const [advancedInputs, setAdvancedInputs] = useState<AdvancedDcfInputs>(() => ({ + 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) { <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) : "—"} + {current_price != null ? fmtCurrency(current_price, 2, currency) : "—"} </span> </div> <SummaryChip label="DCF" price={livePrice} current={current_price} + currency={currency} customised={isCustomised} /> <SummaryChip label="EV / EBITDA" price={ev_ebitda.available ? ev_ebitda.implied_price_per_share : null} current={current_price} + currency={currency} /> <SummaryChip label="EV / Revenue" price={ev_revenue.available ? ev_revenue.implied_price_per_share : null} current={current_price} + currency={currency} /> <SummaryChip label="P / Book" price={price_to_book.available ? price_to_book.implied_price_per_share : null} current={current_price} + currency={currency} /> </div> + {data.currency_warning && ( + <p className="psm-muted-copy psm-val-currency-note">{data.currency_warning}</p> + )} + {/* DCF detail */} <div className="psm-val-section"> <div className="psm-val-dcf-section-head"> <span className="psm-eyebrow">Discounted Cash Flow</span> - {dcf.available && !dcf.error && isCustomised && ( - <button className="psm-val-reset-btn" onClick={handleReset}> - Reset - </button> - )} + <div className="psm-val-mode-toggle"> + <div className="psm-val-mode-segment"> + <button + className={`psm-val-mode-btn${mode === "simple" ? " active" : ""}`} + onClick={() => setMode("simple")} + > + Simple + </button> + <button + className={`psm-val-mode-btn${mode === "advanced" ? " active" : ""}`} + onClick={() => setMode("advanced")} + > + Advanced + </button> + </div> + {dcf.available && !dcf.error && isCustomised && ( + <button className="psm-val-reset-btn" onClick={handleReset}> + Reset + </button> + )} + </div> </div> {!dcf.available && ( @@ -204,112 +312,84 @@ export function ValuationCard({ data }: Props) { )} {dcf.available && !dcf.error && ( <div className="psm-val-dcf-body"> - {/* Left: sliders */} + {/* Left: assumptions */} <div className="psm-val-dcf-assumptions"> <div className="psm-val-dcf-col-label">Assumptions</div> - <DCFSlider - label="WACC" - value={wacc} - min={0.03} - max={0.20} - step={0.005} - fmt={(v) => `${(v * 100).toFixed(1)}%`} - onChange={setWacc} - /> - <DCFSlider - label="Terminal Growth" - value={terminalGrowth} - min={0} - max={0.06} - step={0.0025} - fmt={(v) => `${(v * 100).toFixed(2)}%`} - onChange={setTerminalGrowth} - /> - <DCFSlider - label="FCF Growth" - value={growthRate} - min={0} - max={0.50} - step={0.01} - fmt={(v) => `${(v * 100).toFixed(0)}%`} - onChange={setGrowthRate} - /> - <DCFSlider - label="Horizon" - value={projectionYears} - min={3} - max={15} - step={1} - fmt={(v) => `${v} yr`} - onChange={(v) => setProjectionYears(Math.round(v))} - /> + <WaccNote waccData={waccData} /> + {mode === "simple" ? ( + <> + <DCFSlider + label="WACC" + value={wacc} + min={0.03} + max={0.20} + step={0.005} + fmt={(v) => `${(v * 100).toFixed(1)}%`} + onChange={setWacc} + /> + <DCFSlider + label="Terminal Growth" + value={terminalGrowth} + min={0} + max={0.06} + step={0.0025} + fmt={(v) => `${(v * 100).toFixed(2)}%`} + onChange={setTerminalGrowth} + /> + <DCFSlider + label="FCF Growth" + value={growthRate} + min={0} + max={0.50} + step={0.01} + fmt={(v) => `${(v * 100).toFixed(0)}%`} + onChange={setGrowthRate} + /> + <DCFSlider + label="Horizon" + value={projectionYears} + min={3} + max={15} + step={1} + fmt={(v) => `${v} yr`} + onChange={(v) => setProjectionYears(Math.round(v))} + /> + </> + ) : ( + <AdvancedDcfPanel + inputs={advancedInputs} + onChange={setAdvancedInputs} + wacc={wacc} + terminalGrowth={terminalGrowth} + sharesOutstanding={data.shares_outstanding ?? 0} + equityBridge={equityBridge} + seedRevenue={seedRevenue} + currency={currency} + /> + )} </div> {/* Divider */} <div className="psm-val-dcf-divider" /> {/* Right: results */} - <div className="psm-val-dcf-results"> - <div className="psm-val-dcf-col-label">Results</div> - - {computed && "error" in computed ? ( - <p className="psm-val-dcf-error">{computed.error}</p> - ) : ( - <> - <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">FCF PV Sum</span> - <span className="psm-val-kv-val"> - {computed ? fmtLarge(computed.fcfPvSum) : "—"} - </span> - </div> - <div className="psm-val-kv-row"> - <span className="psm-val-kv-label">Terminal Value PV</span> - <span className="psm-val-kv-val"> - {computed ? fmtLarge(computed.terminalValuePv) : "—"} - </span> - </div> - <div className="psm-val-kv-row"> - <span className="psm-val-kv-label">Enterprise Value</span> - <span className="psm-val-kv-val"> - {computed ? fmtLarge(computed.enterpriseValue) : "—"} - </span> - </div> - <div className="psm-val-kv-row is-total"> - <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> - <div className="psm-val-intrinsic"> - <span className="psm-val-intrinsic-label">Intrinsic Value</span> - <span className="psm-val-intrinsic-price"> - {livePrice != null ? fmtCurrency(livePrice) : "—"} - </span> - {data.shares_outstanding != null && ( - <span className="psm-val-intrinsic-shares"> - {fmtLarge(data.shares_outstanding)} shares - </span> - )} - </div> - </> - )} - </div> + <DcfResults + dcf={dcf} + computed={computed} + livePrice={livePrice} + sharesOutstanding={data.shares_outstanding ?? null} + currency={currency} + /> </div> )} + {sensitivityMatrix && ( + <SensitivityTable + matrix={sensitivityMatrix} + centerWacc={wacc} + centerTerminalGrowth={terminalGrowth} + currency={currency} + /> + )} </div> {/* 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} /> )} </div> |
