summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/components/prism/ValuationCard.tsx305
-rw-r--r--frontend/components/prism/ValuationOverviewCard.tsx8
-rw-r--r--frontend/components/prism/ValuationPage.tsx21
3 files changed, 214 insertions, 120 deletions
diff --git a/frontend/components/prism/ValuationCard.tsx b/frontend/components/prism/ValuationCard.tsx
index a1b80c3..4c00a0c 100644
--- a/frontend/components/prism/ValuationCard.tsx
+++ b/frontend/components/prism/ValuationCard.tsx
@@ -1,10 +1,20 @@
"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";
+import type { ValuationResponse, WaccResponse } from "@/types/api";
+import { deltaClass, fmtCurrency, fmtPct } from "@/lib/format";
+import {
+ computeAdvancedDcf,
+ computeDcf,
+ computeSensitivityGrid,
+ type AdvancedDcfInputs,
+} from "@/lib/dcf";
+import { AdvancedDcfPanel } from "@/components/prism/AdvancedDcfPanel";
+import { DcfResults } from "@/components/prism/DcfResults";
+import { SensitivityTable } from "@/components/prism/SensitivityTable";
-type Props = { data: ValuationResponse };
+type Props = { data: ValuationResponse; waccData?: WaccResponse | null; seedRevenue?: number | null };
+
+type DcfMode = "simple" | "advanced";
function pctVsCurrent(implied?: number | null, current?: number | null): number | null {
if (implied == null || current == null || current === 0) return null;
@@ -15,12 +25,14 @@ function SummaryChip({
label,
price,
current,
+ currency,
accent = false,
customised = false,
}: {
label: string;
price?: number | null;
current?: number | null;
+ currency: string;
accent?: boolean;
customised?: boolean;
}) {
@@ -31,7 +43,7 @@ function SummaryChip({
{label}
{customised && <span className="psm-val-chip-dot" />}
</span>
- <span className="psm-val-chip-price">{price != null ? fmtCurrency(price) : "—"}</span>
+ <span className="psm-val-chip-price">{price != null && price >= 0 ? fmtCurrency(price, 2, currency) : "—"}</span>
{pct != null && (
<span className={`psm-val-chip-pct ${deltaClass(pct)}`}>{fmtPct(pct, 1, true)}</span>
)}
@@ -44,18 +56,20 @@ function MultipleRow({
multiple,
price,
current,
+ currency,
}: {
label: string;
multiple?: number | null;
price?: number | null;
current?: number | null;
+ currency: string;
}) {
const pct = pctVsCurrent(price, current);
return (
<div className="psm-val-mult-row">
<span className="psm-val-mult-label">{label}</span>
<span className="psm-val-mult-x">{multiple != null ? `${multiple.toFixed(1)}×` : "—"}</span>
- <span className="psm-val-mult-price">{price != null ? fmtCurrency(price) : "—"}</span>
+ <span className="psm-val-mult-price">{price != null ? fmtCurrency(price, 2, currency) : "—"}</span>
<span className={`psm-val-mult-pct ${pct != null ? deltaClass(pct) : "neutral"}`}>
{pct != null ? fmtPct(pct, 1, true) : "—"}
</span>
@@ -103,8 +117,24 @@ function DCFSlider({
);
}
-export function ValuationCard({ data }: Props) {
+function WaccNote({ waccData }: { waccData?: WaccResponse | null }) {
+ if (!waccData?.available || waccData.wacc == null) return null;
+ return (
+ <div className="psm-val-wacc-note">
+ <span className="psm-val-wacc-label">Market-implied WACC</span>
+ <span className="psm-val-wacc-value">{fmtPct(waccData.wacc)}</span>
+ <span className="psm-val-wacc-detail">
+ β {waccData.beta.toFixed(2)} · Rf {fmtPct(waccData.risk_free_rate)} · ERP{" "}
+ {fmtPct(waccData.equity_risk_premium)} · rd {fmtPct(waccData.cost_of_debt)} · t{" "}
+ {fmtPct(waccData.tax_rate)}
+ </span>
+ </div>
+ );
+}
+
+export function ValuationCard({ data, waccData, seedRevenue }: Props) {
const { dcf, ev_ebitda, ev_revenue, price_to_book, current_price } = data;
+ const currency = data.currency || "USD";
// Slider defaults captured once from the initial API response
const [defaults] = useState(() => ({
@@ -118,14 +148,26 @@ export function ValuationCard({ data }: Props) {
const [terminalGrowth, setTerminalGrowth] = useState(defaults.terminalGrowth);
const [projectionYears, setProjectionYears] = useState(defaults.projectionYears);
const [growthRate, setGrowthRate] = useState(defaults.growthRate);
+ const [mode, setMode] = useState<DcfMode>("simple");
+ const [advancedInputs, setAdvancedInputs] = useState<AdvancedDcfInputs>(() => ({
+ baseRevenue: seedRevenue ?? 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],
+ }));
const isCustomised =
+ mode !== "simple" ||
wacc !== defaults.wacc ||
terminalGrowth !== defaults.terminalGrowth ||
projectionYears !== defaults.projectionYears ||
growthRate !== defaults.growthRate;
function handleReset() {
+ setMode("simple");
setWacc(defaults.wacc);
setTerminalGrowth(defaults.terminalGrowth);
setProjectionYears(defaults.projectionYears);
@@ -140,16 +182,58 @@ export function ValuationCard({ data }: Props) {
: (dcf.net_debt ?? 0);
const computed = useMemo(() => {
- if (!dcf.available || dcf.error || !dcf.base_fcf || !data.shares_outstanding) return null;
+ if (!dcf.available || dcf.error || !data.shares_outstanding) return null;
+ if (mode === "advanced") {
+ return computeAdvancedDcf(advancedInputs, {
+ wacc,
+ terminalGrowth,
+ projectionYears: advancedInputs.revenueGrowth.length,
+ sharesOutstanding: data.shares_outstanding,
+ equityBridge,
+ });
+ }
+ if (!dcf.base_fcf) 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]);
+ }, [mode, advancedInputs, wacc, terminalGrowth, projectionYears, growthRate, equityBridge, dcf, data.shares_outstanding]);
const livePrice =
computed && !("error" in computed) ? computed.intrinsicValuePerShare : null;
+ const sensitivityMatrix = useMemo(() => {
+ if (!dcf.available || dcf.error || !data.shares_outstanding) return null;
+ if (mode === "simple" && dcf.sensitivity) return dcf.sensitivity;
+ if (mode === "simple" && !dcf.base_fcf) return null;
+
+ const shares = data.shares_outstanding;
+ const grid = computeSensitivityGrid(wacc, terminalGrowth, (testWacc, testTerminalGrowth) => {
+ if (mode === "simple") {
+ if (!dcf.base_fcf) return null;
+ const result = computeDcf(
+ { baseFcf: dcf.base_fcf, equityBridge, sharesOutstanding: shares },
+ { wacc: testWacc, terminalGrowth: testTerminalGrowth, projectionYears, growthRate }
+ );
+ return "error" in result ? null : result.intrinsicValuePerShare;
+ }
+ const result = computeAdvancedDcf(advancedInputs, {
+ wacc: testWacc,
+ terminalGrowth: testTerminalGrowth,
+ projectionYears: advancedInputs.revenueGrowth.length,
+ sharesOutstanding: shares,
+ equityBridge,
+ });
+ return "error" in result ? null : result.intrinsicValuePerShare;
+ });
+
+ return {
+ wacc: grid.wacc,
+ terminal_growth: grid.terminalGrowth,
+ implied_prices: grid.impliedPrices,
+ };
+ }, [mode, dcf, advancedInputs, wacc, terminalGrowth, projectionYears, growthRate, equityBridge, data.shares_outstanding]);
+
const hasMultiples = ev_ebitda.available || ev_revenue.available || price_to_book.available;
return (
@@ -159,41 +243,65 @@ export function ValuationCard({ data }: Props) {
<div className="psm-val-chip accent">
<span className="psm-val-chip-label">Market Price</span>
<span className="psm-val-chip-price">
- {current_price != null ? fmtCurrency(current_price) : "—"}
+ {current_price != null ? fmtCurrency(current_price, 2, currency) : "—"}
</span>
</div>
<SummaryChip
label="DCF"
price={livePrice}
current={current_price}
+ currency={currency}
customised={isCustomised}
/>
<SummaryChip
label="EV / EBITDA"
price={ev_ebitda.available ? ev_ebitda.implied_price_per_share : null}
current={current_price}
+ currency={currency}
/>
<SummaryChip
label="EV / Revenue"
price={ev_revenue.available ? ev_revenue.implied_price_per_share : null}
current={current_price}
+ currency={currency}
/>
<SummaryChip
label="P / Book"
price={price_to_book.available ? price_to_book.implied_price_per_share : null}
current={current_price}
+ currency={currency}
/>
</div>
+ {data.currency_warning && (
+ <p className="psm-muted-copy psm-val-currency-note">{data.currency_warning}</p>
+ )}
+
{/* DCF detail */}
<div className="psm-val-section">
<div className="psm-val-dcf-section-head">
<span className="psm-eyebrow">Discounted Cash Flow</span>
- {dcf.available && !dcf.error && isCustomised && (
- <button className="psm-val-reset-btn" onClick={handleReset}>
- Reset
- </button>
- )}
+ <div className="psm-val-mode-toggle">
+ <div className="psm-val-mode-segment">
+ <button
+ className={`psm-val-mode-btn${mode === "simple" ? " active" : ""}`}
+ onClick={() => setMode("simple")}
+ >
+ Simple
+ </button>
+ <button
+ className={`psm-val-mode-btn${mode === "advanced" ? " active" : ""}`}
+ onClick={() => setMode("advanced")}
+ >
+ Advanced
+ </button>
+ </div>
+ {dcf.available && !dcf.error && isCustomised && (
+ <button className="psm-val-reset-btn" onClick={handleReset}>
+ Reset
+ </button>
+ )}
+ </div>
</div>
{!dcf.available && (
@@ -204,112 +312,84 @@ export function ValuationCard({ data }: Props) {
)}
{dcf.available && !dcf.error && (
<div className="psm-val-dcf-body">
- {/* Left: sliders */}
+ {/* Left: assumptions */}
<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))}
- />
+ <WaccNote waccData={waccData} />
+ {mode === "simple" ? (
+ <>
+ <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))}
+ />
+ </>
+ ) : (
+ <AdvancedDcfPanel
+ inputs={advancedInputs}
+ onChange={setAdvancedInputs}
+ wacc={wacc}
+ terminalGrowth={terminalGrowth}
+ sharesOutstanding={data.shares_outstanding ?? 0}
+ equityBridge={equityBridge}
+ seedRevenue={seedRevenue}
+ currency={currency}
+ />
+ )}
</div>
{/* 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>
+ <DcfResults
+ dcf={dcf}
+ computed={computed}
+ livePrice={livePrice}
+ sharesOutstanding={data.shares_outstanding ?? null}
+ currency={currency}
+ />
</div>
)}
+ {sensitivityMatrix && (
+ <SensitivityTable
+ matrix={sensitivityMatrix}
+ centerWacc={wacc}
+ centerTerminalGrowth={terminalGrowth}
+ currency={currency}
+ />
+ )}
</div>
{/* Multiples */}
@@ -325,6 +405,7 @@ export function ValuationCard({ data }: Props) {
multiple={ev_ebitda.multiple_used}
price={ev_ebitda.implied_price_per_share}
current={current_price}
+ currency={currency}
/>
)}
{ev_revenue.available && (
@@ -333,6 +414,7 @@ export function ValuationCard({ data }: Props) {
multiple={ev_revenue.multiple_used}
price={ev_revenue.implied_price_per_share}
current={current_price}
+ currency={currency}
/>
)}
{price_to_book.available && (
@@ -341,6 +423,7 @@ export function ValuationCard({ data }: Props) {
multiple={price_to_book.multiple_used}
price={price_to_book.implied_price_per_share}
current={current_price}
+ currency={currency}
/>
)}
</div>
diff --git a/frontend/components/prism/ValuationOverviewCard.tsx b/frontend/components/prism/ValuationOverviewCard.tsx
index 9e59787..a881c72 100644
--- a/frontend/components/prism/ValuationOverviewCard.tsx
+++ b/frontend/components/prism/ValuationOverviewCard.tsx
@@ -1,5 +1,6 @@
import type { TickerOverview, ValuationResponse } from "@/types/api";
import { fmtCurrency, fmtLarge, fmtNumber, fmtPct } from "@/lib/format";
+import { CurrencyWarning } from "./CurrencyWarning";
type ValState = "idle" | "loading" | "ready" | "error";
@@ -11,9 +12,9 @@ type Props = {
export function ValuationOverviewCard({ overview, valuation, valState }: Props) {
const rows = [
- { label: "Market Cap", value: fmtLarge(overview.stats.market_cap), missing: overview.stats.market_cap == null },
+ { label: "Market Cap", value: fmtLarge(overview.stats.market_cap, overview.currency), missing: overview.stats.market_cap == null },
{ label: "P/E TTM", value: overview.stats.trailing_pe == null ? "-" : `${fmtNumber(overview.stats.trailing_pe)}x`, missing: overview.stats.trailing_pe == null },
- { label: "EPS TTM", value: fmtCurrency(overview.stats.trailing_eps), missing: overview.stats.trailing_eps == null },
+ { label: "EPS TTM", value: fmtCurrency(overview.stats.trailing_eps, 2, overview.currency), missing: overview.stats.trailing_eps == null },
{ label: "P/B", value: overview.ratios.price_to_book == null ? "-" : `${fmtNumber(overview.ratios.price_to_book)}x`, missing: overview.ratios.price_to_book == null },
{ label: "P/S", value: overview.ratios.price_to_sales == null ? "-" : `${fmtNumber(overview.ratios.price_to_sales)}x`, missing: overview.ratios.price_to_sales == null },
{ label: "EV/Sales", value: overview.ratios.ev_to_sales == null ? "-" : `${fmtNumber(overview.ratios.ev_to_sales)}x`, missing: overview.ratios.ev_to_sales == null },
@@ -30,6 +31,7 @@ export function ValuationOverviewCard({ overview, valuation, valState }: Props)
<div>
<div className="psm-eyebrow">Valuation</div>
<h2 className="psm-card-title">Valuation</h2>
+ <CurrencyWarning warning={overview.currency_warning} />
</div>
</div>
{visible.length > 0 && (
@@ -63,7 +65,7 @@ export function ValuationOverviewCard({ overview, valuation, valState }: Props)
<div className="psm-stat-list">
<div className="psm-stat-row">
<span className="psm-stat-label">DCF Intrinsic Value</span>
- <span className="psm-stat-value">{fmtCurrency(dcf.intrinsic_value_per_share)}</span>
+ <span className="psm-stat-value">{fmtCurrency(dcf.intrinsic_value_per_share, 2, valuation?.currency ?? overview.currency)}</span>
</div>
{dcf.growth_rate_used != null && (
<div className="psm-stat-row">
diff --git a/frontend/components/prism/ValuationPage.tsx b/frontend/components/prism/ValuationPage.tsx
index 3a3ba27..ed8d93c 100644
--- a/frontend/components/prism/ValuationPage.tsx
+++ b/frontend/components/prism/ValuationPage.tsx
@@ -5,7 +5,7 @@ import { buildKpis } from "@/lib/overview";
import { ValuationCard } from "@/components/prism/ValuationCard";
import { KPIStrip } from "@/components/prism/KPIStrip";
import { TickerHeader } from "@/components/prism/TickerHeader";
-import type { TickerOverview, ValuationResponse } from "@/types/api";
+import type { TickerOverview, ValuationResponse, WaccResponse } from "@/types/api";
type ValState = "loading" | "ready" | "error";
@@ -18,6 +18,8 @@ type Props = {
export function ValuationPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) {
const [data, setData] = useState<ValuationResponse | null>(null);
+ const [waccData, setWaccData] = useState<WaccResponse | null>(null);
+ const [seedRevenue, setSeedRevenue] = useState<number | null>(null);
const [valState, setValState] = useState<ValState>("loading");
const kpis = buildKpis(overview);
@@ -25,12 +27,19 @@ export function ValuationPage({ ticker, overview, isSaved, onToggleWatchlist }:
let cancelled = false;
setValState("loading");
setData(null);
+ setWaccData(null);
+ setSeedRevenue(null);
- api
- .valuation(ticker)
- .then((res) => {
+ Promise.all([api.valuation(ticker), api.wacc(ticker), api.financials(ticker)])
+ .then(([valuationRes, waccRes, financialsRes]) => {
if (!cancelled) {
- setData(res);
+ const revenueRow = financialsRes?.income?.rows?.find(
+ (r) => r.label === "Total Revenue"
+ );
+ const latestRevenue = revenueRow?.values?.[0] ?? null;
+ setData(valuationRes);
+ setWaccData(waccRes);
+ setSeedRevenue(latestRevenue);
setValState("ready");
}
})
@@ -55,7 +64,7 @@ export function ValuationPage({ ticker, overview, isSaved, onToggleWatchlist }:
<p className="psm-muted-copy">Valuation data unavailable for {ticker}.</p>
</section>
)}
- {valState === "ready" && data && <ValuationCard data={data} />}
+ {valState === "ready" && data && <ValuationCard data={data} waccData={waccData} seedRevenue={seedRevenue} />}
</>
);
}