summaryrefslogtreecommitdiff
path: root/frontend/components/prism/options/OptionsPage.tsx
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-20 00:22:32 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-20 00:22:32 -0700
commit25360aacb8aab46e7e579707eb9704759af9536d (patch)
tree028f654f97dc23c7bc088bc3b625185f4fb71287 /frontend/components/prism/options/OptionsPage.tsx
parent68b4f52829cdb2d6951faf8037fb002083ebd0a5 (diff)
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>
Diffstat (limited to 'frontend/components/prism/options/OptionsPage.tsx')
-rw-r--r--frontend/components/prism/options/OptionsPage.tsx292
1 files changed, 292 insertions, 0 deletions
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>
+ );
+}