diff options
Diffstat (limited to 'frontend/components')
| -rw-r--r-- | frontend/components/prism/options/OptionsPage.tsx | 10 | ||||
| -rw-r--r-- | frontend/components/prism/options/OptionsSurface.tsx | 337 |
2 files changed, 211 insertions, 136 deletions
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) { <div className="opt-col"> <PolarSmile S={inputs.S} + K={inputs.K} + T={expiry.T} r={r} q={q} atmSigma={atmSigma30} - K={inputs.K} - T={expiry.T} type={inputs.type} expiries={EXPIRIES} - selectedExpiryIdx={expiryIdx} + selectedT={expiry.T} + onPickT={T => { + const idx = EXPIRIES.findIndex(e => Math.abs(e.T - T) < 1e-6); + if (idx >= 0) selectExpiry(idx); + }} /> <IvHeatmap S={inputs.S} diff --git a/frontend/components/prism/options/OptionsSurface.tsx b/frontend/components/prism/options/OptionsSurface.tsx index da55012..8984ed0 100644 --- a/frontend/components/prism/options/OptionsSurface.tsx +++ b/frontend/components/prism/options/OptionsSurface.tsx @@ -1,188 +1,259 @@ "use client"; import { useMemo } from "react"; -import { bsPrice, bsSynthIV } from "@/lib/blackScholes"; +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; - K: number; - T: number; type: OptionType; expiries: Expiry[]; - selectedExpiryIdx: number; + selectedT: number; + onPickT: (T: number) => 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 ( <div className="opt-surface"> <div className="head"> <h3>Vol Surface <em>polar</em></h3> </div> + <div className="opt-surface-legend"> + {expiries.map((e, idx) => { + const isSel = Math.abs(e.T - selectedT) < 1e-6; + const s = styleFor(idx, isSel); + return ( + <div key={e.label} + className={`item${isSel ? '' : ' muted'}`} + onClick={() => onPickT(e.T)} + style={{ cursor: 'pointer' }} + > + <div className="swatch" style={{ background: s.stroke }} /> + {e.label} + </div> + ); + })} + </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); + <svg viewBox={`0 0 ${W} ${H}`} width="100%" className="opt-polar" style={{ display: 'block' }}> + + {/* Concentric IV rings */} + {ivLabels.map((iv, i) => ( + <circle key={i} + className={`ring${i === ivLabels.length - 1 ? ' outer' : ''}`} + cx={cx} cy={cy} r={ivToR(iv)} /> + ))} + {ivLabels.map((iv, i) => ( + <text key={`l${i}`} className="iv" x={cx + 6} y={(cy - ivToR(iv) - 2).toFixed(1)}> + {(iv * 100).toFixed(0)}% + </text> + ))} + + {/* 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 ( - <g key={i}> + <g key={k}> <line - x1={x1.toFixed(1)} y1={y1.toFixed(1)} + className={`spoke${isAtm ? ' atm' : ''}`} + x1={cx} y1={cy} x2={x2.toFixed(1)} y2={y2.toFixed(1)} - className={isAtm ? 'spoke atm' : 'spoke'} + style={{ opacity: isAtm ? 1 : 0.35 }} /> - <text - x={(cx + (maxR + 14) * Math.cos(angle)).toFixed(1)} - y={(cy + (maxR + 14) * Math.sin(angle)).toFixed(1)} + <text x={lx.toFixed(1)} y={(ly + 3).toFixed(1)} textAnchor="middle" - dominantBaseline="middle" - className={isAtm ? 'tick atm' : 'tick'} - fontSize="9" - > - {strike.toFixed(0)} + className={`tick${isAtm ? ' atm' : ''}`}> + {k.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)]; + + {/* Directional labels */} + <text x={(cx - rOuter - 6).toFixed(1)} y={cy} textAnchor="end" + style={{ fontFamily: 'var(--font-sans)', fontSize: 10, letterSpacing: 'var(--tr-wider)', textTransform: 'uppercase', fill: 'var(--negative)' }}> + OTM puts ↓ + </text> + <text x={(cx + rOuter + 6).toFixed(1)} y={cy} textAnchor="start" + style={{ fontFamily: 'var(--font-sans)', fontSize: 10, letterSpacing: 'var(--tr-wider)', textTransform: 'uppercase', fill: 'var(--positive)' }}> + ↓ OTM calls + </text> + <text x={cx} y={(cy - rOuter - 24).toFixed(1)} textAnchor="middle" + style={{ fontFamily: 'var(--font-sans)', fontSize: 10, letterSpacing: 'var(--tr-wider)', textTransform: 'uppercase', fill: 'var(--brass)' }}> + ATM ↑ + </text> + + {/* Expiry curves — closed lens shape */} + {curves.map((c, idx) => { + const s = styleFor(idx, c.isSelected); + const d = pathD(c.pts); return ( - <path - key={idx} - d={curvePath(pts)} - className="expiry" - stroke={color} - strokeWidth="1" - opacity="0.5" - /> + <g key={idx}> + {c.isSelected && ( + <path d={d} className="expiry-fill" fill={s.fill} stroke="none" /> + )} + <path d={d} className="expiry" + stroke={s.stroke} + strokeWidth={s.strokeWidth} + strokeDasharray={s.strokeDasharray} + fill="none" + style={{ cursor: 'pointer' }} + onClick={() => onPickT(c.expiry.T)} + /> + </g> ); })} - {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" - /> + + {/* Dashed spoke from center to selected-K marker */} + <line className="marker-line" x1={cx} y1={cy} x2={dotX.toFixed(1)} y2={dotY.toFixed(1)} /> + + {/* Selected-K dot */} + <circle cx={dotX.toFixed(1)} cy={dotY.toFixed(1)} r={6} className="dot" /> + + {/* K label */} + <text + x={(dotX + (dotLabelRight ? 10 : -10)).toFixed(1)} + y={(dotY + 4).toFixed(1)} + textAnchor={dotLabelRight ? 'start' : 'end'} + className="brass" style={{ fontSize: 12 }} + > + K {K.toFixed(0)} · {(curIv * 100).toFixed(1)}% + </text> + + {/* Center eye */} <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> + <text x={cx} y={(cy - 30).toFixed(1)} textAnchor="middle" className="eye-lbl"> + {type === 'C' ? 'call · fair' : 'put · fair'} + </text> + <text x={cx} y={(cy - 4).toFixed(1)} textAnchor="middle" className="eye-num" style={{ fontSize: 30 }}> + {fair.toFixed(2)} + </text> + <text x={cx} y={(cy + 16).toFixed(1)} textAnchor="middle" + style={{ fontFamily: 'var(--font-mono)', fontSize: 10, fill: 'var(--fg-2)' }}> + Δ {gr.delta.toFixed(2)} · ν {gr.vega.toFixed(2)} + </text> + <text x={cx} y={(cy + 32).toFixed(1)} textAnchor="middle" className="eye-lbl" style={{ fontSize: 9 }}> + {selectedExpiry?.label} · {selectedExpiry?.dte}d + </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> ); } @@ -261,8 +332,8 @@ export function IvHeatmap({ S, atmSigma, expiries, selectedExpiryIdx, selectedK, )} </div> <div className="xlabs" style={{ gridTemplateColumns: `repeat(${nStrikes}, 1fr)` }}> - {strikes.map(K => ( - <span key={K} className={K === atmK ? 'atm' : ''}>{K}</span> + {strikes.map((K, i) => ( + <span key={i} className={K === atmK ? 'atm' : ''}>{K}</span> ))} </div> </div> |
