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) => )}
;
}