import { useMemo } from "react"; import { EmptyState, KeyValueRow, StaticChartSurface, type StaticChartOverlay } from "../../../components"; import type { ProjectedChartPoint } from "../../../components/chart/core/data"; import { resolveChartPalette } from "../../../components/chart/core/palette"; import { useThemeColors } from "../../../theme/theme-context"; import { Box, ScrollBox, Text } from "../../../ui"; import { evaluateSmile, optionDelta } from "../shared/volatility"; import { buildSurfaceGrid, evaluateSurfaceSmile, type SurfaceExpiry, type SurfaceSnapshot } from "./model"; export const formatIv = (value: number | null | undefined) => value == null || !Number.isFinite(value) ? "--" : `${(value * 100).toFixed(2)}%`; export const formatPrice = (value: number | null | undefined) => value == null || !Number.isFinite(value) ? "--" : value.toFixed(2); export const expiryLabel = (expiration: number) => new Date(expiration * 1000).toISOString().slice(0, 10); const point = (x: number, y: number): ProjectedChartPoint => ({ date: new Date(Math.round(x * 1_000_000)), open: y, high: y, low: y, close: y, volume: 0 }); export function SmileChart({ snapshot, expiry, overlay, axis, width, height, focused = false }: { snapshot: SurfaceSnapshot; expiry: SurfaceExpiry | null; overlay: boolean; axis: string; width: number; height: number; focused?: boolean; }) { const colors = useThemeColors(); const palette = resolveChartPalette(colors); const model = useMemo(() => { if (!expiry?.fit || !expiry.forward || expiry.points.length < 2) return null; const strikes = expiry.points.map((entry) => entry.strike).sort((a, b) => a - b); const min = strikes[0]!, max = strikes.at(-1)!; const grid = [...new Set([...strikes, ...Array.from({ length: 81 }, (_, i) => min + (max - min) * i / 80)])].sort((a, b) => a - b); const x = (strike: number) => { if (axis === "strike") return strike; if (axis === "delta") { const volatility = evaluateSmile(expiry.fit!, Math.log(strike / expiry.forward!)); const delta = volatility == null ? null : optionDelta({ spot: snapshot.spot, years: expiry.years, volatility, rate: expiry.rate ?? 0, dividendYield: expiry.dividendYield ?? 0 }, strike, "call"); return delta == null ? NaN : (1 - delta) * 100; } return strike / (axis === "forward" ? expiry.forward! : snapshot.spot) * 100; }; const observations = grid.flatMap((strike) => { const volatility = evaluateSmile(expiry.fit!, Math.log(strike / expiry.forward!)); const coordinate = x(strike); return volatility == null || !Number.isFinite(coordinate) ? [] : [{ strike, x: coordinate, iv: volatility }]; }).sort((a, b) => a.x - b.x); const raw: StaticChartOverlay = { id: "Clean quotes", color: colors.warning, style: "points", points: [] }; observations.forEach((observation, index) => { const quote = expiry.points.find((entry) => entry.strike === observation.strike); if (quote) (raw.points as {index:number;value:number}[]).push({ index, value: quote.volatility * 100 }); }); const overlays: StaticChartOverlay[] = [raw]; if (overlay) { const peers = snapshot.expiries.filter((entry) => entry.fit && entry.forward && entry.expiration !== expiry.expiration) .sort((a, b) => Math.abs(a.years - expiry.years) - Math.abs(b.years - expiry.years)).slice(0, 2); peers.forEach((peer, peerIndex) => overlays.push({ id: expiryLabel(peer.expiration), color: peerIndex ? colors.negative : colors.textDim, points: observations.flatMap((observation, index) => { const strike = axis === "forward" ? observation.x / 100 * peer.forward! : axis === "delta" ? buildSurfaceGrid({ ...snapshot, expiries: [peer] }, { axis: "delta", coordinates: [1 - observation.x / 100], }).rows[0]?.cells[0]?.strike ?? NaN : observation.strike; const value = evaluateSurfaceSmile(peer, strike); return value == null ? [] : [{ index, value: value * 100 }]; }) })); } const left = observations[0]?.x ?? 0, right = observations.at(-1)?.x ?? 1; return { points: observations.map((entry) => point(entry.x, entry.iv * 100)), overlays, ticks: Array.from({ length: 5 }, (_, i) => ({ ratio: i / 4, label: `${(left + (right - left) * i / 4).toFixed(axis === "strike" ? 2 : 0)}${axis === "spot" || axis === "forward" ? "%" : ""}` })), left, right }; }, [axis, colors, expiry, overlay, snapshot]); if (!model) return ; const axisLabel = axis === "strike" ? "Strike" : axis === "delta" ? "100 x (1 - call delta)" : `${axis === "forward" ? "Forward" : "Spot"} %`; return Fitted smile Clean quotes {model.overlays.filter((entry) => entry.id !== "Clean quotes").map((entry) => {entry.id})} {`IV % · ${axisLabel}`} `${value.toFixed(1)}%`} xAxisTicks={model.ticks} formatXAxisCursorValue={(ratio) => `${axisLabel} ${(model.left + ratio * (model.right - model.left)).toFixed(2)}`} focused={focused} /> ; } export function TermChart({ snapshot, width, height, focused = false }: { snapshot: SurfaceSnapshot; width: number; height: number; focused?: boolean }) { const colors = useThemeColors(); const rows = snapshot.expiries.filter((expiry) => expiry.atmIV != null).sort((a, b) => a.years - b.years); if (!rows.length) return ; // Log tenor spacing keeps daily front expiries readable next to LEAPS. const scale = (days: number) => Math.log(Math.max(days, 0.25)); const first = scale(rows[0]!.years * 365), last = scale(rows.at(-1)!.years * 365); const dayLabel = (ratio: number) => { const days = Math.exp(first + (last - first) * ratio); return days < 10 ? `${days.toFixed(1)}d` : `${Math.round(days)}d`; }; const points = rows.map((expiry) => point(scale(expiry.years * 365), expiry.atmIV! * 100)); const overlays: StaticChartOverlay[] = [ { id: "25d put", color: colors.warning, points: rows.flatMap((expiry, index) => expiry.skew.put25 == null ? [] : [{ index, value: expiry.skew.put25 * 100 }]) }, { id: "25d call", color: colors.negative, points: rows.flatMap((expiry, index) => expiry.skew.call25 == null ? [] : [{ index, value: expiry.skew.call25 * 100 }]) }, ]; const moveHeight = Math.min(7, Math.max(2, Math.floor(height / 4))); return ATM spot25d put25d call IV % · calendar days (log) `${value.toFixed(1)}%`} xAxisTicks={Array.from({ length: 5 }, (_, i) => ({ ratio: i / 4, label: dayLabel(i / 4) }))} formatXAxisCursorValue={(ratio) => `${dayLabel(ratio)} to expiry`} focused={focused} /> {rows.map((expiry) => )} ; }