summaryrefslogtreecommitdiff
path: root/frontend/components/prism
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/components/prism')
-rw-r--r--frontend/components/prism/options/OptionsPage.tsx10
-rw-r--r--frontend/components/prism/options/OptionsSurface.tsx337
2 files changed, 211 insertions, 136 deletions
diff --git a/frontend/components/prism/options/OptionsPage.tsx b/frontend/components/prism/options/OptionsPage.tsx
index 7fcf5c3..4065d9f 100644
--- a/frontend/components/prism/options/OptionsPage.tsx
+++ b/frontend/components/prism/options/OptionsPage.tsx
@@ -150,14 +150,18 @@ export function OptionsPage({ overview, ticker }: OptionsPageProps) {
<div className="opt-col">
<PolarSmile
S={inputs.S}
+ K={inputs.K}
+ T={expiry.T}
r={r}
q={q}
atmSigma={atmSigma30}
- K={inputs.K}
- T={expiry.T}
type={inputs.type}
expiries={EXPIRIES}
- selectedExpiryIdx={expiryIdx}
+ selectedT={expiry.T}
+ onPickT={T => {
+ const idx = EXPIRIES.findIndex(e => Math.abs(e.T - T) < 1e-6);
+ if (idx >= 0) selectExpiry(idx);
+ }}
/>
<IvHeatmap
S={inputs.S}
diff --git a/frontend/components/prism/options/OptionsSurface.tsx b/frontend/components/prism/options/OptionsSurface.tsx
index da55012..8984ed0 100644
--- a/frontend/components/prism/options/OptionsSurface.tsx
+++ b/frontend/components/prism/options/OptionsSurface.tsx
@@ -1,188 +1,259 @@
"use client";
import { useMemo } from "react";
-import { bsPrice, bsSynthIV } from "@/lib/blackScholes";
+import { bsPrice, bsGreeks, bsSynthIV } from "@/lib/blackScholes";
import type { Expiry, OptionType } from "./types";
interface PolarSmileProps {
S: number;
+ K: number;
+ T: number;
r: number;
q: number;
atmSigma: number;
- K: number;
- T: number;
type: OptionType;
expiries: Expiry[];
- selectedExpiryIdx: number;
+ selectedT: number;
+ onPickT: (T: number) => void;
}
-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;
+export function PolarSmile({ S, K, T, r, q, atmSigma, type, expiries, selectedT, onPickT }: PolarSmileProps) {
+ const W = 680, H = 680;
+ const cx = W / 2, cy = H / 2;
+ const rOuter = 240;
+ const eyeR = 64;
- const ivMin = 0.04, ivMax = 0.8;
- const ivRings = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
+ // Strike range ±15% moneyness
+ const N = 48;
+ const kMin = S * 0.85;
+ const kMax = S * 1.15;
- 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 strikeAt(i: number): number {
+ return kMin + (i / N) * (kMax - kMin);
+ }
- function strikeToAngle(strike: number): number {
- const norm = (strike - kMin) / (kMax - kMin);
- return -Math.PI / 2 + (norm - 0.5) * Math.PI * 1.5;
+ // Piecewise angle: puts descend LEFT (counterclockwise), calls descend RIGHT (clockwise).
+ // ATM → top (−π/2); both wings meet at BOTTOM (−3π/2 ≡ +π/2). Full closed lens.
+ function angleFor(K_: number): number {
+ if (K_ <= S) {
+ const t = (S - K_) / (S - kMin || 1);
+ return -Math.PI / 2 - t * Math.PI;
+ }
+ const t = (K_ - S) / (kMax - S || 1);
+ return -Math.PI / 2 + t * Math.PI;
}
- function ivToR(iv: number): number {
- return maxR * Math.max(0, Math.min(1, (iv - ivMin) / (ivMax - ivMin)));
+ // Strike tick labels — round multiples of a step fitted to the price range
+ const rawStep = (kMax - kMin) / 8;
+ const tickStep = rawStep >= 40 ? 50 : rawStep >= 20 ? 25 : rawStep >= 8 ? 10 : 5;
+ const tickStrikes: number[] = [];
+ for (let kk = Math.ceil(kMin / tickStep) * tickStep; kk <= kMax; kk += tickStep) {
+ tickStrikes.push(kk);
}
- function polarToXY(angle: number, radius: number): [number, number] {
- return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
+ // Dynamic IV range across all expiries → defines radial scale
+ const { ivLo, ivHi } = useMemo(() => {
+ let lo = Infinity, hi = -Infinity;
+ expiries.forEach(e => {
+ for (let i = 0; i <= N; i++) {
+ const iv = bsSynthIV(S, strikeAt(i), e.T, atmSigma);
+ if (iv < lo) lo = iv;
+ if (iv > hi) hi = iv;
+ }
+ });
+ return { ivLo: Math.max(0.05, lo - 0.02), ivHi: hi + 0.02 };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [S, atmSigma, expiries]);
+
+ // Radial scale: inner floor 60px, outer wall rOuter
+ function ivToR(iv: number): number {
+ return 60 + ((iv - ivLo) / (ivHi - ivLo)) * (rOuter - 60);
}
- const fairVal = useMemo(() => bsPrice(S, K, T, r, q, atmSigma, type), [S, K, T, r, q, atmSigma, type]);
+ // 5 evenly-spaced IV ring labels
+ const ivLabels = useMemo(
+ () => Array.from({ length: 5 }, (_, step) => ivLo + (step / 4) * (ivHi - ivLo)),
+ [ivLo, ivHi]
+ );
+ // Smooth closed curves (N+1 samples, joined with Z so wings meet at bottom)
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);
- });
+ const pts: [number, number][] = [];
+ for (let i = 0; i <= N; i++) {
+ const Kk = strikeAt(i);
+ const iv = bsSynthIV(S, Kk, e.T, atmSigma);
+ const a = angleFor(Kk);
+ const rr = ivToR(iv);
+ pts.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]);
+ }
+ return { expiry: e, pts, isSelected: Math.abs(e.T - selectedT) < 1e-6 };
});
- }, [S, atmSigma, expiries, strikes, kMin, kMax, maxR, ivMin, ivMax, cx, cy]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [S, atmSigma, expiries, selectedT, ivLo, ivHi]);
- function curvePath(pts: [number, number][]): string {
- return pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
+ function pathD(pts: [number, number][]): string {
+ const inner = pts.map(([px, py]) => `${px.toFixed(2)} ${py.toFixed(2)}`).join(' L ');
+ return `M ${inner} Z`;
}
- 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);
+ function styleFor(idx: number, isSelected: boolean) {
+ if (isSelected) return {
+ stroke: 'var(--brass-bright)', strokeWidth: 2.5, strokeDasharray: undefined,
+ fill: 'rgba(194,170,122,0.12)',
+ };
+ const strokes = ['var(--fg-1)', 'var(--fg-2)', 'var(--fg-3)', 'var(--fg-4)'];
+ const dashes = [undefined, '4 3', '5 4', '2 4'];
+ const widths = [1.4, 1.2, 1.2, 1.1];
+ const i = Math.min(3, idx);
+ return { stroke: strokes[i], strokeWidth: widths[i], strokeDasharray: dashes[i], fill: 'none' };
+ }
- const atmAngle = -Math.PI / 2;
+ // Selected expiry object
+ const selectedExpiry = expiries.find(e => Math.abs(e.T - selectedT) < 1e-6) ?? expiries[0];
- const eyeR = 28;
- const legendColors = [
- 'var(--fg-4)', 'var(--fg-3)', 'var(--fg-2)', 'var(--fg-2)', 'var(--brass)', 'var(--brass-bright)'
- ];
+ // Current K dot on selected-expiry curve
+ const curIv = bsSynthIV(S, K, selectedExpiry?.T ?? T, atmSigma);
+ const aCur = angleFor(K);
+ const rCur = ivToR(curIv);
+ const dotX = cx + Math.cos(aCur) * rCur;
+ const dotY = cy + Math.sin(aCur) * rCur;
+ const dotLabelRight = aCur > -Math.PI / 2 && aCur < Math.PI / 2;
+
+ // Fair value + greeks for selected K & expiry
+ const selT = selectedExpiry?.T ?? T;
+ const fair = useMemo(() => bsPrice(S, K, selT, r, q, atmSigma, type), [S, K, selT, r, q, atmSigma, type]);
+ const gr = useMemo(() => bsGreeks(S, K, selT, r, q, atmSigma, type), [S, K, selT, r, q, atmSigma, type]);
return (
<div className="opt-surface">
<div className="head">
<h3>Vol Surface <em>polar</em></h3>
</div>
+ <div className="opt-surface-legend">
+ {expiries.map((e, idx) => {
+ const isSel = Math.abs(e.T - selectedT) < 1e-6;
+ const s = styleFor(idx, isSel);
+ return (
+ <div key={e.label}
+ className={`item${isSel ? '' : ' muted'}`}
+ onClick={() => onPickT(e.T)}
+ style={{ cursor: 'pointer' }}
+ >
+ <div className="swatch" style={{ background: s.stroke }} />
+ {e.label}
+ </div>
+ );
+ })}
+ </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);
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" className="opt-polar" style={{ display: 'block' }}>
+
+ {/* Concentric IV rings */}
+ {ivLabels.map((iv, i) => (
+ <circle key={i}
+ className={`ring${i === ivLabels.length - 1 ? ' outer' : ''}`}
+ cx={cx} cy={cy} r={ivToR(iv)} />
+ ))}
+ {ivLabels.map((iv, i) => (
+ <text key={`l${i}`} className="iv" x={cx + 6} y={(cy - ivToR(iv) - 2).toFixed(1)}>
+ {(iv * 100).toFixed(0)}%
+ </text>
+ ))}
+
+ {/* Spoke lines + round strike tick labels */}
+ {tickStrikes.map(k => {
+ const a = angleFor(k);
+ const isAtm = Math.abs(k - S) < 2.5;
+ const x2 = cx + Math.cos(a) * rOuter;
+ const y2 = cy + Math.sin(a) * rOuter;
+ const lx = cx + Math.cos(a) * (rOuter + 18);
+ const ly = cy + Math.sin(a) * (rOuter + 18);
return (
- <g key={i}>
+ <g key={k}>
<line
- x1={x1.toFixed(1)} y1={y1.toFixed(1)}
+ className={`spoke${isAtm ? ' atm' : ''}`}
+ x1={cx} y1={cy}
x2={x2.toFixed(1)} y2={y2.toFixed(1)}
- className={isAtm ? 'spoke atm' : 'spoke'}
+ style={{ opacity: isAtm ? 1 : 0.35 }}
/>
- <text
- x={(cx + (maxR + 14) * Math.cos(angle)).toFixed(1)}
- y={(cy + (maxR + 14) * Math.sin(angle)).toFixed(1)}
+ <text x={lx.toFixed(1)} y={(ly + 3).toFixed(1)}
textAnchor="middle"
- dominantBaseline="middle"
- className={isAtm ? 'tick atm' : 'tick'}
- fontSize="9"
- >
- {strike.toFixed(0)}
+ className={`tick${isAtm ? ' atm' : ''}`}>
+ {k.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)];
+
+ {/* Directional labels */}
+ <text x={(cx - rOuter - 6).toFixed(1)} y={cy} textAnchor="end"
+ style={{ fontFamily: 'var(--font-sans)', fontSize: 10, letterSpacing: 'var(--tr-wider)', textTransform: 'uppercase', fill: 'var(--negative)' }}>
+ OTM puts ↓
+ </text>
+ <text x={(cx + rOuter + 6).toFixed(1)} y={cy} textAnchor="start"
+ style={{ fontFamily: 'var(--font-sans)', fontSize: 10, letterSpacing: 'var(--tr-wider)', textTransform: 'uppercase', fill: 'var(--positive)' }}>
+ ↓ OTM calls
+ </text>
+ <text x={cx} y={(cy - rOuter - 24).toFixed(1)} textAnchor="middle"
+ style={{ fontFamily: 'var(--font-sans)', fontSize: 10, letterSpacing: 'var(--tr-wider)', textTransform: 'uppercase', fill: 'var(--brass)' }}>
+ ATM ↑
+ </text>
+
+ {/* Expiry curves — closed lens shape */}
+ {curves.map((c, idx) => {
+ const s = styleFor(idx, c.isSelected);
+ const d = pathD(c.pts);
return (
- <path
- key={idx}
- d={curvePath(pts)}
- className="expiry"
- stroke={color}
- strokeWidth="1"
- opacity="0.5"
- />
+ <g key={idx}>
+ {c.isSelected && (
+ <path d={d} className="expiry-fill" fill={s.fill} stroke="none" />
+ )}
+ <path d={d} className="expiry"
+ stroke={s.stroke}
+ strokeWidth={s.strokeWidth}
+ strokeDasharray={s.strokeDasharray}
+ fill="none"
+ style={{ cursor: 'pointer' }}
+ onClick={() => onPickT(c.expiry.T)}
+ />
+ </g>
);
})}
- {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"
- />
+
+ {/* Dashed spoke from center to selected-K marker */}
+ <line className="marker-line" x1={cx} y1={cy} x2={dotX.toFixed(1)} y2={dotY.toFixed(1)} />
+
+ {/* Selected-K dot */}
+ <circle cx={dotX.toFixed(1)} cy={dotY.toFixed(1)} r={6} className="dot" />
+
+ {/* K label */}
+ <text
+ x={(dotX + (dotLabelRight ? 10 : -10)).toFixed(1)}
+ y={(dotY + 4).toFixed(1)}
+ textAnchor={dotLabelRight ? 'start' : 'end'}
+ className="brass" style={{ fontSize: 12 }}
+ >
+ K {K.toFixed(0)} · {(curIv * 100).toFixed(1)}%
+ </text>
+
+ {/* Center eye */}
<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>
+ <text x={cx} y={(cy - 30).toFixed(1)} textAnchor="middle" className="eye-lbl">
+ {type === 'C' ? 'call · fair' : 'put · fair'}
+ </text>
+ <text x={cx} y={(cy - 4).toFixed(1)} textAnchor="middle" className="eye-num" style={{ fontSize: 30 }}>
+ {fair.toFixed(2)}
+ </text>
+ <text x={cx} y={(cy + 16).toFixed(1)} textAnchor="middle"
+ style={{ fontFamily: 'var(--font-mono)', fontSize: 10, fill: 'var(--fg-2)' }}>
+ Δ {gr.delta.toFixed(2)} · ν {gr.vega.toFixed(2)}
+ </text>
+ <text x={cx} y={(cy + 32).toFixed(1)} textAnchor="middle" className="eye-lbl" style={{ fontSize: 9 }}>
+ {selectedExpiry?.label} · {selectedExpiry?.dte}d
+ </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>
);
}
@@ -261,8 +332,8 @@ export function IvHeatmap({ S, atmSigma, expiries, selectedExpiryIdx, selectedK,
)}
</div>
<div className="xlabs" style={{ gridTemplateColumns: `repeat(${nStrikes}, 1fr)` }}>
- {strikes.map(K => (
- <span key={K} className={K === atmK ? 'atm' : ''}>{K}</span>
+ {strikes.map((K, i) => (
+ <span key={i} className={K === atmK ? 'atm' : ''}>{K}</span>
))}
</div>
</div>