diff options
Diffstat (limited to 'frontend/components/prism')
| -rw-r--r-- | frontend/components/prism/AdvancedDcfPanel.tsx | 218 | ||||
| -rw-r--r-- | frontend/components/prism/CurrencyWarning.tsx | 8 | ||||
| -rw-r--r-- | frontend/components/prism/DcfResults.tsx | 81 | ||||
| -rw-r--r-- | frontend/components/prism/SensitivityTable.tsx | 72 |
4 files changed, 379 insertions, 0 deletions
diff --git a/frontend/components/prism/AdvancedDcfPanel.tsx b/frontend/components/prism/AdvancedDcfPanel.tsx new file mode 100644 index 0000000..9d82f3a --- /dev/null +++ b/frontend/components/prism/AdvancedDcfPanel.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { computeAdvancedDcf } from "@/lib/dcf"; +import { fmtCurrency } from "@/lib/format"; +import type { AdvancedDcfInputs as AdvancedDcfInputShape } from "@/lib/dcf"; + +const YEARS = 5; + +const PRESETS: Record<string, AdvancedDcfInputShape> = { + bear: { + baseRevenue: 1_000, + revenueGrowth: [0.02, 0.02, 0.02, 0.02, 0.02], + ebitdaMargin: [0.2, 0.2, 0.2, 0.2, 0.2], + dnaPctRevenue: [0.05, 0.05, 0.05, 0.05, 0.05], + capexPctRevenue: [0.1, 0.1, 0.1, 0.1, 0.1], + nwcPctDeltaRev: [0.03, 0.03, 0.03, 0.03, 0.03], + taxRate: [0.28, 0.28, 0.28, 0.28, 0.28], + }, + base: { + baseRevenue: 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], + }, + bull: { + baseRevenue: 1_000, + revenueGrowth: [0.1, 0.1, 0.1, 0.1, 0.1], + ebitdaMargin: [0.32, 0.32, 0.32, 0.32, 0.32], + dnaPctRevenue: [0.05, 0.05, 0.05, 0.05, 0.05], + capexPctRevenue: [0.05, 0.05, 0.05, 0.05, 0.05], + nwcPctDeltaRev: [0.015, 0.015, 0.015, 0.015, 0.015], + taxRate: [0.21, 0.21, 0.21, 0.21, 0.21], + }, +}; + +const LABELS: Record<keyof AdvancedDcfInputShape, string> = { + baseRevenue: "Base Revenue", + revenueGrowth: "Revenue Growth", + ebitdaMargin: "EBITDA Margin", + dnaPctRevenue: "D&A % Rev", + capexPctRevenue: "CapEx % Rev", + nwcPctDeltaRev: "ΔNWC % ΔRev", + taxRate: "Tax Rate", +}; + +const YEAR_LABELS = ["Y1", "Y2", "Y3", "Y4", "Y5"]; + +type Props = { + inputs: AdvancedDcfInputShape; + onChange: (inputs: AdvancedDcfInputShape) => void; + wacc: number; + terminalGrowth: number; + sharesOutstanding: number; + equityBridge: number; + seedRevenue?: number | null; + currency: string; +}; + +function fmtRate(v: number) { + return `${(v * 100).toFixed(1)}%`; +} + +function parseRateInput(raw: string): number | null { + const cleaned = raw.replace(/[^0-9.\-]/g, ""); + const num = parseFloat(cleaned) / 100; + return Number.isFinite(num) ? num : null; +} + +function fmtNumber(n: number): string { + return n.toLocaleString("en-US"); +} + +function parseNumber(raw: string): number { + const cleaned = raw.replace(/[^0-9.\-]/g, ""); + const num = parseFloat(cleaned); + return Number.isFinite(num) ? num : 0; +} + +export function AdvancedDcfPanel({ + inputs, + onChange, + wacc, + terminalGrowth, + sharesOutstanding, + equityBridge, + seedRevenue, + currency, +}: Props) { + const baseRevenue = seedRevenue ?? 1_000; + + const presets = useMemo<Record<string, AdvancedDcfInputShape>>( + () => ({ + bear: { ...PRESETS.bear, baseRevenue }, + base: { ...PRESETS.base, baseRevenue }, + bull: { ...PRESETS.bull, baseRevenue }, + }), + [baseRevenue] + ); + + const computed = useMemo(() => { + return computeAdvancedDcf(inputs, { + wacc, + terminalGrowth, + projectionYears: YEARS, + sharesOutstanding, + equityBridge, + }); + }, [inputs, wacc, terminalGrowth, sharesOutstanding, equityBridge]); + + const price = computed && !("error" in computed) ? computed.intrinsicValuePerShare : null; + const isInvalid = price != null && price < 0; + + function applyPreset(name: string) { + const preset = presets[name]; + if (preset) onChange({ ...preset }); + } + + function updateScalar(field: "baseRevenue", value: number) { + onChange({ ...inputs, [field]: value }); + } + + function updateArray(field: Exclude<keyof AdvancedDcfInputShape, "baseRevenue">, index: number, value: number) { + const next = [...inputs[field]]; + next[index] = value; + onChange({ ...inputs, [field]: next }); + } + + const [baseRevenueText, setBaseRevenueText] = useState(fmtNumber(inputs.baseRevenue)); + + useEffect(() => { + setBaseRevenueText(fmtNumber(inputs.baseRevenue)); + }, [inputs.baseRevenue]); + + return ( + <div className="psm-val-advanced-panel"> + <div className="psm-val-preset-row"> + {Object.keys(PRESETS).map((name) => ( + <button + key={name} + className="psm-val-preset-btn" + onClick={() => applyPreset(name)} + > + {name.charAt(0).toUpperCase() + name.slice(1)} + </button> + ))} + </div> + + <div className="psm-val-advanced-grid"> + {/* Scalar field */} + <div className="psm-val-advanced-field"> + <label className="psm-val-advanced-field-label">{LABELS.baseRevenue}</label> + <input + type="text" + inputMode="numeric" + className="psm-val-advanced-input" + value={baseRevenueText} + onChange={(e) => { + const raw = e.target.value; + setBaseRevenueText(raw); + const num = parseNumber(raw); + if (num !== inputs.baseRevenue) { + updateScalar("baseRevenue", num); + } + }} + onBlur={() => setBaseRevenueText(fmtNumber(inputs.baseRevenue))} + /> + </div> + + {/* Array fields with year headers */} + <div className="psm-val-advanced-table"> + <div className="psm-val-advanced-table-header"> + <span className="psm-val-advanced-table-corner" /> + {YEAR_LABELS.map((yl) => ( + <span key={yl} className="psm-val-advanced-table-year">{yl}</span> + ))} + </div> + {(Object.keys(LABELS) as Array<keyof AdvancedDcfInputShape>) + .filter((key) => key !== "baseRevenue") + .map((field) => ( + <div key={field} className="psm-val-advanced-table-row"> + <span className="psm-val-advanced-table-label">{LABELS[field]}</span> + {inputs[field].map((value, idx) => ( + <input + key={idx} + type="text" + inputMode="decimal" + className="psm-val-advanced-input" + value={fmtRate(value)} + onChange={(e) => { + const num = parseRateInput(e.target.value); + if (num !== null) updateArray(field, idx, num); + }} + /> + ))} + </div> + ))} + </div> + </div> + + <div className="psm-val-advanced-result"> + <span className="psm-val-advanced-result-label">Advanced Intrinsic Value</span> + <span className={`psm-val-advanced-result-price${isInvalid ? " invalid" : ""}`}> + {isInvalid + ? "Invalid assumptions" + : price != null + ? fmtCurrency(price, 2, currency) + : computed && "error" in computed + ? computed.error + : "—"} + </span> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/frontend/components/prism/CurrencyWarning.tsx b/frontend/components/prism/CurrencyWarning.tsx new file mode 100644 index 0000000..c4f9097 --- /dev/null +++ b/frontend/components/prism/CurrencyWarning.tsx @@ -0,0 +1,8 @@ +type Props = { + warning: string | null | undefined; +}; + +export function CurrencyWarning({ warning }: Props) { + if (!warning) return null; + return <span className="psm-currency-warning">{warning}</span>; +} diff --git a/frontend/components/prism/DcfResults.tsx b/frontend/components/prism/DcfResults.tsx new file mode 100644 index 0000000..062f041 --- /dev/null +++ b/frontend/components/prism/DcfResults.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { fmtCurrency, fmtLarge, fmtPct } from "@/lib/format"; +import type { DcfComputed } from "@/lib/dcf"; +import type { DcfResult } from "@/types/api"; + +type Props = { + dcf: DcfResult; + computed: DcfComputed | { error: string } | null; + livePrice: number | null; + sharesOutstanding: number | null; + currency: string; +}; + +export function DcfResults({ dcf, computed, livePrice, sharesOutstanding, currency }: Props) { + return ( + <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, currency) : "—"} + </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, currency) : "—"} + </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, currency) : "—"} + </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, currency) : "—"} + </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, currency) : "—"} + </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 && livePrice < 0 ? " invalid" : ""}`}> + {livePrice != null && livePrice < 0 + ? "Invalid assumptions" + : livePrice != null + ? fmtCurrency(livePrice, 2, currency) + : "—"} + </span> + {sharesOutstanding != null && ( + <span className="psm-val-intrinsic-shares"> + {fmtLarge(sharesOutstanding, currency)} shares + </span> + )} + </div> + </> + )} + </div> + ); +} diff --git a/frontend/components/prism/SensitivityTable.tsx b/frontend/components/prism/SensitivityTable.tsx new file mode 100644 index 0000000..ef86e9b --- /dev/null +++ b/frontend/components/prism/SensitivityTable.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { fmtCurrency, fmtPct } from "@/lib/format"; +import type { SensitivityMatrix } from "@/types/api"; + +type Props = { + matrix: SensitivityMatrix; + centerWacc?: number; + centerTerminalGrowth?: number; + currency: string; +}; + +export function SensitivityTable({ matrix, centerWacc, centerTerminalGrowth, currency }: Props) { + const { wacc, terminal_growth, implied_prices } = matrix; + const centerRow = + centerWacc != null ? wacc.findIndex((v) => Math.abs(v - centerWacc) < 1e-9) : -1; + const centerCol = + centerTerminalGrowth != null + ? terminal_growth.findIndex((v) => Math.abs(v - centerTerminalGrowth) < 1e-9) + : -1; + + return ( + <div className="psm-val-sensitivity"> + <div className="psm-val-sensitivity-head"> + <span className="psm-val-sensitivity-title">Implied Price Sensitivity</span> + <span className="psm-val-sensitivity-subtitle">WACC × Terminal Growth</span> + </div> + <table className="psm-val-sensitivity-table"> + <thead> + <tr> + <th className="psm-val-sensitivity-corner">WACC \ g</th> + {terminal_growth.map((g, i) => ( + <th + key={i} + className={`psm-val-sensitivity-col-header${ + i === centerCol ? " is-center" : "" + }`} + > + {fmtPct(g)} + </th> + ))} + </tr> + </thead> + <tbody> + {wacc.map((w, rowIdx) => ( + <tr key={rowIdx}> + <th + className={`psm-val-sensitivity-row-header${ + rowIdx === centerRow ? " is-center" : "" + }`} + > + {fmtPct(w)} + </th> + {implied_prices[rowIdx]?.map((price, colIdx) => { + const isCenter = rowIdx === centerRow && colIdx === centerCol; + const isNegative = price != null && price < 0; + return ( + <td + key={colIdx} + className={`psm-val-sensitivity-cell${isCenter ? " is-center" : ""}${isNegative ? " negative" : ""}`} + > + {price != null && !isNegative ? fmtCurrency(price, 2, currency) : "—"} + </td> + ); + })} + </tr> + ))} + </tbody> + </table> + </div> + ); +} |
