summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/page.tsx6
-rw-r--r--frontend/app/prism-shell.css69
-rw-r--r--frontend/components/prism/PriceVsValueCard.tsx90
3 files changed, 165 insertions, 0 deletions
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() {
<ChartCard symbol={overview.profile.symbol} period={period} points={history} chartState={chartState} chartError={chartError} onChangePeriod={setPeriod} />
<VolumeCard overview={overview} />
<SignalCard overview={overview} />
+ <PriceVsValueCard overview={overview} valuation={valuation} valState={valState} />
</div>
<div className="psm-column">
<ProfileCard overview={overview} />
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 (
+ <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>
+ );
+}