summaryrefslogtreecommitdiff
path: root/frontend/components/prism
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/components/prism')
-rw-r--r--frontend/components/prism/ValuationCard.tsx251
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>