"use client"; import { useMemo } from "react"; import { bsPrice, bsGreeks, bsSynthIV } from "@/lib/blackScholes"; import type { Expiry, OptionType } from "./types"; interface PolarSmileProps { S: number; K: number; T: number; r: number; q: number; atmSigma: number; type: OptionType; expiries: Expiry[]; selectedT: number; onPickT: (T: number) => void; } export function PolarSmile({ S, K, T, r, q, atmSigma, type, expiries, selectedT, onPickT }: PolarSmileProps) { const W = 680, H = 680; const cx = W / 2, cy = H / 2; const rOuter = 240; const eyeR = 64; // Strike range ±15% moneyness const N = 48; const kMin = S * 0.85; const kMax = S * 1.15; function strikeAt(i: number): number { return kMin + (i / N) * (kMax - kMin); } // Piecewise angle: puts descend LEFT (counterclockwise), calls descend RIGHT (clockwise). // ATM → top (−π/2); both wings meet at BOTTOM (−3π/2 ≡ +π/2). Full closed lens. function angleFor(K_: number): number { if (K_ <= S) { const t = (S - K_) / (S - kMin || 1); return -Math.PI / 2 - t * Math.PI; } const t = (K_ - S) / (kMax - S || 1); return -Math.PI / 2 + t * Math.PI; } // Strike tick labels — round multiples of a step fitted to the price range const rawStep = (kMax - kMin) / 8; const tickStep = rawStep >= 40 ? 50 : rawStep >= 20 ? 25 : rawStep >= 8 ? 10 : 5; const tickStrikes: number[] = []; for (let kk = Math.ceil(kMin / tickStep) * tickStep; kk <= kMax; kk += tickStep) { tickStrikes.push(kk); } // Dynamic IV range across all expiries → defines radial scale const { ivLo, ivHi } = useMemo(() => { let lo = Infinity, hi = -Infinity; expiries.forEach(e => { for (let i = 0; i <= N; i++) { const iv = bsSynthIV(S, strikeAt(i), e.T, atmSigma); if (iv < lo) lo = iv; if (iv > hi) hi = iv; } }); return { ivLo: Math.max(0.05, lo - 0.02), ivHi: hi + 0.02 }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [S, atmSigma, expiries]); // Radial scale: inner floor 60px, outer wall rOuter function ivToR(iv: number): number { return 60 + ((iv - ivLo) / (ivHi - ivLo)) * (rOuter - 60); } // 5 evenly-spaced IV ring labels const ivLabels = useMemo( () => Array.from({ length: 5 }, (_, step) => ivLo + (step / 4) * (ivHi - ivLo)), [ivLo, ivHi] ); // Smooth closed curves (N+1 samples, joined with Z so wings meet at bottom) const curves = useMemo(() => { return expiries.map(e => { const pts: [number, number][] = []; for (let i = 0; i <= N; i++) { const Kk = strikeAt(i); const iv = bsSynthIV(S, Kk, e.T, atmSigma); const a = angleFor(Kk); const rr = ivToR(iv); pts.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]); } return { expiry: e, pts, isSelected: Math.abs(e.T - selectedT) < 1e-6 }; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [S, atmSigma, expiries, selectedT, ivLo, ivHi]); function pathD(pts: [number, number][]): string { const inner = pts.map(([px, py]) => `${px.toFixed(2)} ${py.toFixed(2)}`).join(' L '); return `M ${inner} Z`; } function styleFor(idx: number, isSelected: boolean) { if (isSelected) return { stroke: 'var(--brass-bright)', strokeWidth: 2.5, strokeDasharray: undefined, fill: 'rgba(194,170,122,0.12)', }; const strokes = ['var(--fg-1)', 'var(--fg-2)', 'var(--fg-3)', 'var(--fg-4)']; const dashes = [undefined, '4 3', '5 4', '2 4']; const widths = [1.4, 1.2, 1.2, 1.1]; const i = Math.min(3, idx); return { stroke: strokes[i], strokeWidth: widths[i], strokeDasharray: dashes[i], fill: 'none' }; } // Selected expiry object const selectedExpiry = expiries.find(e => Math.abs(e.T - selectedT) < 1e-6) ?? expiries[0]; // Current K dot on selected-expiry curve const curIv = bsSynthIV(S, K, selectedExpiry?.T ?? T, atmSigma); const aCur = angleFor(K); const rCur = ivToR(curIv); const dotX = cx + Math.cos(aCur) * rCur; const dotY = cy + Math.sin(aCur) * rCur; const dotLabelRight = aCur > -Math.PI / 2 && aCur < Math.PI / 2; // Fair value + greeks for selected K & expiry const selT = selectedExpiry?.T ?? T; const fair = useMemo(() => bsPrice(S, K, selT, r, q, atmSigma, type), [S, K, selT, r, q, atmSigma, type]); const gr = useMemo(() => bsGreeks(S, K, selT, r, q, atmSigma, type), [S, K, selT, r, q, atmSigma, type]); return (

Vol Surface polar

{expiries.map((e, idx) => { const isSel = Math.abs(e.T - selectedT) < 1e-6; const s = styleFor(idx, isSel); return (
onPickT(e.T)} style={{ cursor: 'pointer' }} >
{e.label}
); })}
{/* Concentric IV rings */} {ivLabels.map((iv, i) => ( ))} {ivLabels.map((iv, i) => ( {(iv * 100).toFixed(0)}% ))} {/* Spoke lines + round strike tick labels */} {tickStrikes.map(k => { const a = angleFor(k); const isAtm = Math.abs(k - S) < 2.5; const x2 = cx + Math.cos(a) * rOuter; const y2 = cy + Math.sin(a) * rOuter; const lx = cx + Math.cos(a) * (rOuter + 18); const ly = cy + Math.sin(a) * (rOuter + 18); return ( {k.toFixed(0)} ); })} {/* Directional labels */} OTM puts ↓ ↓ OTM calls ATM ↑ {/* Expiry curves — closed lens shape */} {curves.map((c, idx) => { const s = styleFor(idx, c.isSelected); const d = pathD(c.pts); return ( {c.isSelected && ( )} onPickT(c.expiry.T)} /> ); })} {/* Dashed spoke from center to selected-K marker */} {/* Selected-K dot */} {/* K label */} K {K.toFixed(0)} · {(curIv * 100).toFixed(1)}% {/* Center eye */} {type === 'C' ? 'call · fair' : 'put · fair'} {fair.toFixed(2)} Δ {gr.delta.toFixed(2)} · ν {gr.vega.toFixed(2)} {selectedExpiry?.label} · {selectedExpiry?.dte}d
); } 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 (

IV Heatmap surface

{expiries.map(e => ( {e.label} ))}
{ivGrid.map((row, eIdx) => row.map((iv, kIdx) => { const K = strikes[kIdx]; const isCursor = eIdx === selectedExpiryIdx && K === selectedK; return (
onSelect(eIdx, K)} > {(iv * 100).toFixed(0)}%
); }) )}
{strikes.map((K, i) => ( {K} ))}
); }