summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/components/prism/AdvancedDcfPanel.tsx218
-rw-r--r--frontend/components/prism/CurrencyWarning.tsx8
-rw-r--r--frontend/components/prism/DcfResults.tsx81
-rw-r--r--frontend/components/prism/SensitivityTable.tsx72
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>
+ );
+}