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

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

diff --git a/frontend/components/prism/options/OptionsPricer.tsx b/frontend/components/prism/options/OptionsPricer.tsx
new file mode 100644
index 0000000..79abe0b
--- /dev/null
+++ b/frontend/components/prism/options/OptionsPricer.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import { useMemo } from "react";
+import { bsPrice, bsGreeks, bsImpliedVol } from "@/lib/blackScholes";
+import type { OptionInputs } from "./types";
+
+function fmt(n: number, d = 2): string {
+  return isNaN(n) || !isFinite(n) ? "—" : n.toFixed(d);
+}
+
+function fmtPct(n: number, d = 1): string {
+  return isNaN(n) || !isFinite(n) ? "—" : (n * 100).toFixed(d) + "%";
+}
+
+function pctColor(n: number): string {
+  if (isNaN(n) || !isFinite(n)) return "";
+  return n >= 0 ? "pos" : "neg";
+}
+
+interface SliderProps {
+  glyph: string;
+  label: string;
+  value: number;
+  min: number;
+  max: number;
+  step: number;
+  unit?: string;
+  displayScale?: number;
+  meta?: string;
+  onReset?: () => void;
+  onChange: (v: number) => void;
+  className?: string;
+}
+
+function Slider({ glyph, value, min, max, step, unit, displayScale = 1, meta, onReset, onChange, className }: SliderProps) {
+  const displayed = value * displayScale;
+  return (
+    <div className={`opt-slide${className ? ' ' + className : ''}`}>
+      <span className="g">{glyph}</span>
+      <input
+        type="range"
+        min={min * displayScale}
+        max={max * displayScale}
+        step={step * displayScale}
+        value={displayed}
+        onChange={e => onChange(parseFloat(e.target.value) / displayScale)}
+      />
+      <div className="val">
+        <input
+          type="number"
+          min={min * displayScale}
+          max={max * displayScale}
+          step={step * displayScale}
+          value={displayed}
+          onChange={e => {
+            const v = parseFloat(e.target.value) / displayScale;
+            if (!isNaN(v)) onChange(Math.max(min, Math.min(max, v)));
+          }}
+        />
+        {unit && <span className="unit">{unit}</span>}
+      </div>
+      {(meta || onReset) && (
+        <div className="meta">
+          {meta && <span>{meta}</span>}
+          {onReset && <button type="button" onClick={onReset}>RESET</button>}
+        </div>
+      )}
+    </div>
+  );
+}
+
+interface PricerProps {
+  inputs: OptionInputs;
+  spot: number;
+  onChange: (partial: Partial<OptionInputs>) => void;
+}
+
+export function Pricer({ inputs, spot, onChange }: PricerProps) {
+  const { S, K, T, r, q, sigma, type } = inputs;
+
+  const fair = useMemo(() => bsPrice(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]);
+  const g = useMemo(() => bsGreeks(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]);
+
+  const mid = bsPrice(S, K, T, r, q, bsImpliedVol(S, K, T, r, q, fair, type), type);
+  const iv = bsImpliedVol(S, K, T, r, q, fair, type);
+  const ivPct = isNaN(iv) ? 0.5 : Math.min(1, Math.max(0, (iv - 0.04) / (1.5 - 0.04)));
+  const sigmaPct = Math.min(1, Math.max(0, (sigma - 0.04) / (1.5 - 0.04)));
+
+  const dte = Math.round(T * 365);
+
+  return (
+    <div className="opt-pricer">
+      <div className="head">
+        <h3>Pricer</h3>
+        <div className="opt-cp">
+          <button
+            type="button"
+            className={`${type === 'C' ? 'active C' : ''}`}
+            onClick={() => onChange({ type: 'C' })}
+          >CALL</button>
+          <button
+            type="button"
+            className={`${type === 'P' ? 'active P' : ''}`}
+            onClick={() => onChange({ type: 'P' })}
+          >PUT</button>
+        </div>
+      </div>
+
+      <div className="opt-sliders">
+        <Slider
+          glyph="S"
+          label="Spot"
+          value={S}
+          min={spot * 0.5}
+          max={spot * 2}
+          step={0.01}
+          unit="$"
+          meta={`spot ${spot.toFixed(2)}`}
+          onReset={() => onChange({ S: spot })}
+          onChange={v => onChange({ S: v })}
+        />
+        <Slider
+          glyph="K"
+          label="Strike"
+          value={K}
+          min={spot * 0.5}
+          max={spot * 2}
+          step={1}
+          unit="$"
+          onChange={v => onChange({ K: v })}
+        />
+        <Slider
+          glyph="T"
+          label="Days to Expiry"
+          value={T}
+          min={1 / 365}
+          max={644 / 365}
+          step={1 / 365}
+          displayScale={365}
+          unit="d"
+          meta={`${dte}d`}
+          onChange={v => onChange({ T: v })}
+        />
+        <Slider
+          glyph="r"
+          label="Risk-free Rate"
+          value={r}
+          min={0}
+          max={0.15}
+          step={0.0001}
+          displayScale={100}
+          unit="%"
+          onChange={v => onChange({ r: v })}
+        />
+        <Slider
+          glyph="&sigma;"
+          label="Volatility"
+          value={sigma}
+          min={0.01}
+          max={1.5}
+          step={0.001}
+          displayScale={100}
+          unit="%"
+          onChange={v => onChange({ sigma: v })}
+        />
+        <Slider
+          glyph="q"
+          label="Dividend Yield"
+          value={q}
+          min={0}
+          max={0.15}
+          step={0.0001}
+          displayScale={100}
+          unit="%"
+          onChange={v => onChange({ q: v })}
+        />
+      </div>
+
+      <div className="opt-output">
+        <div>
+          <div className="fair-lbl">Fair Value</div>
+          <div className="fair">
+            <span className="cur">$</span>
+            {fmt(fair)}
+          </div>
+          <div className={`delta ${pctColor(g.delta)}`}>
+            &Delta; {fmt(g.delta, 4)}
+          </div>
+        </div>
+        <div>
+          <div className="mid-lbl">Market Mid</div>
+          <div className="mid">${isNaN(mid) ? '—' : mid.toFixed(2)}</div>
+        </div>
+        <div className="iv-bar">
+          <span className="lbl">IV</span>
+          <div className="iv-track">
+            <div className="iv-mkt" style={{ left: `${ivPct * 100}%` }} />
+            <div className="iv-solved" style={{ left: `${sigmaPct * 100}%` }} />
+          </div>
+          <span className="iv-val">{fmtPct(sigma)}</span>
+        </div>
+      </div>
+
+      <div className="opt-greeks">
+        <div className="opt-greek">
+          <span className="g">&Delta;</span>
+          <span className={`v${g.delta < 0 ? ' neg' : ''}`}>{fmt(g.delta, 4)}</span>
+          <span className="n">Delta</span>
+        </div>
+        <div className="opt-greek">
+          <span className="g">&Gamma;</span>
+          <span className="v">{fmt(g.gamma, 4)}</span>
+          <span className="n">Gamma</span>
+        </div>
+        <div className="opt-greek">
+          <span className="g">&nu;</span>
+          <span className="v">{fmt(g.vega, 4)}</span>
+          <span className="n">Vega</span>
+        </div>
+        <div className="opt-greek">
+          <span className="g">&Theta;</span>
+          <span className={`v${g.theta < 0 ? ' neg' : ''}`}>{fmt(g.theta, 4)}</span>
+          <span className="n">Theta/d</span>
+        </div>
+        <div className="opt-greek">
+          <span className="g">&rho;</span>
+          <span className={`v${g.rho < 0 ? ' neg' : ''}`}>{fmt(g.rho, 4)}</span>
+          <span className="n">Rho</span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+interface SolvePanelProps {
+  inputs: OptionInputs;
+}
+
+export function SolvePanel({ inputs }: SolvePanelProps) {
+  const { S, K, T, r, q, sigma, type } = inputs;
+
+  const fair = useMemo(() => bsPrice(S, K, T, r, q, sigma, type), [S, K, T, r, q, sigma, type]);
+  const iv = useMemo(() => bsImpliedVol(S, K, T, r, q, fair, type), [S, K, T, r, q, fair, type]);
+
+  const intrinsic = type === 'C' ? Math.max(S - K, 0) : Math.max(K - S, 0);
+  const extrinsic = Math.max(0, fair - intrinsic);
+  const breakeven = type === 'C' ? K + fair : K - fair;
+
+  return (
+    <div className="opt-solve">
+      <div className="head">Analytics</div>
+      <div className="row">
+        <span className="g">&sigma;</span>
+        <span className="l">Implied Vol</span>
+        <span className="v">{isNaN(iv) ? '—' : (iv * 100).toFixed(2) + '%'}</span>
+      </div>
+      <div className="row">
+        <span className="g">&#x2195;</span>
+        <span className="l">Breakeven</span>
+        <span className="v">${isNaN(breakeven) ? '—' : breakeven.toFixed(2)}</span>
+      </div>
+      <div className="row">
+        <span className="g">&#x25C6;</span>
+        <span className="l">Intrinsic</span>
+        <span className="v">${fmt(intrinsic)}</span>
+      </div>
+      <div className="row">
+        <span className="g">&#x25C7;</span>
+        <span className="l">Extrinsic</span>
+        <span className="v">${fmt(extrinsic)}</span>
+      </div>
+    </div>
+  );
+}
-- 
cgit v1.3-2-g0d8e

