From 25360aacb8aab46e7e579707eb9704759af9536d Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Wed, 20 May 2026 00:22:32 -0700 Subject: 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 --- .../components/prism/options/OptionsCharts.tsx | 335 +++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 frontend/components/prism/options/OptionsCharts.tsx (limited to 'frontend/components/prism/options/OptionsCharts.tsx') diff --git a/frontend/components/prism/options/OptionsCharts.tsx b/frontend/components/prism/options/OptionsCharts.tsx new file mode 100644 index 0000000..337035f --- /dev/null +++ b/frontend/components/prism/options/OptionsCharts.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useMemo } from "react"; +import { bsPrice, bsGreeks, bsSynthIV } from "@/lib/blackScholes"; +import type { Expiry, OptionType } from "./types"; + +function smoothPath(pts: [number, number][]): string { + if (pts.length < 2) return ""; + let d = `M ${pts[0][0]} ${pts[0][1]}`; + for (let i = 1; i < pts.length; i++) { + const [x0, y0] = pts[i - 1]; + const [x1, y1] = pts[i]; + const cx = (x0 + x1) / 2; + d += ` C ${cx} ${y0}, ${cx} ${y1}, ${x1} ${y1}`; + } + return d; +} + +function linePath(pts: [number, number][]): string { + if (!pts.length) return ""; + return pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`).join(' '); +} + +interface SmileChartProps { + S: number; + T: number; + r: number; + q: number; + atmSigma: number; + K: number; + type: OptionType; + expiryLabel: string; + allExpiries: Expiry[]; +} + +export function SmileChart({ S, T, atmSigma, K, allExpiries }: SmileChartProps) { + const W = 720, H = 240; + const pad = { l: 40, r: 20, t: 20, b: 30 }; + const iW = W - pad.l - pad.r; + const iH = H - pad.t - pad.b; + + const kMin = S * 0.80, kMax = S * 1.22; + const nPts = 60; + + const primaryPts = useMemo((): [number, number][] => { + const pts: [number, number][] = []; + for (let i = 0; i <= nPts; i++) { + const strike = kMin + (kMax - kMin) * i / nPts; + const iv = bsSynthIV(S, strike, T, atmSigma); + pts.push([strike, iv]); + } + return pts; + }, [S, T, atmSigma, kMin, kMax]); + + const otherCurves = useMemo(() => { + return allExpiries + .filter(e => Math.abs(e.T - T) > 0.001) + .slice(0, 3) + .map(e => { + const pts: [number, number][] = []; + for (let i = 0; i <= nPts; i++) { + const strike = kMin + (kMax - kMin) * i / nPts; + const iv = bsSynthIV(S, strike, e.T, atmSigma); + pts.push([strike, iv]); + } + return pts; + }); + }, [S, T, atmSigma, allExpiries, kMin, kMax]); + + const allIvs = primaryPts.map(p => p[1]); + const ivMin = Math.max(0, Math.min(...allIvs) - 0.02); + const ivMax = Math.max(...allIvs) + 0.04; + + function toX(strike: number) { + return pad.l + ((strike - kMin) / (kMax - kMin)) * iW; + } + function toY(iv: number) { + return pad.t + iH - ((iv - ivMin) / (ivMax - ivMin)) * iH; + } + + const svgPrimary = smoothPath(primaryPts.map(([k, iv]) => [toX(k), toY(iv)] as [number, number])); + const fillPath = svgPrimary + ` L ${toX(kMax)} ${pad.t + iH} L ${toX(kMin)} ${pad.t + iH} Z`; + const kX = toX(K); + const kIv = bsSynthIV(S, K, T, atmSigma); + const kY = toY(kIv); + const atmX = toX(S); + + const yTicks = 4; + const yLabels: number[] = []; + for (let i = 0; i <= yTicks; i++) { + yLabels.push(ivMin + (ivMax - ivMin) * i / yTicks); + } + + return ( +
+
+

Vol Smile

+ Implied Volatility vs. Strike +
+ + {yLabels.map(iv => ( + + + {(iv * 100).toFixed(0)}% + + ))} + + + {otherCurves.map((pts, idx) => ( + [toX(k), toY(iv)] as [number, number]))} + /> + ))} + + + + ATM + + + {(kIv * 100).toFixed(1)}% + +
+ ); +} + +interface TermStructureProps { + S: number; + r: number; + q: number; + atmSigma: number; + expiries: Expiry[]; + selectedT: number; + onPickT: (T: number) => void; +} + +export function TermStructure({ S, atmSigma, expiries, selectedT, onPickT }: TermStructureProps) { + const W = 360, H = 150; + const pad = { l: 36, r: 16, t: 16, b: 30 }; + const iW = W - pad.l - pad.r; + const iH = H - pad.t - pad.b; + + const pts = useMemo(() => expiries.map(e => { + const iv = bsSynthIV(S, S, e.T, atmSigma); + return { T: e.T, sqrtT: Math.sqrt(e.T), iv, label: e.label, dte: e.dte }; + }), [S, atmSigma, expiries]); + + const xs = pts.map(p => p.sqrtT); + const ivs = pts.map(p => p.iv); + const xMin = Math.min(...xs), xMax = Math.max(...xs); + const ivMin = Math.min(...ivs) - 0.01, ivMax = Math.max(...ivs) + 0.01; + + function toX(sqrtT: number) { + return pad.l + ((sqrtT - xMin) / (xMax - xMin + 0.001)) * iW; + } + function toY(iv: number) { + return pad.t + iH - ((iv - ivMin) / (ivMax - ivMin + 0.001)) * iH; + } + + const linePts: [number, number][] = pts.map(p => [toX(p.sqrtT), toY(p.iv)]); + + return ( +
+
+

Term Structure

+ ATM IV vs. Expiry +
+ + + + + {pts.map((p) => { + const cx = toX(p.sqrtT); + const cy = toY(p.iv); + const isSel = Math.abs(p.T - selectedT) < 0.001; + return ( + onPickT(p.T)}> + + {p.label} + + ); + })} + +
+ ); +} + +interface GreekMiniProps { + name: string; + glyph: string; + kind: 'delta' | 'gamma' | 'vega' | 'theta'; + S: number; + K: number; + T: number; + r: number; + q: number; + sigma: number; + type: OptionType; +} + +export function GreekMini({ name, glyph, kind, S, K, T, r, q, sigma, type }: GreekMiniProps) { + const W = 160, H = 90; + const pad = { l: 8, r: 8, t: 8, b: 8 }; + const iW = W - pad.l - pad.r; + const iH = H - pad.t - pad.b; + + const nPts = 40; + + const pts = useMemo((): [number, number][] => { + const sMin = S * 0.75, sMax = S * 1.25; + const result: [number, number][] = []; + for (let i = 0; i <= nPts; i++) { + const spot = sMin + (sMax - sMin) * i / nPts; + const g = bsGreeks(spot, K, T, r, q, sigma, type); + result.push([spot, g[kind]]); + } + return result; + }, [S, K, T, r, q, sigma, type, kind]); + + const ys = pts.map(p => p[1]); + const yMin = Math.min(...ys), yMax = Math.max(...ys); + const xMin = pts[0][0], xMax = pts[pts.length - 1][0]; + + function toX(v: number) { return pad.l + ((v - xMin) / (xMax - xMin + 0.001)) * iW; } + function toY(v: number) { return pad.t + iH - ((v - yMin) / (yMax - yMin + 0.001)) * iH; } + + const svgPts: [number, number][] = pts.map(([x, y]) => [toX(x), toY(y)]); + const curValG = bsGreeks(S, K, T, r, q, sigma, type); + const curVal = curValG[kind]; + + const dotX = toX(S); + const dotY = toY(curVal); + + return ( +
+
+ {glyph} {name} + {curVal.toFixed(4)} +
+ + + + +
+ ); +} + +interface PayoffProps { + S: number; + K: number; + T: number; + r: number; + q: number; + sigma: number; + type: OptionType; + mid?: number; +} + +export function Payoff({ S, K, T, r, q, sigma, type, mid }: PayoffProps) { + const W = 720, H = 140; + const pad = { l: 44, r: 16, t: 16, b: 28 }; + const iW = W - pad.l - pad.r; + const iH = H - pad.t - pad.b; + + const premium = mid ?? bsPrice(S, K, T, r, q, sigma, type); + + const sMin = S * 0.70, sMax = S * 1.30; + const nPts = 80; + + const pnlPts = useMemo((): [number, number][] => { + const pts: [number, number][] = []; + for (let i = 0; i <= nPts; i++) { + const spot = sMin + (sMax - sMin) * i / nPts; + const payoff = type === 'C' ? Math.max(spot - K, 0) : Math.max(K - spot, 0); + pts.push([spot, payoff - premium]); + } + return pts; + }, [K, type, premium, sMin, sMax]); + + const ys = pnlPts.map(p => p[1]); + const yMin = Math.min(...ys, -premium * 1.1); + const yMax = Math.max(...ys, premium * 1.1); + + function toX(v: number) { return pad.l + ((v - sMin) / (sMax - sMin)) * iW; } + function toY(v: number) { return pad.t + iH - ((v - yMin) / (yMax - yMin + 0.001)) * iH; } + + const zeroY = toY(0); + const gainPts = pnlPts.filter(p => p[1] >= 0); + const lossPts = pnlPts.filter(p => p[1] <= 0); + + const allSvg = linePath(pnlPts.map(([x, y]) => [toX(x), toY(y)] as [number, number])); + const gainArea = gainPts.length > 1 + ? linePath(gainPts.map(([x, y]) => [toX(x), toY(y)] as [number, number])) + + ` L ${toX(gainPts[gainPts.length - 1][0])} ${zeroY} L ${toX(gainPts[0][0])} ${zeroY} Z` + : ''; + const lossArea = lossPts.length > 1 + ? linePath(lossPts.map(([x, y]) => [toX(x), toY(y)] as [number, number])) + + ` L ${toX(lossPts[lossPts.length - 1][0])} ${zeroY} L ${toX(lossPts[0][0])} ${zeroY} Z` + : ''; + + const beSpot = type === 'C' ? K + premium : K - premium; + const beX = toX(beSpot); + const spotX = toX(S); + const kX = toX(K); + + return ( +
+ + + {gainArea && ( + + )} + {lossArea && ( + + )} + + + K={K.toFixed(0)} + + S + {beSpot >= sMin && beSpot <= sMax && ( + <> + + BE + + )} + +
+ ); +} -- cgit v1.3-2-g0d8e