diff options
Diffstat (limited to 'frontend/components/prism/options/OptionsSurface.tsx')
| -rw-r--r-- | frontend/components/prism/options/OptionsSurface.tsx | 272 |
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> + ); +} |
