From 2222d8a5fac6850d7d924e9404a44a65cb10f68f Mon Sep 17 00:00:00 2001
From: Tyler Hoang <tyler@tylerhoang.xyz>
Date: Thu, 21 May 2026 14:49:56 -0700
Subject: fix: rewrite polar smile with correct closed-lens geometry
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Piecewise angleFor: puts go CCW (ATM→bottom), calls go CW (ATM→bottom)
  so both wings meet at the same bottom point and the path closes with Z
- Dynamic ivToR computed from actual IV range across all expiries
  (inner floor 60px, outer wall 240px) instead of hardcoded percentages
- IV rings: 5 evenly-spaced steps between real ivLo/ivHi
- Curve fill: rgba(194,170,122,0.12) matching design reference
- Non-selected curves: index-based styles (solid fg-1, then dashed fg-2/3/4)
- Eye price at font-size 30, label lowercase 'call · fair'
- Dashed marker-line spoke from center to selected-K dot
- Clicking a curve switches the selected expiry via onPickT
- Strike ticks use round multiples of a price-adaptive step
- Fix IvHeatmap xlabs duplicate key error (key by index, not rounded K)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 .../components/prism/options/OptionsSurface.tsx    | 337 +++++++++++++--------
 1 file changed, 204 insertions(+), 133 deletions(-)

(limited to 'frontend/components/prism/options/OptionsSurface.tsx')

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>
-- 
cgit v1.3-2-g0d8e

