diff options
Diffstat (limited to 'frontend/components')
| -rw-r--r-- | frontend/components/prism/ValuationCard.tsx | 251 |
1 files changed, 206 insertions, 45 deletions
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 ( <div className={`psm-val-chip${accent ? " accent" : ""}`}> - <span className="psm-val-chip-label">{label}</span> + <span className="psm-val-chip-label"> + {label} + {customised && <span className="psm-val-chip-dot" />} + </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> @@ -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 ( + <div className="psm-val-slider-row"> + <div className="psm-val-slider-header"> + <span className="psm-val-slider-label">{label}</span> + <span className="psm-val-slider-value">{fmt(value)}</span> + </div> + <input + type="range" + className="psm-val-range" + min={min} + max={max} + step={step} + value={value} + onChange={(e) => onChange(parseFloat(e.target.value))} + /> + <div className="psm-val-slider-ticks"> + <span className="psm-val-slider-tick">{fmt(min)}</span> + <span className="psm-val-slider-tick">{fmt(max)}</span> + </div> + </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; + + // 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) : "—"} </span> </div> - <SummaryChip label="DCF" price={dcfPrice} current={current_price} /> + <SummaryChip + label="DCF" + price={livePrice} + current={current_price} + customised={isCustomised} + /> <SummaryChip label="EV / EBITDA" price={ev_ebitda.available ? ev_ebitda.implied_price_per_share : null} @@ -91,11 +187,13 @@ export function ValuationCard({ data }: Props) { {/* DCF detail */} <div className="psm-val-section"> - <div className="psm-val-section-head"> + <div className="psm-val-dcf-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> + {dcf.available && !dcf.error && isCustomised && ( + <button className="psm-val-reset-btn" onClick={handleReset}> + Reset + </button> + )} </div> {!dcf.available && ( @@ -106,45 +204,108 @@ export function ValuationCard({ data }: Props) { )} {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> + {/* Left: sliders */} + <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))} + /> </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> + + {/* 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> </div> |
