From 676f2b21c353a647fb1dd4652e6eb159c0d83aa9 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Tue, 19 May 2026 00:09:27 -0700 Subject: feat: add real-time DCF sliders to ValuationCard Co-Authored-By: Claude Sonnet 4.6 --- frontend/components/prism/ValuationCard.tsx | 251 +++++++++++++++++++++++----- 1 file changed, 206 insertions(+), 45 deletions(-) (limited to 'frontend/components') diff --git a/frontend/components/prism/ValuationCard.tsx b/frontend/components/prism/ValuationCard.tsx index 7329ff5..a1b80c3 100644 --- a/frontend/components/prism/ValuationCard.tsx +++ b/frontend/components/prism/ValuationCard.tsx @@ -1,6 +1,8 @@ "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"; type Props = { data: ValuationResponse }; @@ -14,16 +16,21 @@ function SummaryChip({ price, current, accent = false, + customised = false, }: { label: string; price?: number | null; current?: number | null; accent?: boolean; + customised?: boolean; }) { const pct = pctVsCurrent(price, current); return (
- {label} + + {label} + {customised && } + {price != null ? fmtCurrency(price) : "—"} {pct != null && ( {fmtPct(pct, 1, true)} @@ -56,9 +63,93 @@ function MultipleRow({ ); } +function DCFSlider({ + label, + value, + min, + max, + step, + fmt, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + fmt: (v: number) => string; + onChange: (v: number) => void; +}) { + return ( +
+
+ {label} + {fmt(value)} +
+ onChange(parseFloat(e.target.value))} + /> +
+ {fmt(min)} + {fmt(max)} +
+
+ ); +} + 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; + + // Slider defaults captured once from the initial API response + const [defaults] = useState(() => ({ + wacc: dcf.wacc, + terminalGrowth: dcf.terminal_growth, + projectionYears: dcf.projection_years, + growthRate: dcf.growth_rate_used ?? 0.05, + })); + + const [wacc, setWacc] = useState(defaults.wacc); + const [terminalGrowth, setTerminalGrowth] = useState(defaults.terminalGrowth); + const [projectionYears, setProjectionYears] = useState(defaults.projectionYears); + const [growthRate, setGrowthRate] = useState(defaults.growthRate); + + const isCustomised = + wacc !== defaults.wacc || + terminalGrowth !== defaults.terminalGrowth || + projectionYears !== defaults.projectionYears || + growthRate !== defaults.growthRate; + + function handleReset() { + setWacc(defaults.wacc); + setTerminalGrowth(defaults.terminalGrowth); + setProjectionYears(defaults.projectionYears); + setGrowthRate(defaults.growthRate); + } + + // equityBridge = net_debt + preferred equity + minority interest. + // Derived as enterprise_value - equity_value (both from API); stays fixed while sliders move. + const equityBridge = + dcf.enterprise_value != null && dcf.equity_value != null + ? dcf.enterprise_value - dcf.equity_value + : (dcf.net_debt ?? 0); + + const computed = useMemo(() => { + if (!dcf.available || dcf.error || !dcf.base_fcf || !data.shares_outstanding) 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]); + + const livePrice = + computed && !("error" in computed) ? computed.intrinsicValuePerShare : null; + const hasMultiples = ev_ebitda.available || ev_revenue.available || price_to_book.available; return ( @@ -71,7 +162,12 @@ export function ValuationCard({ data }: Props) { {current_price != null ? fmtCurrency(current_price) : "—"}
- + -
+
Discounted Cash Flow - - WACC {(dcf.wacc * 100).toFixed(1)}% · Terminal {(dcf.terminal_growth * 100).toFixed(1)}% - + {dcf.available && !dcf.error && isCustomised && ( + + )}
{!dcf.available && ( @@ -106,45 +204,108 @@ export function ValuationCard({ data }: Props) { )} {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) : "—"} - -
+ {/* Left: sliders */} +
+
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))} + />
-
- Intrinsic Value - - {dcf.intrinsic_value_per_share != null ? fmtCurrency(dcf.intrinsic_value_per_share) : "—"} - - {data.shares_outstanding != null && ( - - {fmtLarge(data.shares_outstanding)} shares - + + {/* 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 + + )} +
+ )}
-- cgit v1.3-2-g0d8e