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/components/prism/PriceVsValueCard.tsx | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 frontend/components/prism/PriceVsValueCard.tsx (limited to 'frontend/components') 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