diff options
Diffstat (limited to 'frontend/components/prism/AdvancedDcfPanel.tsx')
| -rw-r--r-- | frontend/components/prism/AdvancedDcfPanel.tsx | 218 |
1 files changed, 218 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 |
