From 2222d8a5fac6850d7d924e9404a44a65cb10f68f Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Thu, 21 May 2026 14:49:56 -0700 Subject: fix: rewrite polar smile with correct closed-lens geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Piecewise angleFor: puts go CCW (ATM→bottom), calls go CW (ATM→bottom) so both wings meet at the same bottom point and the path closes with Z - Dynamic ivToR computed from actual IV range across all expiries (inner floor 60px, outer wall 240px) instead of hardcoded percentages - IV rings: 5 evenly-spaced steps between real ivLo/ivHi - Curve fill: rgba(194,170,122,0.12) matching design reference - Non-selected curves: index-based styles (solid fg-1, then dashed fg-2/3/4) - Eye price at font-size 30, label lowercase 'call · fair' - Dashed marker-line spoke from center to selected-K dot - Clicking a curve switches the selected expiry via onPickT - Strike ticks use round multiples of a price-adaptive step - Fix IvHeatmap xlabs duplicate key error (key by index, not rounded K) Co-Authored-By: Claude Sonnet 4.6 --- frontend/components/prism/options/OptionsPage.tsx | 10 +- .../components/prism/options/OptionsSurface.tsx | 337 +++++++++++++-------- 2 files changed, 211 insertions(+), 136 deletions(-) (limited to 'frontend/components/prism') diff --git a/frontend/components/prism/options/OptionsPage.tsx b/frontend/components/prism/options/OptionsPage.tsx index 7fcf5c3..4065d9f 100644 --- a/frontend/components/prism/options/OptionsPage.tsx +++ b/frontend/components/prism/options/OptionsPage.tsx @@ -150,14 +150,18 @@ export function OptionsPage({ overview, ticker }: OptionsPageProps) {
{ + const idx = EXPIRIES.findIndex(e => Math.abs(e.T - T) < 1e-6); + if (idx >= 0) selectExpiry(idx); + }} /> void; } -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; +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; - const ivMin = 0.04, ivMax = 0.8; - const ivRings = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]; + // Strike range ±15% moneyness + const N = 48; + const kMin = S * 0.85; + const kMax = S * 1.15; - 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 strikeAt(i: number): number { + return kMin + (i / N) * (kMax - kMin); + } - function strikeToAngle(strike: number): number { - const norm = (strike - kMin) / (kMax - kMin); - return -Math.PI / 2 + (norm - 0.5) * Math.PI * 1.5; + // 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; } - function ivToR(iv: number): number { - return maxR * Math.max(0, Math.min(1, (iv - ivMin) / (ivMax - ivMin))); + // 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); } - function polarToXY(angle: number, radius: number): [number, number] { - return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + // 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); } - const fairVal = useMemo(() => bsPrice(S, K, T, r, q, atmSigma, type), [S, K, T, r, q, atmSigma, type]); + // 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(() => { - 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); - }); + 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 }; }); - }, [S, atmSigma, expiries, strikes, kMin, kMax, maxR, ivMin, ivMax, cx, cy]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [S, atmSigma, expiries, selectedT, ivLo, ivHi]); - function curvePath(pts: [number, number][]): string { - return pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' '); + function pathD(pts: [number, number][]): string { + const inner = pts.map(([px, py]) => `${px.toFixed(2)} ${py.toFixed(2)}`).join(' L '); + return `M ${inner} Z`; } - 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); + 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' }; + } - const atmAngle = -Math.PI / 2; + // Selected expiry object + const selectedExpiry = expiries.find(e => Math.abs(e.T - selectedT) < 1e-6) ?? expiries[0]; - const eyeR = 28; - const legendColors = [ - 'var(--fg-4)', 'var(--fg-3)', 'var(--fg-2)', 'var(--fg-2)', 'var(--brass)', 'var(--brass-bright)' - ]; + // 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} +
+ ); + })} +
- - {ivRings.map(iv => { - const r = ivToR(iv); - return ( - - - {(iv * 100).toFixed(0)}% - - ); - })} - {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); + + + {/* 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 ( - + - - {strike.toFixed(0)} + className={`tick${isAtm ? ' atm' : ''}`}> + {k.toFixed(0)} ); })} - {curves.map((pts, idx) => { - const isSel = idx === selectedExpiryIdx; - if (isSel) return null; - const color = legendColors[Math.min(idx, legendColors.length - 1)]; + + {/* 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)} + /> + ); })} - {selCurve && ( - - )} - - + + {/* 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' : 'Put'} - ${fairVal.toFixed(2)} + + {type === 'C' ? 'call · fair' : 'put · fair'} + + + {fair.toFixed(2)} + + + Δ {gr.delta.toFixed(2)} · ν {gr.vega.toFixed(2)} + + + {selectedExpiry?.label} · {selectedExpiry?.dte}d +
-
- {expiries.map((e, idx) => { - const color = legendColors[Math.min(idx, legendColors.length - 1)]; - const isSel = idx === selectedExpiryIdx; - return ( -
-
- {e.label} -
- ); - })} -
); } @@ -261,8 +332,8 @@ export function IvHeatmap({ S, atmSigma, expiries, selectedExpiryIdx, selectedK, )}
- {strikes.map(K => ( - {K} + {strikes.map((K, i) => ( + {K} ))}
-- cgit v1.3-2-g0d8e