summaryrefslogtreecommitdiff
path: root/frontend/components/prism
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-20 00:22:32 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-20 00:22:32 -0700
commit25360aacb8aab46e7e579707eb9704759af9536d (patch)
tree028f654f97dc23c7bc088bc3b625185f4fb71287 /frontend/components/prism
parent68b4f52829cdb2d6951faf8037fb002083ebd0a5 (diff)
feat: implement options tab with Black-Scholes pricer and vol surface
Adds a fully interactive options tab: Terminal view (3-column Bloomberg- style with pricer, chain, smile/term-structure/greek curves) and Surface view (polar smile dial + IV heatmap). Uses synthetic vol surface until a live yfinance chain endpoint is wired up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend/components/prism')
-rw-r--r--frontend/components/prism/options/OptionsChain.tsx157
-rw-r--r--frontend/components/prism/options/OptionsCharts.tsx335
-rw-r--r--frontend/components/prism/options/OptionsPage.tsx292
-rw-r--r--frontend/components/prism/options/OptionsPricer.tsx274
-rw-r--r--frontend/components/prism/options/OptionsSurface.tsx272
-rw-r--r--frontend/components/prism/options/types.ts47
6 files changed, 1377 insertions, 0 deletions
diff --git a/frontend/components/prism/options/OptionsChain.tsx b/frontend/components/prism/options/OptionsChain.tsx
new file mode 100644
index 0000000..be6b518
--- /dev/null
+++ b/frontend/components/prism/options/OptionsChain.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { useMemo } from "react";
+import { bsPrice, bsGreeks, bsSynthIV } from "@/lib/blackScholes";
+import type { ChainRow, OptionType } from "./types";
+
+function hashRand(seed: number): number {
+ const x = Math.sin(seed) * 10000;
+ return x - Math.floor(x);
+}
+
+export function buildChain(S: number, T: number, r: number, q: number, atmSigma: number, expirySeed: number): ChainRow[] {
+ const rawMin = Math.round(S * 0.85 / 5) * 5;
+ const rawMax = Math.round(S * 1.20 / 5) * 5;
+ const rows: ChainRow[] = [];
+
+ for (let K = rawMin; K <= rawMax; K += 5) {
+ const iv = bsSynthIV(S, K, T, atmSigma);
+ const cMid = bsPrice(S, K, T, r, q, iv, 'C');
+ const pMid = bsPrice(S, K, T, r, q, iv, 'P');
+ const cG = bsGreeks(S, K, T, r, q, iv, 'C');
+ const pG = bsGreeks(S, K, T, r, q, iv, 'P');
+ const seed = expirySeed * 1000 + K;
+ const cOi = Math.round(hashRand(seed + 1) * 50000 + 1000);
+ const pOi = Math.round(hashRand(seed + 2) * 40000 + 800);
+ const cVol = Math.round(hashRand(seed + 3) * 20000 + 200);
+ const pVol = Math.round(hashRand(seed + 4) * 15000 + 150);
+ rows.push({
+ K,
+ cMid, pMid,
+ cIv: iv, pIv: iv,
+ cDelta: cG.delta, pDelta: pG.delta,
+ cOi, pOi,
+ cVol, pVol,
+ });
+ }
+ return rows;
+}
+
+export function findAtmStrike(strikes: number[], S: number): number {
+ return strikes.reduce((prev, curr) => Math.abs(curr - S) < Math.abs(prev - S) ? curr : prev, strikes[0]);
+}
+
+function fmtOi(n: number): string {
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
+ return n.toString();
+}
+
+interface ChainTableProps {
+ rows: ChainRow[];
+ atmStrike: number;
+ selectedK: number;
+ type: OptionType;
+ onPick: (K: number) => void;
+ compact?: boolean;
+}
+
+export function ChainTable({ rows, atmStrike, selectedK, type, onPick, compact = false }: ChainTableProps) {
+ return (
+ <div className={`opt-chain-wrap${compact ? ' compact' : ''}`}>
+ <div className="opt-chain-head">
+ <h3>Option Chain</h3>
+ <span className="sub">{compact ? 'Compact' : 'Full'} · {rows.length} strikes</span>
+ </div>
+ <div className="opt-chain-scroll">
+ <table className="opt-chain">
+ <thead>
+ <tr>
+ {!compact && <th className="group side-c" colSpan={2} style={{ textAlign: 'center' }}>— calls —</th>}
+ {compact && <th className="group side-c" colSpan={3} style={{ textAlign: 'center' }}>— calls —</th>}
+ <th className="group k" style={{ textAlign: 'center' }}>strike</th>
+ {!compact && <th className="group side-p" colSpan={2} style={{ textAlign: 'center' }}>— puts —</th>}
+ {compact && <th className="group side-p" colSpan={3} style={{ textAlign: 'center' }}>— puts —</th>}
+ </tr>
+ {!compact ? (
+ <tr>
+ <th className="side-c">OI</th>
+ <th className="side-c">IV</th>
+ <th className="side-c">last</th>
+ <th className="side-c">&Delta;</th>
+ <th className="k">K</th>
+ <th className="side-p">&Delta;</th>
+ <th className="side-p">last</th>
+ <th className="side-p">IV</th>
+ <th className="side-p">OI</th>
+ </tr>
+ ) : (
+ <tr>
+ <th className="side-c">IV</th>
+ <th className="side-c">last</th>
+ <th className="side-c">&Delta;</th>
+ <th className="k">K</th>
+ <th className="side-p">&Delta;</th>
+ <th className="side-p">last</th>
+ <th className="side-p">IV</th>
+ </tr>
+ )}
+ </thead>
+ <tbody>
+ {rows.map((row) => {
+ const isAtm = row.K === atmStrike;
+ const isSel = row.K === selectedK;
+ const cItm = type === 'C' ? row.K < atmStrike : row.K > atmStrike;
+ const pItm = type === 'P' ? row.K < atmStrike : row.K > atmStrike;
+ return (
+ <tr
+ key={row.K}
+ className={`${isAtm ? 'atm' : ''} ${isSel ? 'selected' : ''}`}
+ onClick={() => onPick(row.K)}
+ >
+ {!compact && <td className="dim">{fmtOi(row.cOi)}</td>}
+ <td className="iv">{(row.cIv * 100).toFixed(1)}%</td>
+ <td className={cItm ? 'itm' : 'otm'}>{row.cMid.toFixed(2)}</td>
+ <td className={cItm ? 'itm' : 'otm'}>{row.cDelta.toFixed(2)}</td>
+ <td className="k">{row.K.toFixed(0)}</td>
+ <td className={pItm ? 'itm' : 'otm'}>{row.pDelta.toFixed(2)}</td>
+ <td className={pItm ? 'itm' : 'otm'}>{row.pMid.toFixed(2)}</td>
+ <td className="iv">{(row.pIv * 100).toFixed(1)}%</td>
+ {!compact && <td className="dim">{fmtOi(row.pOi)}</td>}
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+}
+
+interface OptionsChainProps {
+ S: number;
+ T: number;
+ r: number;
+ q: number;
+ atmSigma: number;
+ expirySeed: number;
+ selectedK: number;
+ type: OptionType;
+ onPick: (K: number) => void;
+ compact?: boolean;
+}
+
+export function OptionsChain({ S, T, r, q, atmSigma, expirySeed, selectedK, type, onPick, compact }: OptionsChainProps) {
+ const rows = useMemo(() => buildChain(S, T, r, q, atmSigma, expirySeed), [S, T, r, q, atmSigma, expirySeed]);
+ const atmStrike = useMemo(() => findAtmStrike(rows.map(rw => rw.K), S), [rows, S]);
+
+ return (
+ <ChainTable
+ rows={rows}
+ atmStrike={atmStrike}
+ selectedK={selectedK}
+ type={type}
+ onPick={onPick}
+ compact={compact}
+ />
+ );
+}
diff --git a/frontend/components/prism/options/OptionsCharts.tsx b/frontend/components/prism/options/OptionsCharts.tsx
new file mode 100644
index 0000000..337035f
--- /dev/null
+++ b/frontend/components/prism/options/OptionsCharts.tsx
@@ -0,0 +1,335 @@
+"use client";
+
+import { useMemo } from "react";
+import { bsPrice, bsGreeks, bsSynthIV } from "@/lib/blackScholes";
+import type { Expiry, OptionType } from "./types";
+
+function smoothPath(pts: [number, number][]): string {
+ if (pts.length < 2) return "";
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
+ for (let i = 1; i < pts.length; i++) {
+ const [x0, y0] = pts[i - 1];
+ const [x1, y1] = pts[i];
+ const cx = (x0 + x1) / 2;
+ d += ` C ${cx} ${y0}, ${cx} ${y1}, ${x1} ${y1}`;
+ }
+ return d;
+}
+
+function linePath(pts: [number, number][]): string {
+ if (!pts.length) return "";
+ return pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
+}
+
+interface SmileChartProps {
+ S: number;
+ T: number;
+ r: number;
+ q: number;
+ atmSigma: number;
+ K: number;
+ type: OptionType;
+ expiryLabel: string;
+ allExpiries: Expiry[];
+}
+
+export function SmileChart({ S, T, atmSigma, K, allExpiries }: SmileChartProps) {
+ const W = 720, H = 240;
+ const pad = { l: 40, r: 20, t: 20, b: 30 };
+ const iW = W - pad.l - pad.r;
+ const iH = H - pad.t - pad.b;
+
+ const kMin = S * 0.80, kMax = S * 1.22;
+ const nPts = 60;
+
+ const primaryPts = useMemo((): [number, number][] => {
+ const pts: [number, number][] = [];
+ for (let i = 0; i <= nPts; i++) {
+ const strike = kMin + (kMax - kMin) * i / nPts;
+ const iv = bsSynthIV(S, strike, T, atmSigma);
+ pts.push([strike, iv]);
+ }
+ return pts;
+ }, [S, T, atmSigma, kMin, kMax]);
+
+ const otherCurves = useMemo(() => {
+ return allExpiries
+ .filter(e => Math.abs(e.T - T) > 0.001)
+ .slice(0, 3)
+ .map(e => {
+ const pts: [number, number][] = [];
+ for (let i = 0; i <= nPts; i++) {
+ const strike = kMin + (kMax - kMin) * i / nPts;
+ const iv = bsSynthIV(S, strike, e.T, atmSigma);
+ pts.push([strike, iv]);
+ }
+ return pts;
+ });
+ }, [S, T, atmSigma, allExpiries, kMin, kMax]);
+
+ const allIvs = primaryPts.map(p => p[1]);
+ const ivMin = Math.max(0, Math.min(...allIvs) - 0.02);
+ const ivMax = Math.max(...allIvs) + 0.04;
+
+ function toX(strike: number) {
+ return pad.l + ((strike - kMin) / (kMax - kMin)) * iW;
+ }
+ function toY(iv: number) {
+ return pad.t + iH - ((iv - ivMin) / (ivMax - ivMin)) * iH;
+ }
+
+ const svgPrimary = smoothPath(primaryPts.map(([k, iv]) => [toX(k), toY(iv)] as [number, number]));
+ const fillPath = svgPrimary + ` L ${toX(kMax)} ${pad.t + iH} L ${toX(kMin)} ${pad.t + iH} Z`;
+ const kX = toX(K);
+ const kIv = bsSynthIV(S, K, T, atmSigma);
+ const kY = toY(kIv);
+ const atmX = toX(S);
+
+ const yTicks = 4;
+ const yLabels: number[] = [];
+ for (let i = 0; i <= yTicks; i++) {
+ yLabels.push(ivMin + (ivMax - ivMin) * i / yTicks);
+ }
+
+ return (
+ <div className="opt-chart-card opt-svg">
+ <div className="head">
+ <h4>Vol Smile</h4>
+ <span className="eyebrow">Implied Volatility vs. Strike</span>
+ </div>
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }}>
+ {yLabels.map(iv => (
+ <g key={iv}>
+ <line className="grid" x1={pad.l} y1={toY(iv)} x2={pad.l + iW} y2={toY(iv)} />
+ <text x={pad.l - 4} y={toY(iv) + 4} textAnchor="end" className="opt-svg">{(iv * 100).toFixed(0)}%</text>
+ </g>
+ ))}
+ <line className="axis" x1={pad.l} y1={pad.t} x2={pad.l} y2={pad.t + iH} />
+ <line className="axis" x1={pad.l} y1={pad.t + iH} x2={pad.l + iW} y2={pad.t + iH} />
+ {otherCurves.map((pts, idx) => (
+ <path
+ key={idx}
+ className={idx === 0 ? 'curve fade1' : 'curve fade2'}
+ d={smoothPath(pts.map(([k, iv]) => [toX(k), toY(iv)] as [number, number]))}
+ />
+ ))}
+ <path className="curve fill" d={fillPath} />
+ <path className="curve accent" d={svgPrimary} />
+ <line className="spoke" x1={atmX} y1={pad.t} x2={atmX} y2={pad.t + iH} strokeDasharray="3 3" stroke="var(--brass)" strokeWidth="1" />
+ <text x={atmX} y={pad.t - 6} textAnchor="middle" className="atm">ATM</text>
+ <line className="crosshair" x1={kX} y1={pad.t} x2={kX} y2={pad.t + iH} />
+ <circle cx={kX} cy={kY} r={4} className="marker" />
+ <text x={kX + 6} y={kY - 6} className="brass">{(kIv * 100).toFixed(1)}%</text>
+ </svg>
+ </div>
+ );
+}
+
+interface TermStructureProps {
+ S: number;
+ r: number;
+ q: number;
+ atmSigma: number;
+ expiries: Expiry[];
+ selectedT: number;
+ onPickT: (T: number) => void;
+}
+
+export function TermStructure({ S, atmSigma, expiries, selectedT, onPickT }: TermStructureProps) {
+ const W = 360, H = 150;
+ const pad = { l: 36, r: 16, t: 16, b: 30 };
+ const iW = W - pad.l - pad.r;
+ const iH = H - pad.t - pad.b;
+
+ const pts = useMemo(() => expiries.map(e => {
+ const iv = bsSynthIV(S, S, e.T, atmSigma);
+ return { T: e.T, sqrtT: Math.sqrt(e.T), iv, label: e.label, dte: e.dte };
+ }), [S, atmSigma, expiries]);
+
+ const xs = pts.map(p => p.sqrtT);
+ const ivs = pts.map(p => p.iv);
+ const xMin = Math.min(...xs), xMax = Math.max(...xs);
+ const ivMin = Math.min(...ivs) - 0.01, ivMax = Math.max(...ivs) + 0.01;
+
+ function toX(sqrtT: number) {
+ return pad.l + ((sqrtT - xMin) / (xMax - xMin + 0.001)) * iW;
+ }
+ function toY(iv: number) {
+ return pad.t + iH - ((iv - ivMin) / (ivMax - ivMin + 0.001)) * iH;
+ }
+
+ const linePts: [number, number][] = pts.map(p => [toX(p.sqrtT), toY(p.iv)]);
+
+ return (
+ <div className="opt-chart-card opt-svg">
+ <div className="head">
+ <h4>Term Structure</h4>
+ <span className="eyebrow">ATM IV vs. Expiry</span>
+ </div>
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }}>
+ <line className="axis" x1={pad.l} y1={pad.t} x2={pad.l} y2={pad.t + iH} />
+ <line className="axis" x1={pad.l} y1={pad.t + iH} x2={pad.l + iW} y2={pad.t + iH} />
+ <path className="curve fade1" d={linePath(linePts)} />
+ {pts.map((p) => {
+ const cx = toX(p.sqrtT);
+ const cy = toY(p.iv);
+ const isSel = Math.abs(p.T - selectedT) < 0.001;
+ return (
+ <g key={p.label} style={{ cursor: 'pointer' }} onClick={() => onPickT(p.T)}>
+ <circle
+ cx={cx} cy={cy} r={isSel ? 5 : 4}
+ fill={isSel ? 'var(--brass-bright)' : 'var(--fg-3)'}
+ stroke="var(--ink-0)" strokeWidth="1.5"
+ />
+ <text x={cx} y={pad.t + iH + 14} textAnchor="middle" fontSize="9">{p.label}</text>
+ </g>
+ );
+ })}
+ </svg>
+ </div>
+ );
+}
+
+interface GreekMiniProps {
+ name: string;
+ glyph: string;
+ kind: 'delta' | 'gamma' | 'vega' | 'theta';
+ S: number;
+ K: number;
+ T: number;
+ r: number;
+ q: number;
+ sigma: number;
+ type: OptionType;
+}
+
+export function GreekMini({ name, glyph, kind, S, K, T, r, q, sigma, type }: GreekMiniProps) {
+ const W = 160, H = 90;
+ const pad = { l: 8, r: 8, t: 8, b: 8 };
+ const iW = W - pad.l - pad.r;
+ const iH = H - pad.t - pad.b;
+
+ const nPts = 40;
+
+ const pts = useMemo((): [number, number][] => {
+ const sMin = S * 0.75, sMax = S * 1.25;
+ const result: [number, number][] = [];
+ for (let i = 0; i <= nPts; i++) {
+ const spot = sMin + (sMax - sMin) * i / nPts;
+ const g = bsGreeks(spot, K, T, r, q, sigma, type);
+ result.push([spot, g[kind]]);
+ }
+ return result;
+ }, [S, K, T, r, q, sigma, type, kind]);
+
+ const ys = pts.map(p => p[1]);
+ const yMin = Math.min(...ys), yMax = Math.max(...ys);
+ const xMin = pts[0][0], xMax = pts[pts.length - 1][0];
+
+ function toX(v: number) { return pad.l + ((v - xMin) / (xMax - xMin + 0.001)) * iW; }
+ function toY(v: number) { return pad.t + iH - ((v - yMin) / (yMax - yMin + 0.001)) * iH; }
+
+ const svgPts: [number, number][] = pts.map(([x, y]) => [toX(x), toY(y)]);
+ const curValG = bsGreeks(S, K, T, r, q, sigma, type);
+ const curVal = curValG[kind];
+
+ const dotX = toX(S);
+ const dotY = toY(curVal);
+
+ return (
+ <div className="opt-greek-mini">
+ <div className="lbl">
+ <span>{glyph} {name}</span>
+ <span className="v">{curVal.toFixed(4)}</span>
+ </div>
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }} className="opt-svg">
+ <path className="curve accent" d={smoothPath(svgPts)} />
+ <circle cx={dotX} cy={dotY} r={3} className="marker" />
+ </svg>
+ </div>
+ );
+}
+
+interface PayoffProps {
+ S: number;
+ K: number;
+ T: number;
+ r: number;
+ q: number;
+ sigma: number;
+ type: OptionType;
+ mid?: number;
+}
+
+export function Payoff({ S, K, T, r, q, sigma, type, mid }: PayoffProps) {
+ const W = 720, H = 140;
+ const pad = { l: 44, r: 16, t: 16, b: 28 };
+ const iW = W - pad.l - pad.r;
+ const iH = H - pad.t - pad.b;
+
+ const premium = mid ?? bsPrice(S, K, T, r, q, sigma, type);
+
+ const sMin = S * 0.70, sMax = S * 1.30;
+ const nPts = 80;
+
+ const pnlPts = useMemo((): [number, number][] => {
+ const pts: [number, number][] = [];
+ for (let i = 0; i <= nPts; i++) {
+ const spot = sMin + (sMax - sMin) * i / nPts;
+ const payoff = type === 'C' ? Math.max(spot - K, 0) : Math.max(K - spot, 0);
+ pts.push([spot, payoff - premium]);
+ }
+ return pts;
+ }, [K, type, premium, sMin, sMax]);
+
+ const ys = pnlPts.map(p => p[1]);
+ const yMin = Math.min(...ys, -premium * 1.1);
+ const yMax = Math.max(...ys, premium * 1.1);
+
+ function toX(v: number) { return pad.l + ((v - sMin) / (sMax - sMin)) * iW; }
+ function toY(v: number) { return pad.t + iH - ((v - yMin) / (yMax - yMin + 0.001)) * iH; }
+
+ const zeroY = toY(0);
+ const gainPts = pnlPts.filter(p => p[1] >= 0);
+ const lossPts = pnlPts.filter(p => p[1] <= 0);
+
+ const allSvg = linePath(pnlPts.map(([x, y]) => [toX(x), toY(y)] as [number, number]));
+ const gainArea = gainPts.length > 1
+ ? linePath(gainPts.map(([x, y]) => [toX(x), toY(y)] as [number, number])) +
+ ` L ${toX(gainPts[gainPts.length - 1][0])} ${zeroY} L ${toX(gainPts[0][0])} ${zeroY} Z`
+ : '';
+ const lossArea = lossPts.length > 1
+ ? linePath(lossPts.map(([x, y]) => [toX(x), toY(y)] as [number, number])) +
+ ` L ${toX(lossPts[lossPts.length - 1][0])} ${zeroY} L ${toX(lossPts[0][0])} ${zeroY} Z`
+ : '';
+
+ const beSpot = type === 'C' ? K + premium : K - premium;
+ const beX = toX(beSpot);
+ const spotX = toX(S);
+ const kX = toX(K);
+
+ return (
+ <div className="opt-payoff opt-svg">
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }}>
+ <line className="grid" x1={pad.l} y1={zeroY} x2={pad.l + iW} y2={zeroY} />
+ {gainArea && (
+ <path d={gainArea} fill="var(--positive)" opacity="0.18" />
+ )}
+ {lossArea && (
+ <path d={lossArea} fill="var(--negative)" opacity="0.18" />
+ )}
+ <path className="curve accent" d={allSvg} />
+ <line className="marker-line" x1={kX} y1={pad.t} x2={kX} y2={pad.t + iH} />
+ <text x={kX} y={pad.t + iH + 16} textAnchor="middle">K={K.toFixed(0)}</text>
+ <line x1={spotX} y1={pad.t} x2={spotX} y2={pad.t + iH} stroke="var(--fg-3)" strokeWidth="1" strokeDasharray="2 3" />
+ <text x={spotX} y={pad.t - 4} textAnchor="middle" className="brass">S</text>
+ {beSpot >= sMin && beSpot <= sMax && (
+ <>
+ <line className="marker-line" x1={beX} y1={pad.t} x2={beX} y2={pad.t + iH} />
+ <text x={beX} y={pad.t + 14} textAnchor="middle" className="brass">BE</text>
+ </>
+ )}
+ </svg>
+ </div>
+ );
+}
diff --git a/frontend/components/prism/options/OptionsPage.tsx b/frontend/components/prism/options/OptionsPage.tsx
new file mode 100644
index 0000000..7fcf5c3
--- /dev/null
+++ b/frontend/components/prism/options/OptionsPage.tsx
@@ -0,0 +1,292 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { bsSynthIV } from "@/lib/blackScholes";
+import type { TickerOverview } from "@/types/api";
+import type { OptionInputs } from "./types";
+import { EXPIRIES } from "./types";
+import { Pricer, SolvePanel } from "./OptionsPricer";
+import { OptionsChain } from "./OptionsChain";
+import { SmileChart, TermStructure, GreekMini, Payoff } from "./OptionsCharts";
+import { PolarSmile, IvHeatmap } from "./OptionsSurface";
+
+interface OptionsPageProps {
+ overview: TickerOverview | null;
+ ticker: string;
+}
+
+export function OptionsPage({ overview, ticker }: OptionsPageProps) {
+ const [view, setView] = useState<'terminal' | 'surface'>('terminal');
+ const [expiryIdx, setExpiryIdx] = useState(1);
+
+ const spot = overview?.quote.price ?? 0;
+ const chgAbs = overview?.quote.change ?? 0;
+ const chgPct = overview?.quote.change_pct ?? 0;
+ const r = 0.0425;
+ const q = overview?.ratios.dividend_yield_ttm ?? 0;
+ const atmSigma30 = 0.243;
+ const sym = overview?.profile.symbol ?? ticker;
+ const name = overview?.profile.name ?? "";
+
+ const expiry = EXPIRIES[expiryIdx];
+
+ const [inputs, setInputs] = useState<OptionInputs>(() => ({
+ S: spot || 100,
+ K: spot ? Math.round(spot / 5) * 5 : 100,
+ T: expiry.T,
+ r,
+ q,
+ sigma: atmSigma30,
+ type: 'C',
+ }));
+
+ useEffect(() => {
+ if (spot > 0) {
+ setInputs(prev => ({
+ ...prev,
+ S: spot,
+ K: Math.round(spot / 5) * 5,
+ r,
+ q,
+ }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [spot]);
+
+ function patchInputs(partial: Partial<OptionInputs>) {
+ setInputs(prev => ({ ...prev, ...partial }));
+ }
+
+ function selectExpiry(idx: number) {
+ setExpiryIdx(idx);
+ setInputs(prev => ({ ...prev, T: EXPIRIES[idx].T }));
+ }
+
+ const atmIv = bsSynthIV(inputs.S, inputs.S, expiry.T, atmSigma30);
+ const d25C = 0.243 + 0.012;
+ const d25P = 0.243 + 0.012;
+ const rr25 = (d25C - d25P) * 100;
+ const bf25 = ((d25C + d25P) / 2 - atmIv) * 100;
+ const pcRatio = 0.87;
+
+ if (!overview || spot === 0) {
+ return (
+ <section className="psm-state-panel">
+ <span className="psm-status-chip">Options</span>
+ <h1>Select a ticker to view options</h1>
+ <p>Load a ticker from the search bar to access the Black-Scholes pricer, option chain, vol surface, and greek visualizations.</p>
+ </section>
+ );
+ }
+
+ const terminalView = (
+ <div className="opt-grid">
+ <div className="opt-col">
+ <Pricer inputs={inputs} spot={spot} onChange={patchInputs} />
+ <SolvePanel inputs={inputs} />
+ </div>
+ <div className="opt-col">
+ <OptionsChain
+ S={inputs.S}
+ T={expiry.T}
+ r={r}
+ q={q}
+ atmSigma={atmSigma30}
+ expirySeed={expiryIdx}
+ selectedK={inputs.K}
+ type={inputs.type}
+ onPick={K => patchInputs({ K })}
+ />
+ <Payoff
+ S={inputs.S}
+ K={inputs.K}
+ T={inputs.T}
+ r={inputs.r}
+ q={inputs.q}
+ sigma={inputs.sigma}
+ type={inputs.type}
+ />
+ </div>
+ <div className="opt-col">
+ <SmileChart
+ S={inputs.S}
+ T={expiry.T}
+ r={r}
+ q={q}
+ atmSigma={atmSigma30}
+ K={inputs.K}
+ type={inputs.type}
+ expiryLabel={expiry.label}
+ allExpiries={EXPIRIES}
+ />
+ <TermStructure
+ S={inputs.S}
+ r={r}
+ q={q}
+ atmSigma={atmSigma30}
+ expiries={EXPIRIES}
+ selectedT={expiry.T}
+ onPickT={T => {
+ const idx = EXPIRIES.findIndex(e => Math.abs(e.T - T) < 0.001);
+ if (idx >= 0) selectExpiry(idx);
+ }}
+ />
+ <div className="opt-greek-multi">
+ <GreekMini name="Delta" glyph="&#916;" kind="delta" S={inputs.S} K={inputs.K} T={inputs.T} r={inputs.r} q={inputs.q} sigma={inputs.sigma} type={inputs.type} />
+ <GreekMini name="Gamma" glyph="&#915;" kind="gamma" S={inputs.S} K={inputs.K} T={inputs.T} r={inputs.r} q={inputs.q} sigma={inputs.sigma} type={inputs.type} />
+ <GreekMini name="Vega" glyph="&#957;" kind="vega" S={inputs.S} K={inputs.K} T={inputs.T} r={inputs.r} q={inputs.q} sigma={inputs.sigma} type={inputs.type} />
+ <GreekMini name="Theta" glyph="&#920;" kind="theta" S={inputs.S} K={inputs.K} T={inputs.T} r={inputs.r} q={inputs.q} sigma={inputs.sigma} type={inputs.type} />
+ </div>
+ </div>
+ </div>
+ );
+
+ const surfaceView = (
+ <div className="opt-grid">
+ <div className="opt-col">
+ <Pricer inputs={inputs} spot={spot} onChange={patchInputs} />
+ <SolvePanel inputs={inputs} />
+ </div>
+ <div className="opt-col">
+ <PolarSmile
+ S={inputs.S}
+ r={r}
+ q={q}
+ atmSigma={atmSigma30}
+ K={inputs.K}
+ T={expiry.T}
+ type={inputs.type}
+ expiries={EXPIRIES}
+ selectedExpiryIdx={expiryIdx}
+ />
+ <IvHeatmap
+ S={inputs.S}
+ atmSigma={atmSigma30}
+ expiries={EXPIRIES}
+ selectedExpiryIdx={expiryIdx}
+ selectedK={inputs.K}
+ onSelect={(eIdx, K) => {
+ selectExpiry(eIdx);
+ patchInputs({ K });
+ }}
+ />
+ </div>
+ <div className="opt-col">
+ <Payoff
+ S={inputs.S}
+ K={inputs.K}
+ T={inputs.T}
+ r={inputs.r}
+ q={inputs.q}
+ sigma={inputs.sigma}
+ type={inputs.type}
+ />
+ <OptionsChain
+ S={inputs.S}
+ T={expiry.T}
+ r={r}
+ q={q}
+ atmSigma={atmSigma30}
+ expirySeed={expiryIdx}
+ selectedK={inputs.K}
+ type={inputs.type}
+ onPick={K => patchInputs({ K })}
+ compact
+ />
+ </div>
+ </div>
+ );
+
+ return (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)' }}>
+ <div className="opt-header">
+ <div className="ticker">
+ <span className="tab-eyebrow">Options · Black&ndash;Scholes</span>
+ <div className="ticker-row">
+ <span className="sym">{sym}</span>
+ <span className="name">{name}</span>
+ <div className="px-block">
+ <span className="px">${spot.toFixed(2)}</span>
+ <span className={`chg ${chgPct >= 0 ? 'pos' : 'neg'}`}>
+ {chgPct >= 0 ? '+' : ''}{chgAbs.toFixed(2)} &middot; {chgPct >= 0 ? '+' : ''}{chgPct.toFixed(2)}%
+ </span>
+ </div>
+ </div>
+ </div>
+ <div className="opt-view" role="tablist">
+ <button
+ type="button"
+ className={view === 'terminal' ? 'active' : ''}
+ onClick={() => setView('terminal')}
+ >
+ <span className="glyph">&#9638;</span> Terminal
+ </button>
+ <button
+ type="button"
+ className={view === 'surface' ? 'active' : ''}
+ onClick={() => setView('surface')}
+ >
+ <span className="glyph">&#9908;</span> Surface
+ </button>
+ </div>
+ </div>
+
+ <div className="opt-expiry-bar">
+ <span className="lbl">Expiry</span>
+ <div className="opt-expiries">
+ {EXPIRIES.map((e, idx) => (
+ <button
+ key={e.label}
+ type="button"
+ className={`opt-exp-chip${idx === expiryIdx ? ' active' : ''}`}
+ onClick={() => selectExpiry(idx)}
+ >
+ {e.label}
+ <span className="dte">{e.dte}d</span>
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <div className="opt-strip">
+ <div>
+ <span className="k">ATM IV</span>
+ <span className="v accent">{(atmIv * 100).toFixed(1)}%</span>
+ <span className="s">{expiry.label}</span>
+ </div>
+ <div>
+ <span className="k">25&Delta; RR</span>
+ <span className={`v ${rr25 >= 0 ? 'gain' : 'loss'}`}>{rr25 >= 0 ? '+' : ''}{rr25.toFixed(2)}v</span>
+ <span className="s">call skew</span>
+ </div>
+ <div>
+ <span className="k">25&Delta; BF</span>
+ <span className="v">{bf25 >= 0 ? '+' : ''}{bf25.toFixed(2)}v</span>
+ <span className="s">smile</span>
+ </div>
+ <div>
+ <span className="k">P/C Ratio</span>
+ <span className="v">{pcRatio.toFixed(2)}</span>
+ <span className="s">put / call OI</span>
+ </div>
+ <div>
+ <span className="k">Rate r</span>
+ <span className="v">{(r * 100).toFixed(2)}%</span>
+ <span className="s">risk-free</span>
+ </div>
+ <div>
+ <span className="k">Div q</span>
+ <span className="v">{(q * 100).toFixed(2)}%</span>
+ <span className="s">yield</span>
+ </div>
+ <div>
+ <span className="k">Contract</span>
+ <span className="v">{inputs.K.toFixed(0)}</span>
+ <span className="s">{inputs.type === 'C' ? 'Call' : 'Put'} · {expiry.label}</span>
+ </div>
+ </div>
+
+ {view === 'terminal' ? terminalView : surfaceView}
+ </div>
+ );
+}
diff --git a/frontend/components/prism/options/OptionsPricer.tsx b/frontend/components/prism/options/OptionsPricer.tsx
new file mode 100644
index 0000000..79abe0b
--- /dev/null
+++ b/frontend/components/prism/options/OptionsPricer.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import { useMemo } from "react";
+import { bsPrice, bsGreeks, bsImpliedVol } from "@/lib/blackScholes";
+import type { OptionInputs } from "./types";
+
+function fmt(n: number, d = 2): string {
+ return isNaN(n) || !isFinite(n) ? "—" : n.toFixed(d);
+}
+
+function fmtPct(n: number, d = 1): string {
+ return isNaN(n) || !isFinite(n) ? "—" : (n * 100).toFixed(d) + "%";
+}
+
+function pctColor(n: number): string {
+ if (isNaN(n) || !isFinite(n)) return "";
+ return n >= 0 ? "pos" : "neg";
+}
+
+interface SliderProps {
+ glyph: string;
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ step: number;
+ unit?: string;
+ displayScale?: number;
+ meta?: string;
+ onReset?: () => void;
+ onChange: (v: number) => void;
+ className?: string;
+}
+
+function Slider({ glyph, value, min, max, step, unit, displayScale = 1, meta, onReset, onChange, className }: SliderProps) {
+ const displayed = value * displayScale;
+ return (
+ <div className={`opt-slide${className ? ' ' + className : ''}`}>
+ <span className="g">{glyph}</span>
+ <input
+ type="range"
+ min={min * displayScale}
+ max={max * displayScale}
+ step={step * displayScale}
+ value={displayed}
+ onChange={e => onChange(parseFloat(e.target.value) / displayScale)}
+ />
+ <div className="val">
+ <input
+ type="number"
+ min={min * displayScale}
+ max={max * displayScale}
+ step={step * displayScale}
+ value={displayed}
+ onChange={e => {
+ const v = parseFloat(e.target.value) / displayScale;
+ if (!isNaN(v)) onChange(Math.max(min, Math.min(max, v)));
+ }}
+ />
+ {unit && <span className="unit">{unit}</span>}
+ </div>
+ {(meta || onReset) && (
+ <div className="meta">
+ {meta && <span>{meta}</span>}
+ {onReset && <button type="button" onClick={onReset}>RESET</button>}
+ </div>
+ )}
+ </div>
+ );
+}
+
+interface PricerProps {
+ inputs: OptionInputs;
+ spot: number;
+ onChange: (partial: Partial<OptionInputs>) => void;
+}
+
+export function Pricer({ inputs, spot, onChange }: PricerProps) {
+ const { S, K, T, r, q, sigma, type } = inputs;
+
+ const fair = useMemo(() => bsPrice(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]);
+ const g = useMemo(() => bsGreeks(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]);
+
+ const mid = bsPrice(S, K, T, r, q, bsImpliedVol(S, K, T, r, q, fair, type), type);
+ const iv = bsImpliedVol(S, K, T, r, q, fair, type);
+ const ivPct = isNaN(iv) ? 0.5 : Math.min(1, Math.max(0, (iv - 0.04) / (1.5 - 0.04)));
+ const sigmaPct = Math.min(1, Math.max(0, (sigma - 0.04) / (1.5 - 0.04)));
+
+ const dte = Math.round(T * 365);
+
+ return (
+ <div className="opt-pricer">
+ <div className="head">
+ <h3>Pricer</h3>
+ <div className="opt-cp">
+ <button
+ type="button"
+ className={`${type === 'C' ? 'active C' : ''}`}
+ onClick={() => onChange({ type: 'C' })}
+ >CALL</button>
+ <button
+ type="button"
+ className={`${type === 'P' ? 'active P' : ''}`}
+ onClick={() => onChange({ type: 'P' })}
+ >PUT</button>
+ </div>
+ </div>
+
+ <div className="opt-sliders">
+ <Slider
+ glyph="S"
+ label="Spot"
+ value={S}
+ min={spot * 0.5}
+ max={spot * 2}
+ step={0.01}
+ unit="$"
+ meta={`spot ${spot.toFixed(2)}`}
+ onReset={() => onChange({ S: spot })}
+ onChange={v => onChange({ S: v })}
+ />
+ <Slider
+ glyph="K"
+ label="Strike"
+ value={K}
+ min={spot * 0.5}
+ max={spot * 2}
+ step={1}
+ unit="$"
+ onChange={v => onChange({ K: v })}
+ />
+ <Slider
+ glyph="T"
+ label="Days to Expiry"
+ value={T}
+ min={1 / 365}
+ max={644 / 365}
+ step={1 / 365}
+ displayScale={365}
+ unit="d"
+ meta={`${dte}d`}
+ onChange={v => onChange({ T: v })}
+ />
+ <Slider
+ glyph="r"
+ label="Risk-free Rate"
+ value={r}
+ min={0}
+ max={0.15}
+ step={0.0001}
+ displayScale={100}
+ unit="%"
+ onChange={v => onChange({ r: v })}
+ />
+ <Slider
+ glyph="&sigma;"
+ label="Volatility"
+ value={sigma}
+ min={0.01}
+ max={1.5}
+ step={0.001}
+ displayScale={100}
+ unit="%"
+ onChange={v => onChange({ sigma: v })}
+ />
+ <Slider
+ glyph="q"
+ label="Dividend Yield"
+ value={q}
+ min={0}
+ max={0.15}
+ step={0.0001}
+ displayScale={100}
+ unit="%"
+ onChange={v => onChange({ q: v })}
+ />
+ </div>
+
+ <div className="opt-output">
+ <div>
+ <div className="fair-lbl">Fair Value</div>
+ <div className="fair">
+ <span className="cur">$</span>
+ {fmt(fair)}
+ </div>
+ <div className={`delta ${pctColor(g.delta)}`}>
+ &Delta; {fmt(g.delta, 4)}
+ </div>
+ </div>
+ <div>
+ <div className="mid-lbl">Market Mid</div>
+ <div className="mid">${isNaN(mid) ? '—' : mid.toFixed(2)}</div>
+ </div>
+ <div className="iv-bar">
+ <span className="lbl">IV</span>
+ <div className="iv-track">
+ <div className="iv-mkt" style={{ left: `${ivPct * 100}%` }} />
+ <div className="iv-solved" style={{ left: `${sigmaPct * 100}%` }} />
+ </div>
+ <span className="iv-val">{fmtPct(sigma)}</span>
+ </div>
+ </div>
+
+ <div className="opt-greeks">
+ <div className="opt-greek">
+ <span className="g">&Delta;</span>
+ <span className={`v${g.delta < 0 ? ' neg' : ''}`}>{fmt(g.delta, 4)}</span>
+ <span className="n">Delta</span>
+ </div>
+ <div className="opt-greek">
+ <span className="g">&Gamma;</span>
+ <span className="v">{fmt(g.gamma, 4)}</span>
+ <span className="n">Gamma</span>
+ </div>
+ <div className="opt-greek">
+ <span className="g">&nu;</span>
+ <span className="v">{fmt(g.vega, 4)}</span>
+ <span className="n">Vega</span>
+ </div>
+ <div className="opt-greek">
+ <span className="g">&Theta;</span>
+ <span className={`v${g.theta < 0 ? ' neg' : ''}`}>{fmt(g.theta, 4)}</span>
+ <span className="n">Theta/d</span>
+ </div>
+ <div className="opt-greek">
+ <span className="g">&rho;</span>
+ <span className={`v${g.rho < 0 ? ' neg' : ''}`}>{fmt(g.rho, 4)}</span>
+ <span className="n">Rho</span>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+interface SolvePanelProps {
+ inputs: OptionInputs;
+}
+
+export function SolvePanel({ inputs }: SolvePanelProps) {
+ const { S, K, T, r, q, sigma, type } = inputs;
+
+ const fair = useMemo(() => bsPrice(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]);
+ const iv = useMemo(() => bsImpliedVol(S, K, T, r, q, fair, type), [S, K, T, r, q, fair, type]);
+
+ const intrinsic = type === 'C' ? Math.max(S - K, 0) : Math.max(K - S, 0);
+ const extrinsic = Math.max(0, fair - intrinsic);
+ const breakeven = type === 'C' ? K + fair : K - fair;
+
+ return (
+ <div className="opt-solve">
+ <div className="head">Analytics</div>
+ <div className="row">
+ <span className="g">&sigma;</span>
+ <span className="l">Implied Vol</span>
+ <span className="v">{isNaN(iv) ? '—' : (iv * 100).toFixed(2) + '%'}</span>
+ </div>
+ <div className="row">
+ <span className="g">&#x2195;</span>
+ <span className="l">Breakeven</span>
+ <span className="v">${isNaN(breakeven) ? '—' : breakeven.toFixed(2)}</span>
+ </div>
+ <div className="row">
+ <span className="g">&#x25C6;</span>
+ <span className="l">Intrinsic</span>
+ <span className="v">${fmt(intrinsic)}</span>
+ </div>
+ <div className="row">
+ <span className="g">&#x25C7;</span>
+ <span className="l">Extrinsic</span>
+ <span className="v">${fmt(extrinsic)}</span>
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/components/prism/options/OptionsSurface.tsx b/frontend/components/prism/options/OptionsSurface.tsx
new file mode 100644
index 0000000..da55012
--- /dev/null
+++ b/frontend/components/prism/options/OptionsSurface.tsx
@@ -0,0 +1,272 @@
+"use client";
+
+import { useMemo } from "react";
+import { bsPrice, bsSynthIV } from "@/lib/blackScholes";
+import type { Expiry, OptionType } from "./types";
+
+interface PolarSmileProps {
+ S: number;
+ r: number;
+ q: number;
+ atmSigma: number;
+ K: number;
+ T: number;
+ type: OptionType;
+ expiries: Expiry[];
+ selectedExpiryIdx: number;
+}
+
+export function PolarSmile({ S, r, q, atmSigma, K, T, type, expiries, selectedExpiryIdx }: PolarSmileProps) {
+ const SIZE = 680;
+ const cx = SIZE / 2, cy = SIZE / 2;
+ const maxR = SIZE * 0.42;
+
+ const ivMin = 0.04, ivMax = 0.8;
+ const ivRings = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
+
+ const kMin = S * 0.82, kMax = S * 1.18;
+ const nSpokes = 13;
+ const strikes = useMemo(() => {
+ const arr: number[] = [];
+ for (let i = 0; i < nSpokes; i++) {
+ arr.push(kMin + (kMax - kMin) * i / (nSpokes - 1));
+ }
+ return arr;
+ }, [kMin, kMax]);
+
+ function strikeToAngle(strike: number): number {
+ const norm = (strike - kMin) / (kMax - kMin);
+ return -Math.PI / 2 + (norm - 0.5) * Math.PI * 1.5;
+ }
+
+ function ivToR(iv: number): number {
+ return maxR * Math.max(0, Math.min(1, (iv - ivMin) / (ivMax - ivMin)));
+ }
+
+ function polarToXY(angle: number, radius: number): [number, number] {
+ return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
+ }
+
+ const fairVal = useMemo(() => bsPrice(S, K, T, r, q, atmSigma, type), [S, K, T, r, q, atmSigma, type]);
+
+ const curves = useMemo(() => {
+ function _strikeToAngle(strike: number): number {
+ const norm = (strike - kMin) / (kMax - kMin);
+ return -Math.PI / 2 + (norm - 0.5) * Math.PI * 1.5;
+ }
+ function _ivToR(iv: number): number {
+ return maxR * Math.max(0, Math.min(1, (iv - ivMin) / (ivMax - ivMin)));
+ }
+ function _polarToXY(angle: number, radius: number): [number, number] {
+ return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
+ }
+ return expiries.map(e => {
+ return strikes.map(strike => {
+ const iv = bsSynthIV(S, strike, e.T, atmSigma);
+ const angle = _strikeToAngle(strike);
+ const radius = _ivToR(iv);
+ return _polarToXY(angle, radius);
+ });
+ });
+ }, [S, atmSigma, expiries, strikes, kMin, kMax, maxR, ivMin, ivMax, cx, cy]);
+
+ function curvePath(pts: [number, number][]): string {
+ return pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
+ }
+
+ const selCurve = curves[selectedExpiryIdx];
+ const kAngle = strikeToAngle(K);
+ const kIv = bsSynthIV(S, K, T, atmSigma);
+ const kR = ivToR(kIv);
+ const [dotX, dotY] = polarToXY(kAngle, kR);
+
+ const atmAngle = -Math.PI / 2;
+
+ const eyeR = 28;
+ const legendColors = [
+ 'var(--fg-4)', 'var(--fg-3)', 'var(--fg-2)', 'var(--fg-2)', 'var(--brass)', 'var(--brass-bright)'
+ ];
+
+ return (
+ <div className="opt-surface">
+ <div className="head">
+ <h3>Vol Surface <em>polar</em></h3>
+ </div>
+ <div className="opt-polar-wrap">
+ <svg
+ viewBox={`0 0 ${SIZE} ${SIZE}`}
+ width="100%"
+ height={SIZE}
+ className="opt-polar"
+ style={{ display: 'block' }}
+ >
+ {ivRings.map(iv => {
+ const r = ivToR(iv);
+ return (
+ <g key={iv}>
+ <circle cx={cx} cy={cy} r={r} className={iv === Math.max(...ivRings) ? 'ring outer' : 'ring'} />
+ <text x={cx + 4} y={cy - r + 4} className="iv">{(iv * 100).toFixed(0)}%</text>
+ </g>
+ );
+ })}
+ {strikes.map((strike, i) => {
+ const angle = strikeToAngle(strike);
+ const [x1, y1] = polarToXY(angle, 0);
+ const [x2, y2] = polarToXY(angle, maxR);
+ const isAtm = Math.abs(strike - S) < (kMax - kMin) / (nSpokes * 2);
+ return (
+ <g key={i}>
+ <line
+ x1={x1.toFixed(1)} y1={y1.toFixed(1)}
+ x2={x2.toFixed(1)} y2={y2.toFixed(1)}
+ className={isAtm ? 'spoke atm' : 'spoke'}
+ />
+ <text
+ x={(cx + (maxR + 14) * Math.cos(angle)).toFixed(1)}
+ y={(cy + (maxR + 14) * Math.sin(angle)).toFixed(1)}
+ textAnchor="middle"
+ dominantBaseline="middle"
+ className={isAtm ? 'tick atm' : 'tick'}
+ fontSize="9"
+ >
+ {strike.toFixed(0)}
+ </text>
+ </g>
+ );
+ })}
+ {curves.map((pts, idx) => {
+ const isSel = idx === selectedExpiryIdx;
+ if (isSel) return null;
+ const color = legendColors[Math.min(idx, legendColors.length - 1)];
+ return (
+ <path
+ key={idx}
+ d={curvePath(pts)}
+ className="expiry"
+ stroke={color}
+ strokeWidth="1"
+ opacity="0.5"
+ />
+ );
+ })}
+ {selCurve && (
+ <path
+ d={curvePath(selCurve)}
+ className="expiry"
+ stroke="var(--brass-bright)"
+ strokeWidth="2"
+ fill="none"
+ />
+ )}
+ <circle cx={dotX.toFixed(1)} cy={dotY.toFixed(1)} r={5} className="dot" />
+ <line
+ x1={(cx + eyeR * Math.cos(atmAngle)).toFixed(1)}
+ y1={(cy + eyeR * Math.sin(atmAngle)).toFixed(1)}
+ x2={(cx + (maxR + 2) * Math.cos(atmAngle)).toFixed(1)}
+ y2={(cy + (maxR + 2) * Math.sin(atmAngle)).toFixed(1)}
+ className="spoke atm"
+ />
+ <circle cx={cx} cy={cy} r={eyeR} className="eye" />
+ <text x={cx} y={cy - 8} textAnchor="middle" className="eye-lbl">{type === 'C' ? 'Call' : 'Put'}</text>
+ <text x={cx} y={cy + 10} textAnchor="middle" className="eye-num">${fairVal.toFixed(2)}</text>
+ </svg>
+ </div>
+ <div className="opt-surface-legend">
+ {expiries.map((e, idx) => {
+ const color = legendColors[Math.min(idx, legendColors.length - 1)];
+ const isSel = idx === selectedExpiryIdx;
+ return (
+ <div key={e.label} className={`item${isSel ? '' : ' muted'}`}>
+ <div className="swatch" style={{ background: isSel ? 'var(--brass-bright)' : color }} />
+ {e.label}
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+}
+
+interface IvHeatmapProps {
+ S: number;
+ atmSigma: number;
+ expiries: Expiry[];
+ selectedExpiryIdx: number;
+ selectedK: number;
+ onSelect: (expiryIdx: number, K: number) => void;
+}
+
+export function IvHeatmap({ S, atmSigma, expiries, selectedExpiryIdx, selectedK, onSelect }: IvHeatmapProps) {
+ const nStrikes = 13;
+ const kMin = S * 0.85, kMax = S * 1.15;
+
+ const strikes = useMemo(() => {
+ const arr: number[] = [];
+ for (let i = 0; i < nStrikes; i++) {
+ arr.push(Math.round((kMin + (kMax - kMin) * i / (nStrikes - 1)) / 5) * 5);
+ }
+ return arr;
+ }, [kMin, kMax]);
+
+ const ivGrid = useMemo(() => {
+ return expiries.map(e =>
+ strikes.map(K => bsSynthIV(S, K, e.T, atmSigma))
+ );
+ }, [S, atmSigma, expiries, strikes]);
+
+ const allIvs = ivGrid.flat();
+ const ivGridMin = Math.min(...allIvs);
+ const ivGridMax = Math.max(...allIvs);
+
+ function cellColor(iv: number): string {
+ const pct = Math.round(((iv - ivGridMin) / (ivGridMax - ivGridMin + 0.001)) * 80);
+ return `color-mix(in oklch, var(--ink-2), var(--brass-deep) ${pct}%)`;
+ }
+
+ function textColor(iv: number): string {
+ const pct = (iv - ivGridMin) / (ivGridMax - ivGridMin + 0.001);
+ return pct > 0.5 ? 'var(--fg-1)' : 'var(--fg-3)';
+ }
+
+ const atmK = Math.round(S / 5) * 5;
+
+ return (
+ <div className="opt-surface">
+ <div className="head">
+ <h3>IV Heatmap <em>surface</em></h3>
+ </div>
+ <div className="opt-heat">
+ <div className="ylabs">
+ {expiries.map(e => (
+ <span key={e.label}>{e.label}</span>
+ ))}
+ </div>
+ <div>
+ <div className="grid" style={{ gridTemplateColumns: `repeat(${nStrikes}, 1fr)` }}>
+ {ivGrid.map((row, eIdx) =>
+ row.map((iv, kIdx) => {
+ const K = strikes[kIdx];
+ const isCursor = eIdx === selectedExpiryIdx && K === selectedK;
+ return (
+ <div
+ key={`${eIdx}-${kIdx}`}
+ className={`cell${isCursor ? ' cursor' : ''}`}
+ style={{ background: cellColor(iv), color: textColor(iv) }}
+ onClick={() => onSelect(eIdx, K)}
+ >
+ {(iv * 100).toFixed(0)}%
+ </div>
+ );
+ })
+ )}
+ </div>
+ <div className="xlabs" style={{ gridTemplateColumns: `repeat(${nStrikes}, 1fr)` }}>
+ {strikes.map(K => (
+ <span key={K} className={K === atmK ? 'atm' : ''}>{K}</span>
+ ))}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/components/prism/options/types.ts b/frontend/components/prism/options/types.ts
new file mode 100644
index 0000000..fb90a7a
--- /dev/null
+++ b/frontend/components/prism/options/types.ts
@@ -0,0 +1,47 @@
+export type OptionType = 'C' | 'P';
+
+export interface Expiry {
+ label: string;
+ dte: number;
+ T: number;
+}
+
+export interface OptionInputs {
+ S: number;
+ K: number;
+ T: number;
+ r: number;
+ q: number;
+ sigma: number;
+ type: OptionType;
+}
+
+export interface ChainRow {
+ K: number;
+ cMid: number; pMid: number;
+ cIv: number; pIv: number;
+ cDelta: number; pDelta: number;
+ cOi: number; pOi: number;
+ cVol: number; pVol: number;
+}
+
+export interface TickerDefaults {
+ sym: string;
+ name: string;
+ sector: string;
+ spot: number;
+ chgAbs: number;
+ chgPct: number;
+ r: number;
+ q: number;
+ atmSigma30: number;
+}
+
+export const EXPIRIES: Expiry[] = [
+ { label: 'Apr 19', dte: 14, T: 14 / 365 },
+ { label: 'May 17', dte: 30, T: 30 / 365 },
+ { label: 'Jun 21', dte: 65, T: 65 / 365 },
+ { label: 'Sep 20', dte: 156, T: 156 / 365 },
+ { label: "Jan '27", dte: 280, T: 280 / 365 },
+ { label: "Jan '28", dte: 644, T: 644 / 365 },
+];