From f1b1e85b53db87a04442181a40c530aca4791a14 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Mon, 29 Jun 2026 01:41:20 -0700 Subject: feat: add DCF panel components --- frontend/components/prism/AdvancedDcfPanel.tsx | 218 +++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 frontend/components/prism/AdvancedDcfPanel.tsx (limited to 'frontend/components/prism/AdvancedDcfPanel.tsx') 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 = { + 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 = { + 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>( + () => ({ + 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, 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 ( +
+
+ {Object.keys(PRESETS).map((name) => ( + + ))} +
+ +
+ {/* Scalar field */} +
+ + { + const raw = e.target.value; + setBaseRevenueText(raw); + const num = parseNumber(raw); + if (num !== inputs.baseRevenue) { + updateScalar("baseRevenue", num); + } + }} + onBlur={() => setBaseRevenueText(fmtNumber(inputs.baseRevenue))} + /> +
+ + {/* Array fields with year headers */} +
+
+ + {YEAR_LABELS.map((yl) => ( + {yl} + ))} +
+ {(Object.keys(LABELS) as Array) + .filter((key) => key !== "baseRevenue") + .map((field) => ( +
+ {LABELS[field]} + {inputs[field].map((value, idx) => ( + { + const num = parseRateInput(e.target.value); + if (num !== null) updateArray(field, idx, num); + }} + /> + ))} +
+ ))} +
+
+ +
+ Advanced Intrinsic Value + + {isInvalid + ? "Invalid assumptions" + : price != null + ? fmtCurrency(price, 2, currency) + : computed && "error" in computed + ? computed.error + : "—"} + +
+
+ ); +} \ No newline at end of file -- cgit v1.3-2-g0d8e