From 676f2b21c353a647fb1dd4652e6eb159c0d83aa9 Mon Sep 17 00:00:00 2001
From: Tyler Hoang <tyler@tylerhoang.xyz>
Date: Tue, 19 May 2026 00:09:27 -0700
Subject: feat: add real-time DCF sliders to ValuationCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 frontend/components/prism/ValuationCard.tsx | 251 +++++++++++++++++++++++-----
 1 file changed, 206 insertions(+), 45 deletions(-)

(limited to 'frontend/components/prism/ValuationCard.tsx')

diff --git a/frontend/components/prism/ValuationCard.tsx b/frontend/components/prism/ValuationCard.tsx
index 7329ff5..a1b80c3 100644
--- a/frontend/components/prism/ValuationCard.tsx
+++ b/frontend/components/prism/ValuationCard.tsx
@@ -1,6 +1,8 @@
 "use client";
+import { useMemo, useState } from "react";
 import type { ValuationResponse } from "@/types/api";
 import { deltaClass, fmtCurrency, fmtLarge, fmtPct } from "@/lib/format";
+import { computeDcf } from "@/lib/dcf";
 
 type Props = { data: ValuationResponse };
 
@@ -14,16 +16,21 @@ function SummaryChip({
   price,
   current,
   accent = false,
+  customised = false,
 }: {
   label: string;
   price?: number | null;
   current?: number | null;
   accent?: boolean;
+  customised?: boolean;
 }) {
   const pct = pctVsCurrent(price, current);
   return (
     <div className={`psm-val-chip${accent ? " accent" : ""}`}>
-      <span className="psm-val-chip-label">{label}</span>
+      <span className="psm-val-chip-label">
+        {label}
+        {customised && <span className="psm-val-chip-dot" />}
+      </span>
       <span className="psm-val-chip-price">{price != null ? fmtCurrency(price) : "—"}</span>
       {pct != null && (
         <span className={`psm-val-chip-pct ${deltaClass(pct)}`}>{fmtPct(pct, 1, true)}</span>
@@ -56,9 +63,93 @@ function MultipleRow({
   );
 }
 
+function DCFSlider({
+  label,
+  value,
+  min,
+  max,
+  step,
+  fmt,
+  onChange,
+}: {
+  label: string;
+  value: number;
+  min: number;
+  max: number;
+  step: number;
+  fmt: (v: number) => string;
+  onChange: (v: number) => void;
+}) {
+  return (
+    <div className="psm-val-slider-row">
+      <div className="psm-val-slider-header">
+        <span className="psm-val-slider-label">{label}</span>
+        <span className="psm-val-slider-value">{fmt(value)}</span>
+      </div>
+      <input
+        type="range"
+        className="psm-val-range"
+        min={min}
+        max={max}
+        step={step}
+        value={value}
+        onChange={(e) => onChange(parseFloat(e.target.value))}
+      />
+      <div className="psm-val-slider-ticks">
+        <span className="psm-val-slider-tick">{fmt(min)}</span>
+        <span className="psm-val-slider-tick">{fmt(max)}</span>
+      </div>
+    </div>
+  );
+}
+
 export function ValuationCard({ data }: Props) {
   const { dcf, ev_ebitda, ev_revenue, price_to_book, current_price } = data;
-  const dcfPrice = dcf.available && !dcf.error ? dcf.intrinsic_value_per_share : null;
+
+  // Slider defaults captured once from the initial API response
+  const [defaults] = useState(() => ({
+    wacc: dcf.wacc,
+    terminalGrowth: dcf.terminal_growth,
+    projectionYears: dcf.projection_years,
+    growthRate: dcf.growth_rate_used ?? 0.05,
+  }));
+
+  const [wacc, setWacc] = useState(defaults.wacc);
+  const [terminalGrowth, setTerminalGrowth] = useState(defaults.terminalGrowth);
+  const [projectionYears, setProjectionYears] = useState(defaults.projectionYears);
+  const [growthRate, setGrowthRate] = useState(defaults.growthRate);
+
+  const isCustomised =
+    wacc !== defaults.wacc ||
+    terminalGrowth !== defaults.terminalGrowth ||
+    projectionYears !== defaults.projectionYears ||
+    growthRate !== defaults.growthRate;
+
+  function handleReset() {
+    setWacc(defaults.wacc);
+    setTerminalGrowth(defaults.terminalGrowth);
+    setProjectionYears(defaults.projectionYears);
+    setGrowthRate(defaults.growthRate);
+  }
+
+  // equityBridge = net_debt + preferred equity + minority interest.
+  // Derived as enterprise_value - equity_value (both from API); stays fixed while sliders move.
+  const equityBridge =
+    dcf.enterprise_value != null && dcf.equity_value != null
+      ? dcf.enterprise_value - dcf.equity_value
+      : (dcf.net_debt ?? 0);
+
+  const computed = useMemo(() => {
+    if (!dcf.available || dcf.error || !dcf.base_fcf || !data.shares_outstanding) return null;
+    return computeDcf(
+      { baseFcf: dcf.base_fcf, equityBridge, sharesOutstanding: data.shares_outstanding },
+      { wacc, terminalGrowth, projectionYears, growthRate }
+    );
+  }, [wacc, terminalGrowth, projectionYears, growthRate, equityBridge, dcf, data.shares_outstanding]);
+
+  const livePrice =
+    computed && !("error" in computed) ? computed.intrinsicValuePerShare : null;
+
   const hasMultiples = ev_ebitda.available || ev_revenue.available || price_to_book.available;
 
   return (
@@ -71,7 +162,12 @@ export function ValuationCard({ data }: Props) {
             {current_price != null ? fmtCurrency(current_price) : "—"}
           </span>
         </div>
-        <SummaryChip label="DCF" price={dcfPrice} current={current_price} />
+        <SummaryChip
+          label="DCF"
+          price={livePrice}
+          current={current_price}
+          customised={isCustomised}
+        />
         <SummaryChip
           label="EV / EBITDA"
           price={ev_ebitda.available ? ev_ebitda.implied_price_per_share : null}
@@ -91,11 +187,13 @@ export function ValuationCard({ data }: Props) {
 
       {/* DCF detail */}
       <div className="psm-val-section">
-        <div className="psm-val-section-head">
+        <div className="psm-val-dcf-section-head">
           <span className="psm-eyebrow">Discounted Cash Flow</span>
-          <span className="psm-val-wacc-note">
-            WACC {(dcf.wacc * 100).toFixed(1)}% · Terminal {(dcf.terminal_growth * 100).toFixed(1)}%
-          </span>
+          {dcf.available && !dcf.error && isCustomised && (
+            <button className="psm-val-reset-btn" onClick={handleReset}>
+              Reset
+            </button>
+          )}
         </div>
 
         {!dcf.available && (
@@ -106,45 +204,108 @@ export function ValuationCard({ data }: Props) {
         )}
         {dcf.available && !dcf.error && (
           <div className="psm-val-dcf-body">
-            <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) : "—"}</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">Enterprise Value</span>
-                <span className="psm-val-kv-val">
-                  {dcf.enterprise_value != null ? fmtLarge(dcf.enterprise_value) : "—"}
-                </span>
-              </div>
-              <div className="psm-val-kv-row">
-                <span className="psm-val-kv-label">Net Debt</span>
-                <span className="psm-val-kv-val">
-                  {dcf.net_debt != null ? fmtLarge(dcf.net_debt) : "—"}
-                </span>
-              </div>
-              <div className="psm-val-kv-row is-total">
-                <span className="psm-val-kv-label">Equity Value</span>
-                <span className="psm-val-kv-val">
-                  {dcf.equity_value != null ? fmtLarge(dcf.equity_value) : "—"}
-                </span>
-              </div>
+            {/* Left: sliders */}
+            <div className="psm-val-dcf-assumptions">
+              <div className="psm-val-dcf-col-label">Assumptions</div>
+              <DCFSlider
+                label="WACC"
+                value={wacc}
+                min={0.03}
+                max={0.20}
+                step={0.005}
+                fmt={(v) => `${(v * 100).toFixed(1)}%`}
+                onChange={setWacc}
+              />
+              <DCFSlider
+                label="Terminal Growth"
+                value={terminalGrowth}
+                min={0}
+                max={0.06}
+                step={0.0025}
+                fmt={(v) => `${(v * 100).toFixed(2)}%`}
+                onChange={setTerminalGrowth}
+              />
+              <DCFSlider
+                label="FCF Growth"
+                value={growthRate}
+                min={0}
+                max={0.50}
+                step={0.01}
+                fmt={(v) => `${(v * 100).toFixed(0)}%`}
+                onChange={setGrowthRate}
+              />
+              <DCFSlider
+                label="Horizon"
+                value={projectionYears}
+                min={3}
+                max={15}
+                step={1}
+                fmt={(v) => `${v} yr`}
+                onChange={(v) => setProjectionYears(Math.round(v))}
+              />
             </div>
-            <div className="psm-val-intrinsic">
-              <span className="psm-val-intrinsic-label">Intrinsic Value</span>
-              <span className="psm-val-intrinsic-price">
-                {dcf.intrinsic_value_per_share != null ? fmtCurrency(dcf.intrinsic_value_per_share) : "—"}
-              </span>
-              {data.shares_outstanding != null && (
-                <span className="psm-val-intrinsic-shares">
-                  {fmtLarge(data.shares_outstanding)} shares
-                </span>
+
+            {/* Divider */}
+            <div className="psm-val-dcf-divider" />
+
+            {/* Right: results */}
+            <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) : "—"}
+                      </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) : "—"}
+                      </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) : "—"}
+                      </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) : "—"}
+                      </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) : "—"}
+                      </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 ? fmtCurrency(livePrice) : "—"}
+                    </span>
+                    {data.shares_outstanding != null && (
+                      <span className="psm-val-intrinsic-shares">
+                        {fmtLarge(data.shares_outstanding)} shares
+                      </span>
+                    )}
+                  </div>
+                </>
               )}
             </div>
           </div>
-- 
cgit v1.3-2-g0d8e

