summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/app/prism-shell.css201
-rw-r--r--frontend/components/prism/ValuationCard.tsx190
2 files changed, 391 insertions, 0 deletions
diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css
index cd0023a..9a37bdd 100644
--- a/frontend/app/prism-shell.css
+++ b/frontend/app/prism-shell.css
@@ -1289,3 +1289,204 @@
.psm-fin-empty {
padding: var(--sp-4);
}
+
+/* ── Valuation tab ──────────────────────────────── */
+
+.psm-val-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-5);
+}
+
+.psm-val-strip {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: var(--sp-3);
+}
+
+.psm-val-chip {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-1);
+ padding: var(--sp-3) var(--sp-4);
+ background: var(--ink-2);
+ border-radius: var(--r-2);
+ border: 1px solid var(--line-1);
+}
+
+.psm-val-chip.accent {
+ border-color: var(--brass);
+}
+
+.psm-val-chip-label {
+ font-family: var(--font-sans);
+ font-size: var(--fs-12);
+ font-weight: 600;
+ letter-spacing: var(--tr-wider);
+ text-transform: uppercase;
+ color: var(--fg-3);
+}
+
+.psm-val-chip-price {
+ font-family: var(--font-mono);
+ font-size: var(--fs-18);
+ color: var(--fg-1);
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-val-chip-pct {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-val-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-3);
+ padding-top: var(--sp-4);
+ border-top: 1px solid var(--line-1);
+}
+
+.psm-val-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.psm-val-wacc-note {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ color: var(--fg-4);
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-val-dcf-error {
+ font-family: var(--font-sans);
+ font-size: var(--fs-13);
+ color: var(--warning);
+ padding: var(--sp-3) var(--sp-4);
+ background: var(--ink-2);
+ border-radius: var(--r-2);
+ border-left: 2px solid var(--warning);
+}
+
+.psm-val-dcf-body {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--sp-5);
+}
+
+.psm-val-kv-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.psm-val-kv-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--sp-2) 0;
+ border-bottom: 1px solid var(--ink-2);
+}
+
+.psm-val-kv-row.is-divider {
+ margin-top: var(--sp-2);
+ border-top: 1px solid var(--line-1);
+}
+
+.psm-val-kv-row.is-total .psm-val-kv-val {
+ color: var(--fg-1);
+ font-weight: 600;
+}
+
+.psm-val-kv-label {
+ font-family: var(--font-sans);
+ font-size: var(--fs-13);
+ color: var(--fg-3);
+}
+
+.psm-val-kv-val {
+ font-family: var(--font-mono);
+ font-size: var(--fs-13);
+ color: var(--fg-2);
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-val-intrinsic {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ padding: var(--sp-4);
+ background: var(--ink-2);
+ border-radius: var(--r-2);
+ border: 1px solid var(--line-1);
+ align-self: start;
+}
+
+.psm-val-intrinsic-label {
+ font-family: var(--font-sans);
+ font-size: var(--fs-12);
+ font-weight: 600;
+ letter-spacing: var(--tr-wider);
+ text-transform: uppercase;
+ color: var(--fg-3);
+}
+
+.psm-val-intrinsic-price {
+ font-family: var(--font-mono);
+ font-size: var(--fs-38);
+ color: var(--brass);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+}
+
+.psm-val-intrinsic-shares {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ color: var(--fg-4);
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-val-mult-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.psm-val-mult-row {
+ display: grid;
+ grid-template-columns: 1fr 56px 80px 64px;
+ gap: var(--sp-4);
+ align-items: center;
+ padding: var(--sp-3) 0;
+ border-bottom: 1px solid var(--ink-2);
+}
+
+.psm-val-mult-label {
+ font-family: var(--font-sans);
+ font-size: var(--fs-13);
+ color: var(--fg-2);
+}
+
+.psm-val-mult-x {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ color: var(--fg-3);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.psm-val-mult-price {
+ font-family: var(--font-mono);
+ font-size: var(--fs-13);
+ color: var(--fg-1);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.psm-val-mult-pct {
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
diff --git a/frontend/components/prism/ValuationCard.tsx b/frontend/components/prism/ValuationCard.tsx
new file mode 100644
index 0000000..7329ff5
--- /dev/null
+++ b/frontend/components/prism/ValuationCard.tsx
@@ -0,0 +1,190 @@
+"use client";
+import type { ValuationResponse } from "@/types/api";
+import { deltaClass, fmtCurrency, fmtLarge, fmtPct } from "@/lib/format";
+
+type Props = { data: ValuationResponse };
+
+function pctVsCurrent(implied?: number | null, current?: number | null): number | null {
+ if (implied == null || current == null || current === 0) return null;
+ return (implied - current) / current;
+}
+
+function SummaryChip({
+ label,
+ price,
+ current,
+ accent = false,
+}: {
+ label: string;
+ price?: number | null;
+ current?: number | null;
+ accent?: 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-price">{price != null ? fmtCurrency(price) : "—"}</span>
+ {pct != null && (
+ <span className={`psm-val-chip-pct ${deltaClass(pct)}`}>{fmtPct(pct, 1, true)}</span>
+ )}
+ </div>
+ );
+}
+
+function MultipleRow({
+ label,
+ multiple,
+ price,
+ current,
+}: {
+ label: string;
+ multiple?: number | null;
+ price?: number | null;
+ current?: number | null;
+}) {
+ 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-pct ${pct != null ? deltaClass(pct) : "neutral"}`}>
+ {pct != null ? fmtPct(pct, 1, true) : "—"}
+ </span>
+ </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;
+ const hasMultiples = ev_ebitda.available || ev_revenue.available || price_to_book.available;
+
+ return (
+ <section className="psm-card psm-val-card">
+ {/* Summary strip */}
+ <div className="psm-val-strip">
+ <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) : "—"}
+ </span>
+ </div>
+ <SummaryChip label="DCF" price={dcfPrice} current={current_price} />
+ <SummaryChip
+ label="EV / EBITDA"
+ price={ev_ebitda.available ? ev_ebitda.implied_price_per_share : null}
+ current={current_price}
+ />
+ <SummaryChip
+ label="EV / Revenue"
+ price={ev_revenue.available ? ev_revenue.implied_price_per_share : null}
+ current={current_price}
+ />
+ <SummaryChip
+ label="P / Book"
+ price={price_to_book.available ? price_to_book.implied_price_per_share : null}
+ current={current_price}
+ />
+ </div>
+
+ {/* DCF detail */}
+ <div className="psm-val-section">
+ <div className="psm-val-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>
+ </div>
+
+ {!dcf.available && (
+ <p className="psm-muted-copy">Insufficient free cash flow history for DCF.</p>
+ )}
+ {dcf.available && dcf.error && (
+ <p className="psm-val-dcf-error">{dcf.error}</p>
+ )}
+ {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>
+ </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>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* Multiples */}
+ {hasMultiples && (
+ <div className="psm-val-section">
+ <div className="psm-val-section-head">
+ <span className="psm-eyebrow">Multiples — at current market multiple</span>
+ </div>
+ <div className="psm-val-mult-list">
+ {ev_ebitda.available && (
+ <MultipleRow
+ label="EV / EBITDA"
+ multiple={ev_ebitda.multiple_used}
+ price={ev_ebitda.implied_price_per_share}
+ current={current_price}
+ />
+ )}
+ {ev_revenue.available && (
+ <MultipleRow
+ label="EV / Revenue"
+ multiple={ev_revenue.multiple_used}
+ price={ev_revenue.implied_price_per_share}
+ current={current_price}
+ />
+ )}
+ {price_to_book.available && (
+ <MultipleRow
+ label="P / Book"
+ multiple={price_to_book.multiple_used}
+ price={price_to_book.implied_price_per_share}
+ current={current_price}
+ />
+ )}
+ </div>
+ </div>
+ )}
+ </section>
+ );
+}