summaryrefslogtreecommitdiff
path: root/frontend/components/prism
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-19 00:45:57 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-19 00:45:57 -0700
commit5fd350345a6f6caceb3375d3736a9a9ef7291257 (patch)
tree6a46f7a35d1223c180b7d801859babfa182e413d /frontend/components/prism
parent927a77d68b138778690d380fe7931cc37ce06c9e (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')
-rw-r--r--frontend/components/prism/PriceVsValueCard.tsx90
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>
+ );
+}