From 5fd350345a6f6caceb3375d3736a9a9ef7291257 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Tue, 19 May 2026 00:45:57 -0700 Subject: feat: add PriceVsValueCard to overview left column (DCF compass) Co-Authored-By: Claude Sonnet 4.6 --- frontend/app/page.tsx | 6 ++ frontend/app/prism-shell.css | 69 ++++++++++++++++++++ frontend/components/prism/PriceVsValueCard.tsx | 90 ++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 frontend/components/prism/PriceVsValueCard.tsx diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 07bdfa4..5c14488 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -11,6 +11,7 @@ import { KPIStrip } from "@/components/prism/KPIStrip"; import { Sidebar } from "@/components/prism/Sidebar"; import { TickerHeader } from "@/components/prism/TickerHeader"; import { TopBar } from "@/components/prism/TopBar"; +import { PriceVsValueCard } from "@/components/prism/PriceVsValueCard"; import { QualityCard } from "@/components/prism/QualityCard"; import { ValuationOverviewCard } from "@/components/prism/ValuationOverviewCard"; import { VolumeCard } from "@/components/prism/VolumeCard"; @@ -76,6 +77,8 @@ function OverviewClient() { setOverviewError(null); setChartError(null); setWatchlistError(null); + setValuation(null); + setValState("idle"); setOverviewState("loading"); setChartState("loading"); @@ -107,6 +110,8 @@ function OverviewClient() { setOverviewError(null); setChartError(null); setWatchlistError(null); + setValuation(null); + setValState("idle"); setOverviewState("idle"); setChartState("idle"); startTransition(() => { @@ -345,6 +350,7 @@ function OverviewClient() { +
diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css index 56ca536..9bcbdc5 100644 --- a/frontend/app/prism-shell.css +++ b/frontend/app/prism-shell.css @@ -1888,3 +1888,72 @@ border-top: 1px solid var(--line-1); margin: var(--sp-4) 0 var(--sp-3); } + +/* ── Price vs Value card ────────────────────────── */ + +.psm-pvv-figures { + display: flex; + align-items: baseline; + gap: var(--sp-4); + margin-bottom: var(--sp-4); +} + +.psm-pvv-current { + color: var(--fg-1); + font-family: var(--font-mono); + font-size: var(--fs-24); + font-variant-numeric: tabular-nums; +} + +.psm-pvv-iv { + font-family: var(--font-mono); + font-size: var(--fs-14); + font-variant-numeric: tabular-nums; +} + +.psm-pvv-rail { + position: relative; + height: 4px; + border-radius: var(--r-full); + background: var(--ink-3); + margin-bottom: var(--sp-2); +} + +.psm-pvv-fill { + position: absolute; + top: 0; + height: 100%; + border-radius: var(--r-full); +} + +.psm-pvv-tick { + position: absolute; + top: -4px; + width: 2px; + height: 12px; + border-radius: 1px; +} + +.psm-pvv-tick.price { + background: var(--fg-1); +} + +.psm-pvv-tick.iv { + background: var(--fg-4); +} + +.psm-pvv-rail-labels { + display: flex; + justify-content: space-between; + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); + font-variant-numeric: tabular-nums; + margin-bottom: var(--sp-3); +} + +.psm-pvv-meta { + color: var(--fg-4); + font-size: var(--fs-12); + line-height: 1.5; +} diff --git a/frontend/components/prism/PriceVsValueCard.tsx b/frontend/components/prism/PriceVsValueCard.tsx new file mode 100644 index 0000000..95124ce --- /dev/null +++ b/frontend/components/prism/PriceVsValueCard.tsx @@ -0,0 +1,90 @@ +import type { TickerOverview, ValuationResponse } from "@/types/api"; +import { fmtCurrency, fmtPct } from "@/lib/format"; + +type ValState = "idle" | "loading" | "ready" | "error"; + +type Props = { + overview: TickerOverview; + valuation: ValuationResponse | null; + valState: ValState; +}; + +function barPositions(price: number, iv: number): { + leftPct: number; + widthPct: number; + pricePct: number; + ivPct: number; + isPremium: boolean; +} { + const padding = 0.1; + const min = Math.min(price, iv) * (1 - padding); + const max = Math.max(price, iv) * (1 + padding); + const range = max - min; + if (range === 0) return { leftPct: 50, widthPct: 0, pricePct: 50, ivPct: 50, isPremium: false }; + + const pricePct = ((price - min) / range) * 100; + const ivPct = ((iv - min) / range) * 100; + const leftPct = Math.min(pricePct, ivPct); + const widthPct = Math.abs(pricePct - ivPct); + + return { leftPct, widthPct, pricePct, ivPct, isPremium: price > iv }; +} + +export function PriceVsValueCard({ overview, valuation, valState }: Props) { + if (valState === "idle" || valState === "loading" || valState === "error") return null; + + const dcf = valuation?.dcf; + if (!dcf?.available || dcf.intrinsic_value_per_share == null) return null; + + const price = overview.quote.price ?? overview.range_52w.price; + if (price == null) return null; + + const iv = dcf.intrinsic_value_per_share; + const { leftPct, widthPct, pricePct, ivPct, isPremium } = barPositions(price, iv); + const pct = ((price - iv) / iv) * 100; + const pctLabel = isPremium + ? `Trading at ${pct.toFixed(1)}% premium to DCF` + : `Trading at ${Math.abs(pct).toFixed(1)}% discount to DCF`; + + const [leftLabel, rightLabel] = isPremium + ? [`IV ${fmtCurrency(iv)}`, `Price ${fmtCurrency(price)}`] + : [`Price ${fmtCurrency(price)}`, `IV ${fmtCurrency(iv)}`]; + + return ( +
+
+
+
Price vs Value
+

Intrinsic Value

+
+
+
+ {fmtCurrency(price)} + + {isPremium ? "↑" : "↓"} IV {fmtCurrency(iv)} + +
+
+
+
+
+
+
+ {leftLabel} + {rightLabel} +
+

+ {pctLabel} · WACC {fmtPct(dcf.wacc)} · {dcf.projection_years}yr + {dcf.growth_rate_used != null ? ` · growth ${fmtPct(dcf.growth_rate_used)}` : ""} +

+
+ ); +} -- cgit v1.3-2-g0d8e