summaryrefslogtreecommitdiff
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
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>
-rw-r--r--frontend/app/globals.css1
-rw-r--r--frontend/app/options.css490
-rw-r--r--frontend/app/page.tsx87
-rw-r--r--frontend/components/prism/options/OptionsChain.tsx157
-rw-r--r--frontend/components/prism/options/OptionsCharts.tsx335
-rw-r--r--frontend/components/prism/options/OptionsPage.tsx292
-rw-r--r--frontend/components/prism/options/OptionsPricer.tsx274
-rw-r--r--frontend/components/prism/options/OptionsSurface.tsx272
-rw-r--r--frontend/components/prism/options/types.ts47
-rw-r--r--frontend/lib/blackScholes.ts81
-rw-r--r--frontend/lib/overview.ts2
11 files changed, 1997 insertions, 41 deletions
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 7c4ad44..cbbf2be 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -1,3 +1,4 @@
@import "./design-tokens.css";
@import "./prism-shell.css";
+@import "./options.css";
diff --git a/frontend/app/options.css b/frontend/app/options.css
new file mode 100644
index 0000000..4055041
--- /dev/null
+++ b/frontend/app/options.css
@@ -0,0 +1,490 @@
+/* Options Tab */
+
+.opt-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: var(--sp-5);
+ padding-bottom: var(--sp-3);
+ border-bottom: 1px solid var(--line-1);
+ flex-wrap: wrap;
+}
+.opt-header .ticker {
+ display: flex; align-items: baseline; gap: var(--sp-4);
+ flex-wrap: wrap;
+}
+.opt-header .ticker-row { display: flex; align-items: baseline; gap: var(--sp-3); flex-wrap: wrap; }
+.opt-header .sym {
+ font-family: var(--font-display); font-size: var(--fs-48);
+ font-weight: 500; color: var(--fg-1); line-height: 0.95;
+ letter-spacing: -0.025em;
+}
+.opt-header .name {
+ font-family: var(--font-display); font-style: italic;
+ font-size: var(--fs-18); color: var(--fg-2); font-weight: 400;
+ line-height: 1;
+}
+.opt-header .tab-eyebrow {
+ font-family: var(--font-sans); font-size: var(--fs-12);
+ text-transform: uppercase; letter-spacing: var(--tr-wider);
+ color: var(--brass); font-weight: 600;
+ display: block;
+}
+.opt-header .px-block { display: flex; align-items: baseline; gap: 10px; }
+.opt-header .px {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-24); color: var(--fg-1); font-weight: 500; line-height: 1;
+}
+.opt-header .chg {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-13); white-space: nowrap;
+}
+.opt-header .chg.pos { color: var(--positive); }
+.opt-header .chg.neg { color: var(--negative); }
+
+.opt-expiry-bar {
+ display: flex; align-items: center; gap: var(--sp-3);
+ padding: var(--sp-3) 0;
+ flex-wrap: wrap;
+}
+.opt-expiry-bar .lbl {
+ font-family: var(--font-sans); font-size: 11px;
+ text-transform: uppercase; letter-spacing: var(--tr-wider);
+ color: var(--fg-3); font-weight: 600;
+ margin-right: 4px;
+}
+
+.opt-view {
+ display: inline-flex;
+ border: 1px solid var(--line-2);
+ background: var(--ink-2);
+ border-radius: var(--r-1);
+ padding: 3px;
+ gap: 2px;
+}
+.opt-view button {
+ font-family: var(--font-sans); font-size: var(--fs-13);
+ font-weight: 500;
+ background: transparent; border: none; color: var(--fg-3);
+ padding: 6px 14px; cursor: pointer; border-radius: var(--r-1);
+ letter-spacing: 0.02em;
+ display: inline-flex; align-items: center; gap: 6px;
+ transition: color 150ms, background 150ms;
+}
+.opt-view button:hover { color: var(--fg-1); }
+.opt-view button.active {
+ background: var(--ink-0); color: var(--fg-1);
+ box-shadow: inset 0 0 0 1px var(--line-2);
+}
+.opt-view button .glyph {
+ font-family: var(--font-mono); font-size: 11px;
+ color: var(--brass); letter-spacing: 0;
+}
+
+.opt-expiries { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
+.opt-exp-chip {
+ font-family: var(--font-mono); font-size: var(--fs-12);
+ color: var(--fg-2); background: var(--ink-2);
+ border: 1px solid var(--line-2); border-radius: var(--r-1);
+ padding: 4px 10px; cursor: pointer;
+ display: inline-flex; gap: 6px; align-items: baseline;
+ transition: all 150ms;
+ white-space: nowrap; flex-shrink: 0;
+}
+.opt-exp-chip:hover { color: var(--fg-1); border-color: var(--line-3); }
+.opt-exp-chip.active { color: var(--brass-ink); background: var(--brass); border-color: var(--brass); }
+.opt-exp-chip .dte { font-size: 10px; color: var(--fg-3); letter-spacing: 0.06em; }
+.opt-exp-chip.active .dte { color: var(--brass-ink); opacity: 0.75; }
+
+.opt-strip {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ background: var(--ink-1);
+ border: 1px solid var(--line-1);
+ border-radius: var(--r-3);
+ overflow: hidden;
+}
+.opt-strip > div {
+ padding: var(--sp-3) var(--sp-4);
+ border-right: 1px solid var(--line-1);
+ display: flex; flex-direction: column; gap: 4px;
+}
+.opt-strip > div:last-child { border-right: none; }
+.opt-strip .k {
+ font-family: var(--font-sans); font-size: 11px;
+ text-transform: uppercase; letter-spacing: var(--tr-wider);
+ color: var(--fg-3); font-weight: 600;
+}
+.opt-strip .v {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-20); color: var(--fg-1); font-weight: 500; line-height: 1;
+}
+.opt-strip .v.accent { color: var(--brass-bright); }
+.opt-strip .v.gain { color: var(--positive); }
+.opt-strip .v.loss { color: var(--negative); }
+.opt-strip .s { font-family: var(--font-mono); font-size: 11px; color: var(--fg-3); }
+
+.opt-grid {
+ display: grid;
+ grid-template-columns: 320px 1fr 360px;
+ gap: var(--sp-5);
+ align-items: start;
+ min-width: 0;
+}
+.opt-grid.dense { grid-template-columns: 280px 1fr 320px; gap: var(--sp-3); }
+.opt-grid.sparse { grid-template-columns: 340px 1fr 380px; gap: var(--sp-6); }
+.opt-grid > .opt-col { display: flex; flex-direction: column; gap: var(--sp-4); min-width: 0; }
+
+@media (max-width: 1280px) {
+ .opt-grid { grid-template-columns: 280px 1fr 320px; gap: var(--sp-4); }
+}
+@media (max-width: 1120px) {
+ .opt-grid {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas: "pricer charts" "chain chain";
+ }
+ .opt-grid > .opt-col:nth-child(1) { grid-area: pricer; }
+ .opt-grid > .opt-col:nth-child(2) { grid-area: chain; }
+ .opt-grid > .opt-col:nth-child(3) { grid-area: charts; }
+}
+
+.opt-pricer {
+ background: var(--ink-1);
+ border: 1px solid var(--line-1);
+ border-radius: var(--r-3);
+ box-shadow: var(--shadow-1);
+ padding: var(--sp-4);
+ display: flex; flex-direction: column; gap: var(--sp-3);
+}
+.opt-pricer .head { display: flex; justify-content: space-between; align-items: baseline; }
+.opt-pricer .head h3 {
+ font-family: var(--font-display); font-size: var(--fs-20);
+ color: var(--fg-1); font-weight: 500; margin: 0;
+}
+
+.opt-cp {
+ display: inline-flex; border: 1px solid var(--line-2);
+ background: var(--ink-2); border-radius: var(--r-1); padding: 2px; gap: 1px;
+}
+.opt-cp button {
+ background: transparent; border: none;
+ color: var(--fg-3); padding: 3px 10px;
+ font-family: var(--font-sans); font-size: var(--fs-12); font-weight: 600;
+ cursor: pointer; border-radius: var(--r-1); letter-spacing: 0.02em;
+}
+.opt-cp button.active { color: var(--fg-1); background: var(--ink-3); }
+.opt-cp button.active.C { color: var(--positive); }
+.opt-cp button.active.P { color: var(--negative); }
+
+.opt-sliders { display: flex; flex-direction: column; gap: var(--sp-2); }
+.opt-slide {
+ display: grid;
+ grid-template-columns: 28px 1fr 76px;
+ align-items: center;
+ gap: var(--sp-2);
+}
+.opt-slide .g {
+ font-family: var(--font-mono); font-size: var(--fs-14);
+ color: var(--brass); font-weight: 500;
+}
+.opt-slide input[type=range] {
+ -webkit-appearance: none; appearance: none;
+ width: 100%; height: 4px; background: var(--ink-3);
+ border-radius: 999px; outline: none; cursor: pointer;
+}
+.opt-slide input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none; appearance: none;
+ width: 14px; height: 14px; border-radius: 50%;
+ background: var(--fg-1); border: 1.5px solid var(--brass);
+ cursor: grab;
+ box-shadow: 0 0 0 2px rgba(194,170,122,0.18);
+}
+.opt-slide input[type=range]::-moz-range-thumb {
+ width: 14px; height: 14px; border-radius: 50%;
+ background: var(--fg-1); border: 1.5px solid var(--brass);
+}
+.opt-slide .val {
+ display: flex; align-items: center; gap: 2px;
+ background: var(--ink-2); border: 1px solid var(--line-2);
+ border-radius: var(--r-1); padding: 2px 6px;
+ justify-content: flex-end;
+}
+.opt-slide .val input {
+ width: 100%; background: transparent; border: none; outline: none;
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-13); color: var(--fg-1); text-align: right; padding: 0;
+}
+.opt-slide .val .unit {
+ font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); margin-left: 1px;
+}
+.opt-slide.market input[type=range]::-webkit-slider-thumb { border-color: var(--positive); }
+.opt-slide .meta {
+ grid-column: 2 / -1;
+ font-family: var(--font-mono); font-size: 10px;
+ color: var(--fg-4); margin-top: -2px;
+ display: flex; gap: 6px; align-items: center;
+}
+.opt-slide .meta button {
+ background: transparent; border: 1px solid var(--line-2);
+ border-radius: var(--r-0); padding: 0 4px;
+ font-family: var(--font-mono); font-size: 9px;
+ color: var(--fg-3); cursor: pointer; letter-spacing: 0.06em;
+}
+.opt-slide .meta button:hover { color: var(--brass); border-color: var(--brass); }
+
+.opt-output {
+ display: grid; grid-template-columns: 1fr 1fr;
+ gap: var(--sp-3);
+ padding: var(--sp-3) var(--sp-3) var(--sp-2);
+ border-top: 1px solid var(--line-1);
+ border-bottom: 1px solid var(--line-1);
+ background: var(--ink-0);
+ border-radius: var(--r-2);
+ align-items: end; min-width: 0;
+}
+.opt-output > div { min-width: 0; }
+.opt-output .fair-lbl {
+ font-family: var(--font-sans); font-size: 11px;
+ text-transform: uppercase; letter-spacing: var(--tr-wider);
+ color: var(--fg-3); font-weight: 600; white-space: nowrap;
+}
+.opt-output .fair {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: clamp(24px, 3vw, 32px);
+ color: var(--brass-bright); font-weight: 500;
+ line-height: 1; letter-spacing: -0.02em;
+ display: flex; align-items: baseline; gap: 4px;
+ overflow: hidden; text-overflow: clip;
+}
+.opt-output .fair .cur { font-size: 0.55em; color: var(--fg-3); }
+.opt-output .mid-lbl {
+ font-family: var(--font-sans); font-size: 11px;
+ text-transform: uppercase; letter-spacing: var(--tr-wider);
+ color: var(--fg-3); font-weight: 600; white-space: nowrap;
+}
+.opt-output .mid {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: clamp(16px, 2.2vw, 20px);
+ color: var(--fg-2); font-weight: 500; line-height: 1;
+}
+.opt-output .delta {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-12); margin-top: 2px;
+}
+.opt-output .delta.pos { color: var(--positive); }
+.opt-output .delta.neg { color: var(--negative); }
+.opt-output .iv-bar {
+ grid-column: 1 / -1;
+ display: grid; grid-template-columns: auto 1fr auto;
+ align-items: center; gap: var(--sp-3);
+ padding-top: var(--sp-2); margin-top: var(--sp-2);
+ border-top: 1px dashed var(--line-1);
+}
+.opt-output .iv-bar .lbl {
+ font-family: var(--font-sans); font-size: 10px;
+ text-transform: uppercase; letter-spacing: var(--tr-wider);
+ color: var(--fg-3); font-weight: 600;
+}
+.opt-output .iv-bar .iv-track {
+ position: relative; height: 4px;
+ background: var(--ink-3); border-radius: 999px;
+}
+.opt-output .iv-bar .iv-mkt {
+ position: absolute; top: -3px; width: 2px; height: 10px;
+ background: var(--fg-3);
+}
+.opt-output .iv-bar .iv-solved {
+ position: absolute; top: -4px; width: 10px; height: 12px;
+ background: var(--brass); border-radius: 2px;
+ transform: translateX(-50%);
+}
+.opt-output .iv-bar .iv-val {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-13); color: var(--brass);
+}
+
+.opt-greeks {
+ display: grid; grid-template-columns: repeat(5, 1fr);
+ gap: 3px; min-width: 0;
+}
+.opt-greek {
+ padding: 6px 2px;
+ background: var(--ink-2); border: 1px solid var(--line-1);
+ border-radius: var(--r-1);
+ display: flex; flex-direction: column; gap: 2px; align-items: center;
+ cursor: default; transition: border-color 150ms; min-width: 0;
+}
+.opt-greek:hover { border-color: var(--brass); }
+.opt-greek .g { font-family: var(--font-mono); font-size: var(--fs-13); color: var(--fg-3); font-weight: 500; }
+.opt-greek .v { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--fs-13); color: var(--fg-1); font-weight: 500; }
+.opt-greek .v.neg { color: var(--negative); }
+.opt-greek .n { font-family: var(--font-sans); font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--fg-4); white-space: nowrap; }
+
+.opt-solve {
+ background: var(--ink-1); border: 1px solid var(--line-1);
+ border-radius: var(--r-3);
+ padding: var(--sp-3) var(--sp-4);
+ display: flex; flex-direction: column; gap: 6px;
+ box-shadow: var(--shadow-1);
+}
+.opt-solve .head {
+ font-family: var(--font-sans); font-size: 11px;
+ text-transform: uppercase; letter-spacing: var(--tr-wider);
+ color: var(--fg-3); font-weight: 600;
+ padding-bottom: 4px; border-bottom: 1px solid var(--line-1); margin-bottom: 2px;
+}
+.opt-solve .row { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: var(--sp-2); padding: 4px 0; }
+.opt-solve .row .g { font-family: var(--font-mono); font-size: var(--fs-14); color: var(--brass); }
+.opt-solve .row .l { font-family: var(--font-sans); font-size: var(--fs-13); color: var(--fg-2); }
+.opt-solve .row .v { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--fs-14); color: var(--fg-1); }
+
+.opt-chain-wrap {
+ background: var(--ink-1); border: 1px solid var(--line-1);
+ border-radius: var(--r-3); overflow: hidden;
+ box-shadow: var(--shadow-1);
+ display: flex; flex-direction: column;
+}
+.opt-chain-head {
+ padding: var(--sp-3) var(--sp-4);
+ display: flex; justify-content: space-between; align-items: baseline;
+ border-bottom: 1px solid var(--line-1);
+ background: var(--ink-1);
+}
+.opt-chain-head h3 { font-family: var(--font-display); font-size: var(--fs-20); color: var(--fg-1); font-weight: 500; margin: 0; }
+.opt-chain-head .sub { font-family: var(--font-mono); font-size: var(--fs-12); color: var(--fg-3); }
+.opt-chain { width: 100%; border-collapse: collapse; }
+.opt-chain th {
+ font-family: var(--font-sans); font-size: 10px; font-weight: 600;
+ color: var(--fg-3); text-transform: uppercase; letter-spacing: var(--tr-wider);
+ padding: 6px 4px; background: var(--ink-0);
+ border-bottom: 1px solid var(--line-1); text-align: right;
+ position: sticky; top: 0; z-index: 1;
+}
+.opt-chain th.k { text-align: center; color: var(--brass); }
+.opt-chain th.side-c { color: var(--positive); }
+.opt-chain th.side-p { color: var(--negative); }
+.opt-chain th.group {
+ font-family: var(--font-display); font-style: italic;
+ font-size: var(--fs-14); text-transform: none; letter-spacing: 0; padding-bottom: 0;
+}
+.opt-chain td {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-12); color: var(--fg-1);
+ padding: 5px 4px; text-align: right; border-bottom: 1px solid var(--line-1);
+}
+.opt-chain-wrap.compact .opt-chain th { padding: 5px 3px; font-size: 9px; }
+.opt-chain-wrap.compact .opt-chain td { padding: 4px 3px; font-size: 11px; }
+.opt-chain-wrap.compact .opt-chain-head { padding: var(--sp-2) var(--sp-3); }
+.opt-chain-wrap.compact .opt-chain-head h3 { font-size: var(--fs-16); }
+.opt-chain-wrap.compact .opt-chain-head .sub { font-size: 10px; }
+.opt-chain td.k { text-align: center; color: var(--fg-2); font-weight: 500; }
+.opt-chain td.itm { color: var(--fg-1); }
+.opt-chain td.otm { color: var(--fg-3); }
+.opt-chain td.dim { color: var(--fg-4); font-size: var(--fs-12); }
+.opt-chain td.iv { color: var(--brass); }
+.opt-chain tr { cursor: pointer; transition: background 100ms; }
+.opt-chain tr:hover { background: rgba(194,170,122,0.05); }
+.opt-chain tr.atm td { background: rgba(194,170,122,0.10); }
+.opt-chain tr.atm td.k { color: var(--brass-bright); font-weight: 600; }
+.opt-chain tr.selected td { background: rgba(194,170,122,0.22); }
+.opt-chain tr.selected td.k { color: var(--brass-bright); }
+.opt-chain-scroll { max-height: 540px; overflow: auto; }
+
+.opt-chart-card {
+ background: var(--ink-1); border: 1px solid var(--line-1);
+ border-radius: var(--r-3); padding: var(--sp-3) var(--sp-4);
+ box-shadow: var(--shadow-1);
+}
+.opt-chart-card .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: var(--sp-2); }
+.opt-chart-card h4 { font-family: var(--font-sans); font-size: var(--fs-13); font-weight: 600; color: var(--fg-1); margin: 0; letter-spacing: 0.01em; }
+.opt-chart-card .eyebrow { font-family: var(--font-sans); font-size: 10px; text-transform: uppercase; letter-spacing: var(--tr-wider); color: var(--fg-3); font-weight: 600; }
+
+.opt-svg .axis { stroke: var(--line-2); stroke-width: 1; fill: none; }
+.opt-svg .grid { stroke: var(--line-1); stroke-width: 1; fill: none; stroke-dasharray: 2 4; }
+.opt-svg .curve { stroke: var(--fg-2); stroke-width: 1.5; fill: none; stroke-linecap: round; stroke-linejoin: round; }
+.opt-svg .curve.accent { stroke: var(--brass-bright); stroke-width: 2; }
+.opt-svg .curve.fill { stroke: none; fill: var(--brass); opacity: 0.10; }
+.opt-svg .curve.fade1 { stroke: var(--fg-3); stroke-width: 1.2; opacity: 0.5; }
+.opt-svg .curve.fade2 { stroke: var(--fg-4); stroke-width: 1; opacity: 0.4; }
+.opt-svg .marker { fill: var(--brass-bright); stroke: var(--ink-0); stroke-width: 1.5; }
+.opt-svg .marker-line { stroke: var(--brass); stroke-width: 1; stroke-dasharray: 3 3; }
+.opt-svg text { font-family: var(--font-mono); font-size: 10px; fill: var(--fg-3); font-variant-numeric: tabular-nums; }
+.opt-svg text.label { font-family: var(--font-sans); font-size: 10px; text-transform: uppercase; letter-spacing: var(--tr-wider); fill: var(--fg-4); font-weight: 600; }
+.opt-svg text.brass { fill: var(--brass-bright); }
+.opt-svg text.atm { fill: var(--brass-bright); font-weight: 600; }
+.opt-svg .crosshair { stroke: var(--fg-3); stroke-width: 1; stroke-dasharray: 2 3; }
+
+.opt-greek-multi { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
+.opt-greek-mini {
+ background: var(--ink-0); border: 1px solid var(--line-1);
+ border-radius: var(--r-1); padding: 6px 8px 4px; position: relative;
+}
+.opt-greek-mini .lbl {
+ font-family: var(--font-sans); font-size: 10px; color: var(--fg-3); font-weight: 600;
+ text-transform: uppercase; letter-spacing: var(--tr-wider);
+ display: flex; justify-content: space-between; align-items: baseline;
+}
+.opt-greek-mini .lbl .v {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-12); color: var(--fg-1); letter-spacing: 0; text-transform: none;
+}
+
+.opt-surface {
+ background: var(--ink-1); border: 1px solid var(--line-1);
+ border-radius: var(--r-3); padding: var(--sp-4);
+ box-shadow: var(--shadow-1);
+ display: flex; flex-direction: column; gap: var(--sp-3);
+}
+.opt-surface .head {
+ display: flex; justify-content: space-between; align-items: baseline;
+ padding-bottom: var(--sp-2); border-bottom: 1px solid var(--line-1);
+}
+.opt-surface .head h3 {
+ font-family: var(--font-display); font-size: var(--fs-24);
+ color: var(--fg-1); font-weight: 500; margin: 0; letter-spacing: -0.01em;
+}
+.opt-surface .head h3 em { font-style: italic; color: var(--fg-3); }
+.opt-polar-wrap { display: grid; grid-template-columns: 1fr; align-items: center; justify-items: center; position: relative; }
+.opt-polar { width: 100%; max-width: 720px; height: auto; }
+.opt-polar .ring { stroke: var(--line-2); fill: none; stroke-dasharray: 2 4; }
+.opt-polar .ring.outer { stroke: var(--line-1); stroke-dasharray: 0; }
+.opt-polar .spoke { stroke: var(--line-1); stroke-width: 1; }
+.opt-polar .spoke.atm { stroke: var(--brass); stroke-width: 1.2; }
+.opt-polar .expiry { fill: none; stroke-linejoin: round; stroke-linecap: round; }
+.opt-polar .expiry-fill { stroke: none; }
+.opt-polar text.tick { font-family: var(--font-mono); font-size: 10px; fill: var(--fg-4); }
+.opt-polar text.tick.atm { fill: var(--brass-bright); font-weight: 500; }
+.opt-polar text.iv { font-family: var(--font-mono); font-size: 9px; fill: var(--fg-4); letter-spacing: 0.04em; }
+.opt-polar .eye { fill: var(--ink-0); stroke: var(--brass); stroke-width: 1; }
+.opt-polar text.eye-lbl { font-family: var(--font-sans); font-size: 9px; fill: var(--fg-3); text-transform: uppercase; letter-spacing: var(--tr-wider); }
+.opt-polar text.eye-num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; fill: var(--brass-bright); font-weight: 500; }
+.opt-polar .dot { fill: var(--brass-bright); stroke: var(--ink-0); stroke-width: 2; }
+
+.opt-surface-legend {
+ display: flex; gap: var(--sp-3); align-items: center; flex-wrap: wrap;
+ font-family: var(--font-mono); font-size: var(--fs-12); color: var(--fg-3);
+}
+.opt-surface-legend .item { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
+.opt-surface-legend .swatch { width: 22px; height: 2px; border-radius: 2px; }
+.opt-surface-legend .item.muted { opacity: 0.45; }
+.opt-surface-legend .item.muted:hover { opacity: 1; }
+
+.opt-heat { display: grid; grid-template-columns: 60px 1fr; gap: var(--sp-2); align-items: stretch; }
+.opt-heat .ylabs { display: flex; flex-direction: column; justify-content: space-around; font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); text-align: right; padding-right: 4px; }
+.opt-heat .grid { display: grid; gap: 2px; }
+.opt-heat .cell {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: 10px; text-align: center;
+ padding: 6px 2px; border-radius: 2px; position: relative;
+ transition: transform 100ms; cursor: pointer;
+}
+.opt-heat .cell:hover { transform: scale(1.04); z-index: 2; outline: 1px solid var(--brass); }
+.opt-heat .cell.cursor { outline: 1.5px solid var(--brass-bright); outline-offset: 0; }
+.opt-heat .xlabs { display: grid; gap: 2px; margin-top: 4px; font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); text-align: center; }
+.opt-heat .xlabs span.atm { color: var(--brass-bright); }
+
+.opt-payoff {
+ background: var(--ink-1); border: 1px solid var(--line-1);
+ border-radius: var(--r-3); padding: var(--sp-3) var(--sp-4);
+ box-shadow: var(--shadow-1);
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 5c14488..175f072 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { AppShell } from "@/components/prism/AppShell";
import { FinancialsPage } from "@/components/prism/FinancialsPage";
import { ValuationPage } from "@/components/prism/ValuationPage";
+import { OptionsPage } from "@/components/prism/options/OptionsPage";
import { ChartCard } from "@/components/prism/ChartCard";
import { KPIStrip } from "@/components/prism/KPIStrip";
import { Sidebar } from "@/components/prism/Sidebar";
@@ -322,46 +323,52 @@ function OverviewClient() {
}
>
<MarketStrip indices={marketCards} />
- {!selectedTicker ? <EmptyOverviewState watchlist={watchlist} onSelectTicker={navigateToTicker} /> : null}
- {selectedTicker && overviewState === "loading" ? <LoadingOverviewState symbol={selectedTicker} /> : null}
- {selectedTicker && overviewState === "invalid" ? <InvalidTickerState symbol={selectedTicker} onClear={clearTicker} /> : null}
- {selectedTicker && overviewState === "error" ? <ErrorOverviewState message={overviewError || "Ticker data unavailable"} /> : null}
- {overview && overviewState === "ready" ? (
- tab === "valuation" ? (
- <ValuationPage
- ticker={selectedTicker}
- overview={overview}
- isSaved={isSaved}
- onToggleWatchlist={addOrRemoveCurrentTicker}
- />
- ) : tab === "financials" ? (
- <FinancialsPage
- ticker={selectedTicker}
- overview={overview}
- isSaved={isSaved}
- onToggleWatchlist={addOrRemoveCurrentTicker}
- />
- ) : (
- <>
- <TickerHeader overview={overview} onToggleWatchlist={addOrRemoveCurrentTicker} isSaved={isSaved} />
- <KPIStrip items={kpis} />
- <div className="psm-main-grid">
- <div className="psm-column">
- <ChartCard symbol={overview.profile.symbol} period={period} points={history} chartState={chartState} chartError={chartError} onChangePeriod={setPeriod} />
- <VolumeCard overview={overview} />
- <SignalCard overview={overview} />
- <PriceVsValueCard overview={overview} valuation={valuation} valState={valState} />
- </div>
- <div className="psm-column">
- <ProfileCard overview={overview} />
- <ShortInterestCard overview={overview} />
- <ValuationOverviewCard overview={overview} valuation={valuation} valState={valState} />
- <QualityCard overview={overview} />
- </div>
- </div>
- </>
- )
- ) : null}
+ {tab === "options" ? (
+ <OptionsPage ticker={selectedTicker} overview={overview} />
+ ) : (
+ <>
+ {!selectedTicker ? <EmptyOverviewState watchlist={watchlist} onSelectTicker={navigateToTicker} /> : null}
+ {selectedTicker && overviewState === "loading" ? <LoadingOverviewState symbol={selectedTicker} /> : null}
+ {selectedTicker && overviewState === "invalid" ? <InvalidTickerState symbol={selectedTicker} onClear={clearTicker} /> : null}
+ {selectedTicker && overviewState === "error" ? <ErrorOverviewState message={overviewError || "Ticker data unavailable"} /> : null}
+ {overview && overviewState === "ready" ? (
+ tab === "valuation" ? (
+ <ValuationPage
+ ticker={selectedTicker}
+ overview={overview}
+ isSaved={isSaved}
+ onToggleWatchlist={addOrRemoveCurrentTicker}
+ />
+ ) : tab === "financials" ? (
+ <FinancialsPage
+ ticker={selectedTicker}
+ overview={overview}
+ isSaved={isSaved}
+ onToggleWatchlist={addOrRemoveCurrentTicker}
+ />
+ ) : (
+ <>
+ <TickerHeader overview={overview} onToggleWatchlist={addOrRemoveCurrentTicker} isSaved={isSaved} />
+ <KPIStrip items={kpis} />
+ <div className="psm-main-grid">
+ <div className="psm-column">
+ <ChartCard symbol={overview.profile.symbol} period={period} points={history} chartState={chartState} chartError={chartError} onChangePeriod={setPeriod} />
+ <VolumeCard overview={overview} />
+ <SignalCard overview={overview} />
+ <PriceVsValueCard overview={overview} valuation={valuation} valState={valState} />
+ </div>
+ <div className="psm-column">
+ <ProfileCard overview={overview} />
+ <ShortInterestCard overview={overview} />
+ <ValuationOverviewCard overview={overview} valuation={valuation} valState={valState} />
+ <QualityCard overview={overview} />
+ </div>
+ </div>
+ </>
+ )
+ ) : null}
+ </>
+ )}
</AppShell>
);
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}
+ />
+ );
+}
diff --git a/frontend/components/prism/options/OptionsCharts.tsx b/frontend/components/prism/options/OptionsCharts.tsx
new file mode 100644
index 0000000..337035f
--- /dev/null
+++ b/frontend/components/prism/options/OptionsCharts.tsx
@@ -0,0 +1,335 @@
+"use client";
+
+import { useMemo } from "react";
+import { bsPrice, bsGreeks, bsSynthIV } from "@/lib/blackScholes";
+import type { Expiry, OptionType } from "./types";
+
+function smoothPath(pts: [number, number][]): string {
+ if (pts.length < 2) return "";
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
+ for (let i = 1; i < pts.length; i++) {
+ const [x0, y0] = pts[i - 1];
+ const [x1, y1] = pts[i];
+ const cx = (x0 + x1) / 2;
+ d += ` C ${cx} ${y0}, ${cx} ${y1}, ${x1} ${y1}`;
+ }
+ return d;
+}
+
+function linePath(pts: [number, number][]): string {
+ if (!pts.length) return "";
+ return pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
+}
+
+interface SmileChartProps {
+ S: number;
+ T: number;
+ r: number;
+ q: number;
+ atmSigma: number;
+ K: number;
+ type: OptionType;
+ expiryLabel: string;
+ allExpiries: Expiry[];
+}
+
+export function SmileChart({ S, T, atmSigma, K, allExpiries }: SmileChartProps) {
+ const W = 720, H = 240;
+ const pad = { l: 40, r: 20, t: 20, b: 30 };
+ const iW = W - pad.l - pad.r;
+ const iH = H - pad.t - pad.b;
+
+ const kMin = S * 0.80, kMax = S * 1.22;
+ const nPts = 60;
+
+ const primaryPts = useMemo((): [number, number][] => {
+ const pts: [number, number][] = [];
+ for (let i = 0; i <= nPts; i++) {
+ const strike = kMin + (kMax - kMin) * i / nPts;
+ const iv = bsSynthIV(S, strike, T, atmSigma);
+ pts.push([strike, iv]);
+ }
+ return pts;
+ }, [S, T, atmSigma, kMin, kMax]);
+
+ const otherCurves = useMemo(() => {
+ return allExpiries
+ .filter(e => Math.abs(e.T - T) > 0.001)
+ .slice(0, 3)
+ .map(e => {
+ const pts: [number, number][] = [];
+ for (let i = 0; i <= nPts; i++) {
+ const strike = kMin + (kMax - kMin) * i / nPts;
+ const iv = bsSynthIV(S, strike, e.T, atmSigma);
+ pts.push([strike, iv]);
+ }
+ return pts;
+ });
+ }, [S, T, atmSigma, allExpiries, kMin, kMax]);
+
+ const allIvs = primaryPts.map(p => p[1]);
+ const ivMin = Math.max(0, Math.min(...allIvs) - 0.02);
+ const ivMax = Math.max(...allIvs) + 0.04;
+
+ function toX(strike: number) {
+ return pad.l + ((strike - kMin) / (kMax - kMin)) * iW;
+ }
+ function toY(iv: number) {
+ return pad.t + iH - ((iv - ivMin) / (ivMax - ivMin)) * iH;
+ }
+
+ const svgPrimary = smoothPath(primaryPts.map(([k, iv]) => [toX(k), toY(iv)] as [number, number]));
+ const fillPath = svgPrimary + ` L ${toX(kMax)} ${pad.t + iH} L ${toX(kMin)} ${pad.t + iH} Z`;
+ const kX = toX(K);
+ const kIv = bsSynthIV(S, K, T, atmSigma);
+ const kY = toY(kIv);
+ const atmX = toX(S);
+
+ const yTicks = 4;
+ const yLabels: number[] = [];
+ for (let i = 0; i <= yTicks; i++) {
+ yLabels.push(ivMin + (ivMax - ivMin) * i / yTicks);
+ }
+
+ return (
+ <div className="opt-chart-card opt-svg">
+ <div className="head">
+ <h4>Vol Smile</h4>
+ <span className="eyebrow">Implied Volatility vs. Strike</span>
+ </div>
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }}>
+ {yLabels.map(iv => (
+ <g key={iv}>
+ <line className="grid" x1={pad.l} y1={toY(iv)} x2={pad.l + iW} y2={toY(iv)} />
+ <text x={pad.l - 4} y={toY(iv) + 4} textAnchor="end" className="opt-svg">{(iv * 100).toFixed(0)}%</text>
+ </g>
+ ))}
+ <line className="axis" x1={pad.l} y1={pad.t} x2={pad.l} y2={pad.t + iH} />
+ <line className="axis" x1={pad.l} y1={pad.t + iH} x2={pad.l + iW} y2={pad.t + iH} />
+ {otherCurves.map((pts, idx) => (
+ <path
+ key={idx}
+ className={idx === 0 ? 'curve fade1' : 'curve fade2'}
+ d={smoothPath(pts.map(([k, iv]) => [toX(k), toY(iv)] as [number, number]))}
+ />
+ ))}
+ <path className="curve fill" d={fillPath} />
+ <path className="curve accent" d={svgPrimary} />
+ <line className="spoke" x1={atmX} y1={pad.t} x2={atmX} y2={pad.t + iH} strokeDasharray="3 3" stroke="var(--brass)" strokeWidth="1" />
+ <text x={atmX} y={pad.t - 6} textAnchor="middle" className="atm">ATM</text>
+ <line className="crosshair" x1={kX} y1={pad.t} x2={kX} y2={pad.t + iH} />
+ <circle cx={kX} cy={kY} r={4} className="marker" />
+ <text x={kX + 6} y={kY - 6} className="brass">{(kIv * 100).toFixed(1)}%</text>
+ </svg>
+ </div>
+ );
+}
+
+interface TermStructureProps {
+ S: number;
+ r: number;
+ q: number;
+ atmSigma: number;
+ expiries: Expiry[];
+ selectedT: number;
+ onPickT: (T: number) => void;
+}
+
+export function TermStructure({ S, atmSigma, expiries, selectedT, onPickT }: TermStructureProps) {
+ const W = 360, H = 150;
+ const pad = { l: 36, r: 16, t: 16, b: 30 };
+ const iW = W - pad.l - pad.r;
+ const iH = H - pad.t - pad.b;
+
+ const pts = useMemo(() => expiries.map(e => {
+ const iv = bsSynthIV(S, S, e.T, atmSigma);
+ return { T: e.T, sqrtT: Math.sqrt(e.T), iv, label: e.label, dte: e.dte };
+ }), [S, atmSigma, expiries]);
+
+ const xs = pts.map(p => p.sqrtT);
+ const ivs = pts.map(p => p.iv);
+ const xMin = Math.min(...xs), xMax = Math.max(...xs);
+ const ivMin = Math.min(...ivs) - 0.01, ivMax = Math.max(...ivs) + 0.01;
+
+ function toX(sqrtT: number) {
+ return pad.l + ((sqrtT - xMin) / (xMax - xMin + 0.001)) * iW;
+ }
+ function toY(iv: number) {
+ return pad.t + iH - ((iv - ivMin) / (ivMax - ivMin + 0.001)) * iH;
+ }
+
+ const linePts: [number, number][] = pts.map(p => [toX(p.sqrtT), toY(p.iv)]);
+
+ return (
+ <div className="opt-chart-card opt-svg">
+ <div className="head">
+ <h4>Term Structure</h4>
+ <span className="eyebrow">ATM IV vs. Expiry</span>
+ </div>
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }}>
+ <line className="axis" x1={pad.l} y1={pad.t} x2={pad.l} y2={pad.t + iH} />
+ <line className="axis" x1={pad.l} y1={pad.t + iH} x2={pad.l + iW} y2={pad.t + iH} />
+ <path className="curve fade1" d={linePath(linePts)} />
+ {pts.map((p) => {
+ const cx = toX(p.sqrtT);
+ const cy = toY(p.iv);
+ const isSel = Math.abs(p.T - selectedT) < 0.001;
+ return (
+ <g key={p.label} style={{ cursor: 'pointer' }} onClick={() => onPickT(p.T)}>
+ <circle
+ cx={cx} cy={cy} r={isSel ? 5 : 4}
+ fill={isSel ? 'var(--brass-bright)' : 'var(--fg-3)'}
+ stroke="var(--ink-0)" strokeWidth="1.5"
+ />
+ <text x={cx} y={pad.t + iH + 14} textAnchor="middle" fontSize="9">{p.label}</text>
+ </g>
+ );
+ })}
+ </svg>
+ </div>
+ );
+}
+
+interface GreekMiniProps {
+ name: string;
+ glyph: string;
+ kind: 'delta' | 'gamma' | 'vega' | 'theta';
+ S: number;
+ K: number;
+ T: number;
+ r: number;
+ q: number;
+ sigma: number;
+ type: OptionType;
+}
+
+export function GreekMini({ name, glyph, kind, S, K, T, r, q, sigma, type }: GreekMiniProps) {
+ const W = 160, H = 90;
+ const pad = { l: 8, r: 8, t: 8, b: 8 };
+ const iW = W - pad.l - pad.r;
+ const iH = H - pad.t - pad.b;
+
+ const nPts = 40;
+
+ const pts = useMemo((): [number, number][] => {
+ const sMin = S * 0.75, sMax = S * 1.25;
+ const result: [number, number][] = [];
+ for (let i = 0; i <= nPts; i++) {
+ const spot = sMin + (sMax - sMin) * i / nPts;
+ const g = bsGreeks(spot, K, T, r, q, sigma, type);
+ result.push([spot, g[kind]]);
+ }
+ return result;
+ }, [S, K, T, r, q, sigma, type, kind]);
+
+ const ys = pts.map(p => p[1]);
+ const yMin = Math.min(...ys), yMax = Math.max(...ys);
+ const xMin = pts[0][0], xMax = pts[pts.length - 1][0];
+
+ function toX(v: number) { return pad.l + ((v - xMin) / (xMax - xMin + 0.001)) * iW; }
+ function toY(v: number) { return pad.t + iH - ((v - yMin) / (yMax - yMin + 0.001)) * iH; }
+
+ const svgPts: [number, number][] = pts.map(([x, y]) => [toX(x), toY(y)]);
+ const curValG = bsGreeks(S, K, T, r, q, sigma, type);
+ const curVal = curValG[kind];
+
+ const dotX = toX(S);
+ const dotY = toY(curVal);
+
+ return (
+ <div className="opt-greek-mini">
+ <div className="lbl">
+ <span>{glyph} {name}</span>
+ <span className="v">{curVal.toFixed(4)}</span>
+ </div>
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }} className="opt-svg">
+ <path className="curve accent" d={smoothPath(svgPts)} />
+ <circle cx={dotX} cy={dotY} r={3} className="marker" />
+ </svg>
+ </div>
+ );
+}
+
+interface PayoffProps {
+ S: number;
+ K: number;
+ T: number;
+ r: number;
+ q: number;
+ sigma: number;
+ type: OptionType;
+ mid?: number;
+}
+
+export function Payoff({ S, K, T, r, q, sigma, type, mid }: PayoffProps) {
+ const W = 720, H = 140;
+ const pad = { l: 44, r: 16, t: 16, b: 28 };
+ const iW = W - pad.l - pad.r;
+ const iH = H - pad.t - pad.b;
+
+ const premium = mid ?? bsPrice(S, K, T, r, q, sigma, type);
+
+ const sMin = S * 0.70, sMax = S * 1.30;
+ const nPts = 80;
+
+ const pnlPts = useMemo((): [number, number][] => {
+ const pts: [number, number][] = [];
+ for (let i = 0; i <= nPts; i++) {
+ const spot = sMin + (sMax - sMin) * i / nPts;
+ const payoff = type === 'C' ? Math.max(spot - K, 0) : Math.max(K - spot, 0);
+ pts.push([spot, payoff - premium]);
+ }
+ return pts;
+ }, [K, type, premium, sMin, sMax]);
+
+ const ys = pnlPts.map(p => p[1]);
+ const yMin = Math.min(...ys, -premium * 1.1);
+ const yMax = Math.max(...ys, premium * 1.1);
+
+ function toX(v: number) { return pad.l + ((v - sMin) / (sMax - sMin)) * iW; }
+ function toY(v: number) { return pad.t + iH - ((v - yMin) / (yMax - yMin + 0.001)) * iH; }
+
+ const zeroY = toY(0);
+ const gainPts = pnlPts.filter(p => p[1] >= 0);
+ const lossPts = pnlPts.filter(p => p[1] <= 0);
+
+ const allSvg = linePath(pnlPts.map(([x, y]) => [toX(x), toY(y)] as [number, number]));
+ const gainArea = gainPts.length > 1
+ ? linePath(gainPts.map(([x, y]) => [toX(x), toY(y)] as [number, number])) +
+ ` L ${toX(gainPts[gainPts.length - 1][0])} ${zeroY} L ${toX(gainPts[0][0])} ${zeroY} Z`
+ : '';
+ const lossArea = lossPts.length > 1
+ ? linePath(lossPts.map(([x, y]) => [toX(x), toY(y)] as [number, number])) +
+ ` L ${toX(lossPts[lossPts.length - 1][0])} ${zeroY} L ${toX(lossPts[0][0])} ${zeroY} Z`
+ : '';
+
+ const beSpot = type === 'C' ? K + premium : K - premium;
+ const beX = toX(beSpot);
+ const spotX = toX(S);
+ const kX = toX(K);
+
+ return (
+ <div className="opt-payoff opt-svg">
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }}>
+ <line className="grid" x1={pad.l} y1={zeroY} x2={pad.l + iW} y2={zeroY} />
+ {gainArea && (
+ <path d={gainArea} fill="var(--positive)" opacity="0.18" />
+ )}
+ {lossArea && (
+ <path d={lossArea} fill="var(--negative)" opacity="0.18" />
+ )}
+ <path className="curve accent" d={allSvg} />
+ <line className="marker-line" x1={kX} y1={pad.t} x2={kX} y2={pad.t + iH} />
+ <text x={kX} y={pad.t + iH + 16} textAnchor="middle">K={K.toFixed(0)}</text>
+ <line x1={spotX} y1={pad.t} x2={spotX} y2={pad.t + iH} stroke="var(--fg-3)" strokeWidth="1" strokeDasharray="2 3" />
+ <text x={spotX} y={pad.t - 4} textAnchor="middle" className="brass">S</text>
+ {beSpot >= sMin && beSpot <= sMax && (
+ <>
+ <line className="marker-line" x1={beX} y1={pad.t} x2={beX} y2={pad.t + iH} />
+ <text x={beX} y={pad.t + 14} textAnchor="middle" className="brass">BE</text>
+ </>
+ )}
+ </svg>
+ </div>
+ );
+}
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>
+ );
+}
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>
+ );
+}
diff --git a/frontend/components/prism/options/OptionsSurface.tsx b/frontend/components/prism/options/OptionsSurface.tsx
new file mode 100644
index 0000000..da55012
--- /dev/null
+++ b/frontend/components/prism/options/OptionsSurface.tsx
@@ -0,0 +1,272 @@
+"use client";
+
+import { useMemo } from "react";
+import { bsPrice, bsSynthIV } from "@/lib/blackScholes";
+import type { Expiry, OptionType } from "./types";
+
+interface PolarSmileProps {
+ S: number;
+ r: number;
+ q: number;
+ atmSigma: number;
+ K: number;
+ T: number;
+ type: OptionType;
+ expiries: Expiry[];
+ selectedExpiryIdx: number;
+}
+
+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;
+
+ const ivMin = 0.04, ivMax = 0.8;
+ const ivRings = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
+
+ 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 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)];
+ }
+
+ const fairVal = useMemo(() => bsPrice(S, K, T, r, q, atmSigma, type), [S, K, T, r, q, atmSigma, type]);
+
+ 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);
+ });
+ });
+ }, [S, atmSigma, expiries, strikes, kMin, kMax, maxR, ivMin, ivMax, cx, cy]);
+
+ function curvePath(pts: [number, number][]): string {
+ return pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
+ }
+
+ 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);
+
+ const atmAngle = -Math.PI / 2;
+
+ const eyeR = 28;
+ const legendColors = [
+ 'var(--fg-4)', 'var(--fg-3)', 'var(--fg-2)', 'var(--fg-2)', 'var(--brass)', 'var(--brass-bright)'
+ ];
+
+ return (
+ <div className="opt-surface">
+ <div className="head">
+ <h3>Vol Surface <em>polar</em></h3>
+ </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);
+ return (
+ <g key={i}>
+ <line
+ x1={x1.toFixed(1)} y1={y1.toFixed(1)}
+ x2={x2.toFixed(1)} y2={y2.toFixed(1)}
+ className={isAtm ? 'spoke atm' : 'spoke'}
+ />
+ <text
+ x={(cx + (maxR + 14) * Math.cos(angle)).toFixed(1)}
+ y={(cy + (maxR + 14) * Math.sin(angle)).toFixed(1)}
+ textAnchor="middle"
+ dominantBaseline="middle"
+ className={isAtm ? 'tick atm' : 'tick'}
+ fontSize="9"
+ >
+ {strike.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)];
+ return (
+ <path
+ key={idx}
+ d={curvePath(pts)}
+ className="expiry"
+ stroke={color}
+ strokeWidth="1"
+ opacity="0.5"
+ />
+ );
+ })}
+ {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"
+ />
+ <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>
+ </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>
+ );
+}
+
+interface IvHeatmapProps {
+ S: number;
+ atmSigma: number;
+ expiries: Expiry[];
+ selectedExpiryIdx: number;
+ selectedK: number;
+ onSelect: (expiryIdx: number, K: number) => void;
+}
+
+export function IvHeatmap({ S, atmSigma, expiries, selectedExpiryIdx, selectedK, onSelect }: IvHeatmapProps) {
+ const nStrikes = 13;
+ const kMin = S * 0.85, kMax = S * 1.15;
+
+ const strikes = useMemo(() => {
+ const arr: number[] = [];
+ for (let i = 0; i < nStrikes; i++) {
+ arr.push(Math.round((kMin + (kMax - kMin) * i / (nStrikes - 1)) / 5) * 5);
+ }
+ return arr;
+ }, [kMin, kMax]);
+
+ const ivGrid = useMemo(() => {
+ return expiries.map(e =>
+ strikes.map(K => bsSynthIV(S, K, e.T, atmSigma))
+ );
+ }, [S, atmSigma, expiries, strikes]);
+
+ const allIvs = ivGrid.flat();
+ const ivGridMin = Math.min(...allIvs);
+ const ivGridMax = Math.max(...allIvs);
+
+ function cellColor(iv: number): string {
+ const pct = Math.round(((iv - ivGridMin) / (ivGridMax - ivGridMin + 0.001)) * 80);
+ return `color-mix(in oklch, var(--ink-2), var(--brass-deep) ${pct}%)`;
+ }
+
+ function textColor(iv: number): string {
+ const pct = (iv - ivGridMin) / (ivGridMax - ivGridMin + 0.001);
+ return pct > 0.5 ? 'var(--fg-1)' : 'var(--fg-3)';
+ }
+
+ const atmK = Math.round(S / 5) * 5;
+
+ return (
+ <div className="opt-surface">
+ <div className="head">
+ <h3>IV Heatmap <em>surface</em></h3>
+ </div>
+ <div className="opt-heat">
+ <div className="ylabs">
+ {expiries.map(e => (
+ <span key={e.label}>{e.label}</span>
+ ))}
+ </div>
+ <div>
+ <div className="grid" style={{ gridTemplateColumns: `repeat(${nStrikes}, 1fr)` }}>
+ {ivGrid.map((row, eIdx) =>
+ row.map((iv, kIdx) => {
+ const K = strikes[kIdx];
+ const isCursor = eIdx === selectedExpiryIdx && K === selectedK;
+ return (
+ <div
+ key={`${eIdx}-${kIdx}`}
+ className={`cell${isCursor ? ' cursor' : ''}`}
+ style={{ background: cellColor(iv), color: textColor(iv) }}
+ onClick={() => onSelect(eIdx, K)}
+ >
+ {(iv * 100).toFixed(0)}%
+ </div>
+ );
+ })
+ )}
+ </div>
+ <div className="xlabs" style={{ gridTemplateColumns: `repeat(${nStrikes}, 1fr)` }}>
+ {strikes.map(K => (
+ <span key={K} className={K === atmK ? 'atm' : ''}>{K}</span>
+ ))}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/components/prism/options/types.ts b/frontend/components/prism/options/types.ts
new file mode 100644
index 0000000..fb90a7a
--- /dev/null
+++ b/frontend/components/prism/options/types.ts
@@ -0,0 +1,47 @@
+export type OptionType = 'C' | 'P';
+
+export interface Expiry {
+ label: string;
+ dte: number;
+ T: number;
+}
+
+export interface OptionInputs {
+ S: number;
+ K: number;
+ T: number;
+ r: number;
+ q: number;
+ sigma: number;
+ type: OptionType;
+}
+
+export interface ChainRow {
+ K: number;
+ cMid: number; pMid: number;
+ cIv: number; pIv: number;
+ cDelta: number; pDelta: number;
+ cOi: number; pOi: number;
+ cVol: number; pVol: number;
+}
+
+export interface TickerDefaults {
+ sym: string;
+ name: string;
+ sector: string;
+ spot: number;
+ chgAbs: number;
+ chgPct: number;
+ r: number;
+ q: number;
+ atmSigma30: number;
+}
+
+export const EXPIRIES: Expiry[] = [
+ { label: 'Apr 19', dte: 14, T: 14 / 365 },
+ { label: 'May 17', dte: 30, T: 30 / 365 },
+ { label: 'Jun 21', dte: 65, T: 65 / 365 },
+ { label: 'Sep 20', dte: 156, T: 156 / 365 },
+ { label: "Jan '27", dte: 280, T: 280 / 365 },
+ { label: "Jan '28", dte: 644, T: 644 / 365 },
+];
diff --git a/frontend/lib/blackScholes.ts b/frontend/lib/blackScholes.ts
new file mode 100644
index 0000000..b4c831a
--- /dev/null
+++ b/frontend/lib/blackScholes.ts
@@ -0,0 +1,81 @@
+export type OptionType = 'C' | 'P';
+
+export interface Greeks {
+ delta: number;
+ gamma: number;
+ vega: number;
+ theta: number;
+ rho: number;
+}
+
+function erf(x: number): number {
+ const sign = x < 0 ? -1 : 1;
+ x = Math.abs(x);
+ const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741,
+ a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
+ const t = 1.0 / (1.0 + p * x);
+ const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
+ return sign * y;
+}
+
+function normCdf(x: number): number {
+ return 0.5 * (1 + erf(x / Math.SQRT2));
+}
+
+function normPdf(x: number): number {
+ return Math.exp(-0.5 * x * x) / Math.sqrt(2 * Math.PI);
+}
+
+function d1d2(S: number, K: number, T: number, r: number, q: number, sigma: number): [number, number] {
+ const vt = sigma * Math.sqrt(T);
+ const d1 = (Math.log(S / K) + (r - q + 0.5 * sigma * sigma) * T) / vt;
+ return [d1, d1 - vt];
+}
+
+export function bsPrice(S: number, K: number, T: number, r: number, q: number, sigma: number, type: OptionType): number {
+ if (T <= 0 || sigma <= 0) return type === 'C' ? Math.max(S - K, 0) : Math.max(K - S, 0);
+ const [d1, d2] = d1d2(S, K, T, r, q, sigma);
+ const eqt = Math.exp(-q * T), ert = Math.exp(-r * T);
+ if (type === 'C') return S * eqt * normCdf(d1) - K * ert * normCdf(d2);
+ return K * ert * normCdf(-d2) - S * eqt * normCdf(-d1);
+}
+
+export function bsGreeks(S: number, K: number, T: number, r: number, q: number, sigma: number, type: OptionType): Greeks {
+ const safeT = Math.max(T, 1e-6), safeS = Math.max(sigma, 1e-6);
+ const [d1, d2] = d1d2(S, K, safeT, r, q, safeS);
+ const eqt = Math.exp(-q * safeT), ert = Math.exp(-r * safeT);
+ const pdf1 = normPdf(d1);
+ const delta = type === 'C' ? eqt * normCdf(d1) : -eqt * normCdf(-d1);
+ const gamma = eqt * pdf1 / (S * safeS * Math.sqrt(safeT));
+ const vega = S * eqt * pdf1 * Math.sqrt(safeT) * 0.01;
+ const thetaYr = type === 'C'
+ ? (-S * pdf1 * safeS * eqt / (2 * Math.sqrt(safeT)) - r * K * ert * normCdf(d2) + q * S * eqt * normCdf(d1))
+ : (-S * pdf1 * safeS * eqt / (2 * Math.sqrt(safeT)) + r * K * ert * normCdf(-d2) - q * S * eqt * normCdf(-d1));
+ const theta = thetaYr / 365;
+ const rho = type === 'C'
+ ? K * safeT * ert * normCdf(d2) * 0.01
+ : -K * safeT * ert * normCdf(-d2) * 0.01;
+ return { delta, gamma, vega, theta, rho };
+}
+
+export function bsImpliedVol(S: number, K: number, T: number, r: number, q: number, mktPrice: number, type: OptionType): number {
+ if (T <= 0) return NaN;
+ let lo = 0.0001, hi = 5.0;
+ if (mktPrice < bsPrice(S, K, T, r, q, lo, type) || mktPrice > bsPrice(S, K, T, r, q, hi, type)) return NaN;
+ for (let i = 0; i < 100; i++) {
+ const mid = (lo + hi) / 2;
+ const pMid = bsPrice(S, K, T, r, q, mid, type);
+ if (Math.abs(pMid - mktPrice) < 1e-6) return mid;
+ if (pMid < mktPrice) lo = mid; else hi = mid;
+ }
+ return (lo + hi) / 2;
+}
+
+export function bsSynthIV(S: number, K: number, T: number, atmSigma: number): number {
+ const logM = Math.log(K / S);
+ const t = Math.max(T, 1 / 365);
+ const termLift = 0.014 * (Math.sqrt(t) - Math.sqrt(30 / 365));
+ const skew = -0.085 * logM / Math.sqrt(t * 4);
+ const smile = 0.32 * (logM * logM) / Math.sqrt(t);
+ return Math.max(0.04, Math.min(1.5, atmSigma + termLift + skew + smile));
+}
diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts
index 6993397..bb63e83 100644
--- a/frontend/lib/overview.ts
+++ b/frontend/lib/overview.ts
@@ -19,7 +19,7 @@ export const OVERVIEW_NAV_ITEMS: NavItem[] = [
{ key: "overview", label: "Overview", icon: "chart" },
{ key: "financials", label: "Financials", icon: "ledger" },
{ key: "valuation", label: "Valuation", icon: "dollar" },
- { key: "options", label: "Options", icon: "window", disabled: true },
+ { key: "options", label: "Options", icon: "window" },
{ key: "insiders", label: "Insiders", icon: "pulse", disabled: true },
{ key: "filings", label: "Filings", icon: "folder", disabled: true },
{ key: "news", label: "News", icon: "terminal", disabled: true }