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/OptionsPage.tsx | 292 ++++++++++++++++++++++
 1 file changed, 292 insertions(+)
 create mode 100644 frontend/components/prism/options/OptionsPage.tsx

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

diff --git a/frontend/components/prism/options/OptionsPage.tsx b/frontend/components/prism/options/OptionsPage.tsx
new file mode 100644
index 0000000..7fcf5c3
--- /dev/null
+++ b/frontend/components/prism/options/OptionsPage.tsx
@@ -0,0 +1,292 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { bsSynthIV } from "@/lib/blackScholes";
+import type { TickerOverview } from "@/types/api";
+import type { OptionInputs } from "./types";
+import { EXPIRIES } from "./types";
+import { Pricer, SolvePanel } from "./OptionsPricer";
+import { OptionsChain } from "./OptionsChain";
+import { SmileChart, TermStructure, GreekMini, Payoff } from "./OptionsCharts";
+import { PolarSmile, IvHeatmap } from "./OptionsSurface";
+
+interface OptionsPageProps {
+  overview: TickerOverview | null;
+  ticker: string;
+}
+
+export function OptionsPage({ overview, ticker }: OptionsPageProps) {
+  const [view, setView] = useState<'terminal' | 'surface'>('terminal');
+  const [expiryIdx, setExpiryIdx] = useState(1);
+
+  const spot = overview?.quote.price ?? 0;
+  const chgAbs = overview?.quote.change ?? 0;
+  const chgPct = overview?.quote.change_pct ?? 0;
+  const r = 0.0425;
+  const q = overview?.ratios.dividend_yield_ttm ?? 0;
+  const atmSigma30 = 0.243;
+  const sym = overview?.profile.symbol ?? ticker;
+  const name = overview?.profile.name ?? "";
+
+  const expiry = EXPIRIES[expiryIdx];
+
+  const [inputs, setInputs] = useState<OptionInputs>(() => ({
+    S: spot || 100,
+    K: spot ? Math.round(spot / 5) * 5 : 100,
+    T: expiry.T,
+    r,
+    q,
+    sigma: atmSigma30,
+    type: 'C',
+  }));
+
+  useEffect(() => {
+    if (spot > 0) {
+      setInputs(prev => ({
+        ...prev,
+        S: spot,
+        K: Math.round(spot / 5) * 5,
+        r,
+        q,
+      }));
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [spot]);
+
+  function patchInputs(partial: Partial<OptionInputs>) {
+    setInputs(prev => ({ ...prev, ...partial }));
+  }
+
+  function selectExpiry(idx: number) {
+    setExpiryIdx(idx);
+    setInputs(prev => ({ ...prev, T: EXPIRIES[idx].T }));
+  }
+
+  const atmIv = bsSynthIV(inputs.S, inputs.S, expiry.T, atmSigma30);
+  const d25C = 0.243 + 0.012;
+  const d25P = 0.243 + 0.012;
+  const rr25 = (d25C - d25P) * 100;
+  const bf25 = ((d25C + d25P) / 2 - atmIv) * 100;
+  const pcRatio = 0.87;
+
+  if (!overview || spot === 0) {
+    return (
+      <section className="psm-state-panel">
+        <span className="psm-status-chip">Options</span>
+        <h1>Select a ticker to view options</h1>
+        <p>Load a ticker from the search bar to access the Black-Scholes pricer, option chain, vol surface, and greek visualizations.</p>
+      </section>
+    );
+  }
+
+  const terminalView = (
+    <div className="opt-grid">
+      <div className="opt-col">
+        <Pricer inputs={inputs} spot={spot} onChange={patchInputs} />
+        <SolvePanel inputs={inputs} />
+      </div>
+      <div className="opt-col">
+        <OptionsChain
+          S={inputs.S}
+          T={expiry.T}
+          r={r}
+          q={q}
+          atmSigma={atmSigma30}
+          expirySeed={expiryIdx}
+          selectedK={inputs.K}
+          type={inputs.type}
+          onPick={K => patchInputs({ K })}
+        />
+        <Payoff
+          S={inputs.S}
+          K={inputs.K}
+          T={inputs.T}
+          r={inputs.r}
+          q={inputs.q}
+          sigma={inputs.sigma}
+          type={inputs.type}
+        />
+      </div>
+      <div className="opt-col">
+        <SmileChart
+          S={inputs.S}
+          T={expiry.T}
+          r={r}
+          q={q}
+          atmSigma={atmSigma30}
+          K={inputs.K}
+          type={inputs.type}
+          expiryLabel={expiry.label}
+          allExpiries={EXPIRIES}
+        />
+        <TermStructure
+          S={inputs.S}
+          r={r}
+          q={q}
+          atmSigma={atmSigma30}
+          expiries={EXPIRIES}
+          selectedT={expiry.T}
+          onPickT={T => {
+            const idx = EXPIRIES.findIndex(e => Math.abs(e.T - T) < 0.001);
+            if (idx >= 0) selectExpiry(idx);
+          }}
+        />
+        <div className="opt-greek-multi">
+          <GreekMini name="Delta" glyph="&#916;" kind="delta" S={inputs.S} K={inputs.K} T={inputs.T} r={inputs.r} q={inputs.q} sigma={inputs.sigma} type={inputs.type} />
+          <GreekMini name="Gamma" glyph="&#915;" kind="gamma" S={inputs.S} K={inputs.K} T={inputs.T} r={inputs.r} q={inputs.q} sigma={inputs.sigma} type={inputs.type} />
+          <GreekMini name="Vega" glyph="&#957;" kind="vega" S={inputs.S} K={inputs.K} T={inputs.T} r={inputs.r} q={inputs.q} sigma={inputs.sigma} type={inputs.type} />
+          <GreekMini name="Theta" glyph="&#920;" kind="theta" S={inputs.S} K={inputs.K} T={inputs.T} r={inputs.r} q={inputs.q} sigma={inputs.sigma} type={inputs.type} />
+        </div>
+      </div>
+    </div>
+  );
+
+  const surfaceView = (
+    <div className="opt-grid">
+      <div className="opt-col">
+        <Pricer inputs={inputs} spot={spot} onChange={patchInputs} />
+        <SolvePanel inputs={inputs} />
+      </div>
+      <div className="opt-col">
+        <PolarSmile
+          S={inputs.S}
+          r={r}
+          q={q}
+          atmSigma={atmSigma30}
+          K={inputs.K}
+          T={expiry.T}
+          type={inputs.type}
+          expiries={EXPIRIES}
+          selectedExpiryIdx={expiryIdx}
+        />
+        <IvHeatmap
+          S={inputs.S}
+          atmSigma={atmSigma30}
+          expiries={EXPIRIES}
+          selectedExpiryIdx={expiryIdx}
+          selectedK={inputs.K}
+          onSelect={(eIdx, K) => {
+            selectExpiry(eIdx);
+            patchInputs({ K });
+          }}
+        />
+      </div>
+      <div className="opt-col">
+        <Payoff
+          S={inputs.S}
+          K={inputs.K}
+          T={inputs.T}
+          r={inputs.r}
+          q={inputs.q}
+          sigma={inputs.sigma}
+          type={inputs.type}
+        />
+        <OptionsChain
+          S={inputs.S}
+          T={expiry.T}
+          r={r}
+          q={q}
+          atmSigma={atmSigma30}
+          expirySeed={expiryIdx}
+          selectedK={inputs.K}
+          type={inputs.type}
+          onPick={K => patchInputs({ K })}
+          compact
+        />
+      </div>
+    </div>
+  );
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)' }}>
+      <div className="opt-header">
+        <div className="ticker">
+          <span className="tab-eyebrow">Options · Black&ndash;Scholes</span>
+          <div className="ticker-row">
+            <span className="sym">{sym}</span>
+            <span className="name">{name}</span>
+            <div className="px-block">
+              <span className="px">${spot.toFixed(2)}</span>
+              <span className={`chg ${chgPct >= 0 ? 'pos' : 'neg'}`}>
+                {chgPct >= 0 ? '+' : ''}{chgAbs.toFixed(2)} &middot; {chgPct >= 0 ? '+' : ''}{chgPct.toFixed(2)}%
+              </span>
+            </div>
+          </div>
+        </div>
+        <div className="opt-view" role="tablist">
+          <button
+            type="button"
+            className={view === 'terminal' ? 'active' : ''}
+            onClick={() => setView('terminal')}
+          >
+            <span className="glyph">&#9638;</span> Terminal
+          </button>
+          <button
+            type="button"
+            className={view === 'surface' ? 'active' : ''}
+            onClick={() => setView('surface')}
+          >
+            <span className="glyph">&#9908;</span> Surface
+          </button>
+        </div>
+      </div>
+
+      <div className="opt-expiry-bar">
+        <span className="lbl">Expiry</span>
+        <div className="opt-expiries">
+          {EXPIRIES.map((e, idx) => (
+            <button
+              key={e.label}
+              type="button"
+              className={`opt-exp-chip${idx === expiryIdx ? ' active' : ''}`}
+              onClick={() => selectExpiry(idx)}
+            >
+              {e.label}
+              <span className="dte">{e.dte}d</span>
+            </button>
+          ))}
+        </div>
+      </div>
+
+      <div className="opt-strip">
+        <div>
+          <span className="k">ATM IV</span>
+          <span className="v accent">{(atmIv * 100).toFixed(1)}%</span>
+          <span className="s">{expiry.label}</span>
+        </div>
+        <div>
+          <span className="k">25&Delta; RR</span>
+          <span className={`v ${rr25 >= 0 ? 'gain' : 'loss'}`}>{rr25 >= 0 ? '+' : ''}{rr25.toFixed(2)}v</span>
+          <span className="s">call skew</span>
+        </div>
+        <div>
+          <span className="k">25&Delta; BF</span>
+          <span className="v">{bf25 >= 0 ? '+' : ''}{bf25.toFixed(2)}v</span>
+          <span className="s">smile</span>
+        </div>
+        <div>
+          <span className="k">P/C Ratio</span>
+          <span className="v">{pcRatio.toFixed(2)}</span>
+          <span className="s">put / call OI</span>
+        </div>
+        <div>
+          <span className="k">Rate r</span>
+          <span className="v">{(r * 100).toFixed(2)}%</span>
+          <span className="s">risk-free</span>
+        </div>
+        <div>
+          <span className="k">Div q</span>
+          <span className="v">{(q * 100).toFixed(2)}%</span>
+          <span className="s">yield</span>
+        </div>
+        <div>
+          <span className="k">Contract</span>
+          <span className="v">{inputs.K.toFixed(0)}</span>
+          <span className="s">{inputs.type === 'C' ? 'Call' : 'Put'} · {expiry.label}</span>
+        </div>
+      </div>
+
+      {view === 'terminal' ? terminalView : surfaceView}
+    </div>
+  );
+}
-- 
cgit v1.3-2-g0d8e

