summaryrefslogtreecommitdiff
path: root/frontend/components/prism/options/OptionsSurface.tsx
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/options/OptionsSurface.tsx
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/options/OptionsSurface.tsx')
-rw-r--r--frontend/components/prism/options/OptionsSurface.tsx272
1 files changed, 272 insertions, 0 deletions
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>
+ );
+}