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 +++++++++++++++++++++++++ frontend/components/prism/CurrencyWarning.tsx | 8 + frontend/components/prism/DcfResults.tsx | 81 +++++++++ frontend/components/prism/SensitivityTable.tsx | 72 ++++++++ 4 files changed, 379 insertions(+) create mode 100644 frontend/components/prism/AdvancedDcfPanel.tsx create mode 100644 frontend/components/prism/CurrencyWarning.tsx create mode 100644 frontend/components/prism/DcfResults.tsx create mode 100644 frontend/components/prism/SensitivityTable.tsx (limited to 'frontend/components/prism') 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 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 {warning}; +} 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 ( +
+
Results
+ + {computed && "error" in computed ? ( +

{computed.error}

+ ) : ( + <> +
+
+ Base FCF (TTM) + + {dcf.base_fcf != null ? fmtLarge(dcf.base_fcf, currency) : "—"} + +
+
+ Historical growth + + {dcf.growth_rate_used != null ? fmtPct(dcf.growth_rate_used) : "—"} + +
+
+ FCF PV Sum + + {computed ? fmtLarge(computed.fcfPvSum, currency) : "—"} + +
+
+ Terminal Value PV + + {computed ? fmtLarge(computed.terminalValuePv, currency) : "—"} + +
+
+ Enterprise Value + + {computed ? fmtLarge(computed.enterpriseValue, currency) : "—"} + +
+
+ Net Debt + + {dcf.net_debt != null ? fmtLarge(dcf.net_debt, currency) : "—"} + +
+
+
+ Intrinsic Value + + {livePrice != null && livePrice < 0 + ? "Invalid assumptions" + : livePrice != null + ? fmtCurrency(livePrice, 2, currency) + : "—"} + + {sharesOutstanding != null && ( + + {fmtLarge(sharesOutstanding, currency)} shares + + )} +
+ + )} +
+ ); +} 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 ( +
+
+ Implied Price Sensitivity + WACC × Terminal Growth +
+ + + + + {terminal_growth.map((g, i) => ( + + ))} + + + + {wacc.map((w, rowIdx) => ( + + + {implied_prices[rowIdx]?.map((price, colIdx) => { + const isCenter = rowIdx === centerRow && colIdx === centerCol; + const isNegative = price != null && price < 0; + return ( + + ); + })} + + ))} + +
WACC \ g + {fmtPct(g)} +
+ {fmtPct(w)} + + {price != null && !isNegative ? fmtCurrency(price, 2, currency) : "—"} +
+
+ ); +} -- cgit v1.3-2-g0d8e