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 --- frontend/components/prism/options/OptionsChain.tsx | 157 ++++++++++ .../components/prism/options/OptionsCharts.tsx | 335 +++++++++++++++++++++ frontend/components/prism/options/OptionsPage.tsx | 292 ++++++++++++++++++ .../components/prism/options/OptionsPricer.tsx | 274 +++++++++++++++++ .../components/prism/options/OptionsSurface.tsx | 272 +++++++++++++++++ frontend/components/prism/options/types.ts | 47 +++ 6 files changed, 1377 insertions(+) create mode 100644 frontend/components/prism/options/OptionsChain.tsx create mode 100644 frontend/components/prism/options/OptionsCharts.tsx create mode 100644 frontend/components/prism/options/OptionsPage.tsx create mode 100644 frontend/components/prism/options/OptionsPricer.tsx create mode 100644 frontend/components/prism/options/OptionsSurface.tsx create mode 100644 frontend/components/prism/options/types.ts (limited to 'frontend/components') diff --git a/frontend/components/prism/options/OptionsChain.tsx b/frontend/components/prism/options/OptionsChain.tsx new file mode 100644 index 0000000..be6b518 --- /dev/null +++ b/frontend/components/prism/options/OptionsChain.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useMemo } from "react"; +import { bsPrice, bsGreeks, bsSynthIV } from "@/lib/blackScholes"; +import type { ChainRow, OptionType } from "./types"; + +function hashRand(seed: number): number { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +export function buildChain(S: number, T: number, r: number, q: number, atmSigma: number, expirySeed: number): ChainRow[] { + const rawMin = Math.round(S * 0.85 / 5) * 5; + const rawMax = Math.round(S * 1.20 / 5) * 5; + const rows: ChainRow[] = []; + + for (let K = rawMin; K <= rawMax; K += 5) { + const iv = bsSynthIV(S, K, T, atmSigma); + const cMid = bsPrice(S, K, T, r, q, iv, 'C'); + const pMid = bsPrice(S, K, T, r, q, iv, 'P'); + const cG = bsGreeks(S, K, T, r, q, iv, 'C'); + const pG = bsGreeks(S, K, T, r, q, iv, 'P'); + const seed = expirySeed * 1000 + K; + const cOi = Math.round(hashRand(seed + 1) * 50000 + 1000); + const pOi = Math.round(hashRand(seed + 2) * 40000 + 800); + const cVol = Math.round(hashRand(seed + 3) * 20000 + 200); + const pVol = Math.round(hashRand(seed + 4) * 15000 + 150); + rows.push({ + K, + cMid, pMid, + cIv: iv, pIv: iv, + cDelta: cG.delta, pDelta: pG.delta, + cOi, pOi, + cVol, pVol, + }); + } + return rows; +} + +export function findAtmStrike(strikes: number[], S: number): number { + return strikes.reduce((prev, curr) => Math.abs(curr - S) < Math.abs(prev - S) ? curr : prev, strikes[0]); +} + +function fmtOi(n: number): string { + if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; + return n.toString(); +} + +interface ChainTableProps { + rows: ChainRow[]; + atmStrike: number; + selectedK: number; + type: OptionType; + onPick: (K: number) => void; + compact?: boolean; +} + +export function ChainTable({ rows, atmStrike, selectedK, type, onPick, compact = false }: ChainTableProps) { + return ( +
+
+

Option Chain

+ {compact ? 'Compact' : 'Full'} · {rows.length} strikes +
+
+ + + + {!compact && } + {compact && } + + {!compact && } + {compact && } + + {!compact ? ( + + + + + + + + + + + + ) : ( + + + + + + + + + + )} + + + {rows.map((row) => { + const isAtm = row.K === atmStrike; + const isSel = row.K === selectedK; + const cItm = type === 'C' ? row.K < atmStrike : row.K > atmStrike; + const pItm = type === 'P' ? row.K < atmStrike : row.K > atmStrike; + return ( + onPick(row.K)} + > + {!compact && } + + + + + + + + {!compact && } + + ); + })} + +
— calls —— calls —strike— puts —— puts —
OIIVlastΔKΔlastIVOI
IVlastΔKΔlastIV
{fmtOi(row.cOi)}{(row.cIv * 100).toFixed(1)}%{row.cMid.toFixed(2)}{row.cDelta.toFixed(2)}{row.K.toFixed(0)}{row.pDelta.toFixed(2)}{row.pMid.toFixed(2)}{(row.pIv * 100).toFixed(1)}%{fmtOi(row.pOi)}
+
+
+ ); +} + +interface OptionsChainProps { + S: number; + T: number; + r: number; + q: number; + atmSigma: number; + expirySeed: number; + selectedK: number; + type: OptionType; + onPick: (K: number) => void; + compact?: boolean; +} + +export function OptionsChain({ S, T, r, q, atmSigma, expirySeed, selectedK, type, onPick, compact }: OptionsChainProps) { + const rows = useMemo(() => buildChain(S, T, r, q, atmSigma, expirySeed), [S, T, r, q, atmSigma, expirySeed]); + const atmStrike = useMemo(() => findAtmStrike(rows.map(rw => rw.K), S), [rows, S]); + + return ( + + ); +} 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 + + )} + +
+ ); +} diff --git a/frontend/components/prism/options/OptionsPage.tsx b/frontend/components/prism/options/OptionsPage.tsx new file mode 100644 index 0000000..7fcf5c3 --- /dev/null +++ b/frontend/components/prism/options/OptionsPage.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { bsSynthIV } from "@/lib/blackScholes"; +import type { TickerOverview } from "@/types/api"; +import type { OptionInputs } from "./types"; +import { EXPIRIES } from "./types"; +import { Pricer, SolvePanel } from "./OptionsPricer"; +import { OptionsChain } from "./OptionsChain"; +import { SmileChart, TermStructure, GreekMini, Payoff } from "./OptionsCharts"; +import { PolarSmile, IvHeatmap } from "./OptionsSurface"; + +interface OptionsPageProps { + overview: TickerOverview | null; + ticker: string; +} + +export function OptionsPage({ overview, ticker }: OptionsPageProps) { + const [view, setView] = useState<'terminal' | 'surface'>('terminal'); + const [expiryIdx, setExpiryIdx] = useState(1); + + const spot = overview?.quote.price ?? 0; + const chgAbs = overview?.quote.change ?? 0; + const chgPct = overview?.quote.change_pct ?? 0; + const r = 0.0425; + const q = overview?.ratios.dividend_yield_ttm ?? 0; + const atmSigma30 = 0.243; + const sym = overview?.profile.symbol ?? ticker; + const name = overview?.profile.name ?? ""; + + const expiry = EXPIRIES[expiryIdx]; + + const [inputs, setInputs] = useState(() => ({ + S: spot || 100, + K: spot ? Math.round(spot / 5) * 5 : 100, + T: expiry.T, + r, + q, + sigma: atmSigma30, + type: 'C', + })); + + useEffect(() => { + if (spot > 0) { + setInputs(prev => ({ + ...prev, + S: spot, + K: Math.round(spot / 5) * 5, + r, + q, + })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [spot]); + + function patchInputs(partial: Partial) { + setInputs(prev => ({ ...prev, ...partial })); + } + + function selectExpiry(idx: number) { + setExpiryIdx(idx); + setInputs(prev => ({ ...prev, T: EXPIRIES[idx].T })); + } + + const atmIv = bsSynthIV(inputs.S, inputs.S, expiry.T, atmSigma30); + const d25C = 0.243 + 0.012; + const d25P = 0.243 + 0.012; + const rr25 = (d25C - d25P) * 100; + const bf25 = ((d25C + d25P) / 2 - atmIv) * 100; + const pcRatio = 0.87; + + if (!overview || spot === 0) { + return ( +
+ Options +

Select a ticker to view options

+

Load a ticker from the search bar to access the Black-Scholes pricer, option chain, vol surface, and greek visualizations.

+
+ ); + } + + const terminalView = ( +
+
+ + +
+
+ patchInputs({ K })} + /> + +
+
+ + { + const idx = EXPIRIES.findIndex(e => Math.abs(e.T - T) < 0.001); + if (idx >= 0) selectExpiry(idx); + }} + /> +
+ + + + +
+
+
+ ); + + const surfaceView = ( +
+
+ + +
+
+ + { + selectExpiry(eIdx); + patchInputs({ K }); + }} + /> +
+
+ + patchInputs({ K })} + compact + /> +
+
+ ); + + return ( +
+
+
+ Options · Black–Scholes +
+ {sym} + {name} +
+ ${spot.toFixed(2)} + = 0 ? 'pos' : 'neg'}`}> + {chgPct >= 0 ? '+' : ''}{chgAbs.toFixed(2)} · {chgPct >= 0 ? '+' : ''}{chgPct.toFixed(2)}% + +
+
+
+
+ + +
+
+ +
+ Expiry +
+ {EXPIRIES.map((e, idx) => ( + + ))} +
+
+ +
+
+ ATM IV + {(atmIv * 100).toFixed(1)}% + {expiry.label} +
+
+ 25Δ RR + = 0 ? 'gain' : 'loss'}`}>{rr25 >= 0 ? '+' : ''}{rr25.toFixed(2)}v + call skew +
+
+ 25Δ BF + {bf25 >= 0 ? '+' : ''}{bf25.toFixed(2)}v + smile +
+
+ P/C Ratio + {pcRatio.toFixed(2)} + put / call OI +
+
+ Rate r + {(r * 100).toFixed(2)}% + risk-free +
+
+ Div q + {(q * 100).toFixed(2)}% + yield +
+
+ Contract + {inputs.K.toFixed(0)} + {inputs.type === 'C' ? 'Call' : 'Put'} · {expiry.label} +
+
+ + {view === 'terminal' ? terminalView : surfaceView} +
+ ); +} diff --git a/frontend/components/prism/options/OptionsPricer.tsx b/frontend/components/prism/options/OptionsPricer.tsx new file mode 100644 index 0000000..79abe0b --- /dev/null +++ b/frontend/components/prism/options/OptionsPricer.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useMemo } from "react"; +import { bsPrice, bsGreeks, bsImpliedVol } from "@/lib/blackScholes"; +import type { OptionInputs } from "./types"; + +function fmt(n: number, d = 2): string { + return isNaN(n) || !isFinite(n) ? "—" : n.toFixed(d); +} + +function fmtPct(n: number, d = 1): string { + return isNaN(n) || !isFinite(n) ? "—" : (n * 100).toFixed(d) + "%"; +} + +function pctColor(n: number): string { + if (isNaN(n) || !isFinite(n)) return ""; + return n >= 0 ? "pos" : "neg"; +} + +interface SliderProps { + glyph: string; + label: string; + value: number; + min: number; + max: number; + step: number; + unit?: string; + displayScale?: number; + meta?: string; + onReset?: () => void; + onChange: (v: number) => void; + className?: string; +} + +function Slider({ glyph, value, min, max, step, unit, displayScale = 1, meta, onReset, onChange, className }: SliderProps) { + const displayed = value * displayScale; + return ( +
+ {glyph} + onChange(parseFloat(e.target.value) / displayScale)} + /> +
+ { + const v = parseFloat(e.target.value) / displayScale; + if (!isNaN(v)) onChange(Math.max(min, Math.min(max, v))); + }} + /> + {unit && {unit}} +
+ {(meta || onReset) && ( +
+ {meta && {meta}} + {onReset && } +
+ )} +
+ ); +} + +interface PricerProps { + inputs: OptionInputs; + spot: number; + onChange: (partial: Partial) => void; +} + +export function Pricer({ inputs, spot, onChange }: PricerProps) { + const { S, K, T, r, q, sigma, type } = inputs; + + const fair = useMemo(() => bsPrice(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]); + const g = useMemo(() => bsGreeks(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]); + + const mid = bsPrice(S, K, T, r, q, bsImpliedVol(S, K, T, r, q, fair, type), type); + const iv = bsImpliedVol(S, K, T, r, q, fair, type); + const ivPct = isNaN(iv) ? 0.5 : Math.min(1, Math.max(0, (iv - 0.04) / (1.5 - 0.04))); + const sigmaPct = Math.min(1, Math.max(0, (sigma - 0.04) / (1.5 - 0.04))); + + const dte = Math.round(T * 365); + + return ( +
+
+

Pricer

+
+ + +
+
+ +
+ onChange({ S: spot })} + onChange={v => onChange({ S: v })} + /> + onChange({ K: v })} + /> + onChange({ T: v })} + /> + onChange({ r: v })} + /> + onChange({ sigma: v })} + /> + onChange({ q: v })} + /> +
+ +
+
+
Fair Value
+
+ $ + {fmt(fair)} +
+
+ Δ {fmt(g.delta, 4)} +
+
+
+
Market Mid
+
${isNaN(mid) ? '—' : mid.toFixed(2)}
+
+
+ IV +
+
+
+
+ {fmtPct(sigma)} +
+
+ +
+
+ Δ + {fmt(g.delta, 4)} + Delta +
+
+ Γ + {fmt(g.gamma, 4)} + Gamma +
+
+ ν + {fmt(g.vega, 4)} + Vega +
+
+ Θ + {fmt(g.theta, 4)} + Theta/d +
+
+ ρ + {fmt(g.rho, 4)} + Rho +
+
+
+ ); +} + +interface SolvePanelProps { + inputs: OptionInputs; +} + +export function SolvePanel({ inputs }: SolvePanelProps) { + const { S, K, T, r, q, sigma, type } = inputs; + + const fair = useMemo(() => bsPrice(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]); + const iv = useMemo(() => bsImpliedVol(S, K, T, r, q, fair, type), [S, K, T, r, q, fair, type]); + + const intrinsic = type === 'C' ? Math.max(S - K, 0) : Math.max(K - S, 0); + const extrinsic = Math.max(0, fair - intrinsic); + const breakeven = type === 'C' ? K + fair : K - fair; + + return ( +
+
Analytics
+
+ σ + Implied Vol + {isNaN(iv) ? '—' : (iv * 100).toFixed(2) + '%'} +
+
+ + Breakeven + ${isNaN(breakeven) ? '—' : breakeven.toFixed(2)} +
+
+ + Intrinsic + ${fmt(intrinsic)} +
+
+ + Extrinsic + ${fmt(extrinsic)} +
+
+ ); +} 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 ( +
+
+

Vol Surface polar

+
+
+ + {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); + return ( + + + + {strike.toFixed(0)} + + + ); + })} + {curves.map((pts, idx) => { + const isSel = idx === selectedExpiryIdx; + if (isSel) return null; + const color = legendColors[Math.min(idx, legendColors.length - 1)]; + return ( + + ); + })} + {selCurve && ( + + )} + + + + {type === 'C' ? 'Call' : 'Put'} + ${fairVal.toFixed(2)} + +
+
+ {expiries.map((e, idx) => { + const color = legendColors[Math.min(idx, legendColors.length - 1)]; + const isSel = idx === selectedExpiryIdx; + return ( +
+
+ {e.label} +
+ ); + })} +
+
+ ); +} + +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 => ( + {K} + ))} +
+
+
+
+ ); +} diff --git a/frontend/components/prism/options/types.ts b/frontend/components/prism/options/types.ts new file mode 100644 index 0000000..fb90a7a --- /dev/null +++ b/frontend/components/prism/options/types.ts @@ -0,0 +1,47 @@ +export type OptionType = 'C' | 'P'; + +export interface Expiry { + label: string; + dte: number; + T: number; +} + +export interface OptionInputs { + S: number; + K: number; + T: number; + r: number; + q: number; + sigma: number; + type: OptionType; +} + +export interface ChainRow { + K: number; + cMid: number; pMid: number; + cIv: number; pIv: number; + cDelta: number; pDelta: number; + cOi: number; pOi: number; + cVol: number; pVol: number; +} + +export interface TickerDefaults { + sym: string; + name: string; + sector: string; + spot: number; + chgAbs: number; + chgPct: number; + r: number; + q: number; + atmSigma30: number; +} + +export const EXPIRIES: Expiry[] = [ + { label: 'Apr 19', dte: 14, T: 14 / 365 }, + { label: 'May 17', dte: 30, T: 30 / 365 }, + { label: 'Jun 21', dte: 65, T: 65 / 365 }, + { label: 'Sep 20', dte: 156, T: 156 / 365 }, + { label: "Jan '27", dte: 280, T: 280 / 365 }, + { label: "Jan '28", dte: 644, T: 644 / 365 }, +]; -- cgit v1.3-2-g0d8e