From 25360aacb8aab46e7e579707eb9704759af9536d Mon Sep 17 00:00:00 2001
From: Tyler Hoang <tyler@tylerhoang.xyz>
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 <noreply@anthropic.com>
---
 frontend/components/prism/options/OptionsChain.tsx | 157 +++++++++++++++++++++
 1 file changed, 157 insertions(+)
 create mode 100644 frontend/components/prism/options/OptionsChain.tsx

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

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 (
+    <div className={`opt-chain-wrap${compact ? ' compact' : ''}`}>
+      <div className="opt-chain-head">
+        <h3>Option Chain</h3>
+        <span className="sub">{compact ? 'Compact' : 'Full'} · {rows.length} strikes</span>
+      </div>
+      <div className="opt-chain-scroll">
+        <table className="opt-chain">
+          <thead>
+            <tr>
+              {!compact && <th className="group side-c" colSpan={2} style={{ textAlign: 'center' }}>— calls —</th>}
+              {compact && <th className="group side-c" colSpan={3} style={{ textAlign: 'center' }}>— calls —</th>}
+              <th className="group k" style={{ textAlign: 'center' }}>strike</th>
+              {!compact && <th className="group side-p" colSpan={2} style={{ textAlign: 'center' }}>— puts —</th>}
+              {compact && <th className="group side-p" colSpan={3} style={{ textAlign: 'center' }}>— puts —</th>}
+            </tr>
+            {!compact ? (
+              <tr>
+                <th className="side-c">OI</th>
+                <th className="side-c">IV</th>
+                <th className="side-c">last</th>
+                <th className="side-c">&Delta;</th>
+                <th className="k">K</th>
+                <th className="side-p">&Delta;</th>
+                <th className="side-p">last</th>
+                <th className="side-p">IV</th>
+                <th className="side-p">OI</th>
+              </tr>
+            ) : (
+              <tr>
+                <th className="side-c">IV</th>
+                <th className="side-c">last</th>
+                <th className="side-c">&Delta;</th>
+                <th className="k">K</th>
+                <th className="side-p">&Delta;</th>
+                <th className="side-p">last</th>
+                <th className="side-p">IV</th>
+              </tr>
+            )}
+          </thead>
+          <tbody>
+            {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 (
+                <tr
+                  key={row.K}
+                  className={`${isAtm ? 'atm' : ''} ${isSel ? 'selected' : ''}`}
+                  onClick={() => onPick(row.K)}
+                >
+                  {!compact && <td className="dim">{fmtOi(row.cOi)}</td>}
+                  <td className="iv">{(row.cIv * 100).toFixed(1)}%</td>
+                  <td className={cItm ? 'itm' : 'otm'}>{row.cMid.toFixed(2)}</td>
+                  <td className={cItm ? 'itm' : 'otm'}>{row.cDelta.toFixed(2)}</td>
+                  <td className="k">{row.K.toFixed(0)}</td>
+                  <td className={pItm ? 'itm' : 'otm'}>{row.pDelta.toFixed(2)}</td>
+                  <td className={pItm ? 'itm' : 'otm'}>{row.pMid.toFixed(2)}</td>
+                  <td className="iv">{(row.pIv * 100).toFixed(1)}%</td>
+                  {!compact && <td className="dim">{fmtOi(row.pOi)}</td>}
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+}
+
+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 (
+    <ChainTable
+      rows={rows}
+      atmStrike={atmStrike}
+      selectedK={selectedK}
+      type={type}
+      onPick={onPick}
+      compact={compact}
+    />
+  );
+}
-- 
cgit v1.3-2-g0d8e

