diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-19 00:45:57 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-19 00:45:57 -0700 |
| commit | 5fd350345a6f6caceb3375d3736a9a9ef7291257 (patch) | |
| tree | 6a46f7a35d1223c180b7d801859babfa182e413d /frontend/components/prism/PriceVsValueCard.tsx | |
| parent | 927a77d68b138778690d380fe7931cc37ce06c9e (diff) | |
feat: add PriceVsValueCard to overview left column (DCF compass)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend/components/prism/PriceVsValueCard.tsx')
| -rw-r--r-- | frontend/components/prism/PriceVsValueCard.tsx | 90 |
1 files changed, 90 insertions, 0 deletions
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 ( + <section className="psm-card"> + <div className="psm-card-head"> + <div> + <div className="psm-eyebrow">Price vs Value</div> + <h2 className="psm-card-title">Intrinsic Value</h2> + </div> + </div> + <div className="psm-pvv-figures"> + <span className="psm-pvv-current">{fmtCurrency(price)}</span> + <span className={`psm-pvv-iv ${isPremium ? "negative" : "positive"}`}> + {isPremium ? "↑" : "↓"} IV {fmtCurrency(iv)} + </span> + </div> + <div className="psm-pvv-rail"> + <div + className="psm-pvv-fill" + style={{ + left: `${leftPct}%`, + width: `${widthPct}%`, + background: isPremium ? "var(--negative)" : "var(--positive)", + opacity: 0.5, + }} + /> + <div className="psm-pvv-tick price" style={{ left: `${pricePct}%` }} /> + <div className="psm-pvv-tick iv" style={{ left: `${ivPct}%` }} /> + </div> + <div className="psm-pvv-rail-labels"> + <span>{leftLabel}</span> + <span>{rightLabel}</span> + </div> + <p className="psm-pvv-meta"> + {pctLabel} · WACC {fmtPct(dcf.wacc)} · {dcf.projection_years}yr + {dcf.growth_rate_used != null ? ` · growth ${fmtPct(dcf.growth_rate_used)}` : ""} + </p> + </section> + ); +} |
