From 25360aacb8aab46e7e579707eb9704759af9536d Mon Sep 17 00:00:00 2001 From: Tyler Hoang 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 --- frontend/app/globals.css | 1 + frontend/app/options.css | 490 +++++++++++++++++++++ frontend/app/page.tsx | 87 ++-- frontend/components/prism/options/OptionsChain.tsx | 157 +++++++ .../components/prism/options/OptionsCharts.tsx | 335 ++++++++++++++ frontend/components/prism/options/OptionsPage.tsx | 292 ++++++++++++ .../components/prism/options/OptionsPricer.tsx | 274 ++++++++++++ .../components/prism/options/OptionsSurface.tsx | 272 ++++++++++++ frontend/components/prism/options/types.ts | 47 ++ frontend/lib/blackScholes.ts | 81 ++++ frontend/lib/overview.ts | 2 +- 11 files changed, 1997 insertions(+), 41 deletions(-) create mode 100644 frontend/app/options.css create mode 100644 frontend/components/prism/options/OptionsChain.tsx create mode 100644 frontend/components/prism/options/OptionsCharts.tsx create mode 100644 frontend/components/prism/options/OptionsPage.tsx create mode 100644 frontend/components/prism/options/OptionsPricer.tsx create mode 100644 frontend/components/prism/options/OptionsSurface.tsx create mode 100644 frontend/components/prism/options/types.ts create mode 100644 frontend/lib/blackScholes.ts (limited to 'frontend') 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() { } > - {!selectedTicker ? : null} - {selectedTicker && overviewState === "loading" ? : null} - {selectedTicker && overviewState === "invalid" ? : null} - {selectedTicker && overviewState === "error" ? : null} - {overview && overviewState === "ready" ? ( - tab === "valuation" ? ( - - ) : tab === "financials" ? ( - - ) : ( - <> - - -
-
- - - - -
-
- - - - -
-
- - ) - ) : null} + {tab === "options" ? ( + + ) : ( + <> + {!selectedTicker ? : null} + {selectedTicker && overviewState === "loading" ? : null} + {selectedTicker && overviewState === "invalid" ? : null} + {selectedTicker && overviewState === "error" ? : null} + {overview && overviewState === "ready" ? ( + tab === "valuation" ? ( + + ) : tab === "financials" ? ( + + ) : ( + <> + + +
+
+ + + + +
+
+ + + + +
+
+ + ) + ) : null} + + )} ); 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 ( +
+
+

Option Chain

+ {compact ? 'Compact' : 'Full'} · {rows.length} strikes +
+
+ + + + {!compact && } + {compact && } + + {!compact && } + {compact && } + + {!compact ? ( + + + + + + + + + + + + ) : ( + + + + + + + + + + )} + + + {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 ( + onPick(row.K)} + > + {!compact && } + + + + + + + + {!compact && } + + ); + })} + +
— calls —— calls —strike— puts —— puts —
OIIVlastΔKΔlastIVOI
IVlastΔKΔlastIV
{fmtOi(row.cOi)}{(row.cIv * 100).toFixed(1)}%{row.cMid.toFixed(2)}{row.cDelta.toFixed(2)}{row.K.toFixed(0)}{row.pDelta.toFixed(2)}{row.pMid.toFixed(2)}{(row.pIv * 100).toFixed(1)}%{fmtOi(row.pOi)}
+
+
+ ); +} + +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 ( + + ); +} 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 ( +
+
+

Vol Smile

+ Implied Volatility vs. Strike +
+ + {yLabels.map(iv => ( + + + {(iv * 100).toFixed(0)}% + + ))} + + + {otherCurves.map((pts, idx) => ( + [toX(k), toY(iv)] as [number, number]))} + /> + ))} + + + + ATM + + + {(kIv * 100).toFixed(1)}% + +
+ ); +} + +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 ( +
+
+

Term Structure

+ ATM IV vs. Expiry +
+ + + + + {pts.map((p) => { + const cx = toX(p.sqrtT); + const cy = toY(p.iv); + const isSel = Math.abs(p.T - selectedT) < 0.001; + return ( + onPickT(p.T)}> + + {p.label} + + ); + })} + +
+ ); +} + +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 ( +
+
+ {glyph} {name} + {curVal.toFixed(4)} +
+ + + + +
+ ); +} + +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 ( +
+ + + {gainArea && ( + + )} + {lossArea && ( + + )} + + + K={K.toFixed(0)} + + S + {beSpot >= sMin && beSpot <= sMax && ( + <> + + BE + + )} + +
+ ); +} 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(() => ({ + 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) { + 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 ( +
+ Options +

Select a ticker to view options

+

Load a ticker from the search bar to access the Black-Scholes pricer, option chain, vol surface, and greek visualizations.

+
+ ); + } + + const terminalView = ( +
+
+ + +
+
+ patchInputs({ K })} + /> + +
+
+ + { + const idx = EXPIRIES.findIndex(e => Math.abs(e.T - T) < 0.001); + if (idx >= 0) selectExpiry(idx); + }} + /> +
+ + + + +
+
+
+ ); + + const surfaceView = ( +
+
+ + +
+
+ + { + selectExpiry(eIdx); + patchInputs({ K }); + }} + /> +
+
+ + patchInputs({ K })} + compact + /> +
+
+ ); + + return ( +
+
+
+ Options · Black–Scholes +
+ {sym} + {name} +
+ ${spot.toFixed(2)} + = 0 ? 'pos' : 'neg'}`}> + {chgPct >= 0 ? '+' : ''}{chgAbs.toFixed(2)} · {chgPct >= 0 ? '+' : ''}{chgPct.toFixed(2)}% + +
+
+
+
+ + +
+
+ +
+ Expiry +
+ {EXPIRIES.map((e, idx) => ( + + ))} +
+
+ +
+
+ ATM IV + {(atmIv * 100).toFixed(1)}% + {expiry.label} +
+
+ 25Δ RR + = 0 ? 'gain' : 'loss'}`}>{rr25 >= 0 ? '+' : ''}{rr25.toFixed(2)}v + call skew +
+
+ 25Δ BF + {bf25 >= 0 ? '+' : ''}{bf25.toFixed(2)}v + smile +
+
+ P/C Ratio + {pcRatio.toFixed(2)} + put / call OI +
+
+ Rate r + {(r * 100).toFixed(2)}% + risk-free +
+
+ Div q + {(q * 100).toFixed(2)}% + yield +
+
+ Contract + {inputs.K.toFixed(0)} + {inputs.type === 'C' ? 'Call' : 'Put'} · {expiry.label} +
+
+ + {view === 'terminal' ? terminalView : surfaceView} +
+ ); +} 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 ( +
+ {glyph} + onChange(parseFloat(e.target.value) / displayScale)} + /> +
+ { + const v = parseFloat(e.target.value) / displayScale; + if (!isNaN(v)) onChange(Math.max(min, Math.min(max, v))); + }} + /> + {unit && {unit}} +
+ {(meta || onReset) && ( +
+ {meta && {meta}} + {onReset && } +
+ )} +
+ ); +} + +interface PricerProps { + inputs: OptionInputs; + spot: number; + onChange: (partial: Partial) => 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 ( +
+
+

Pricer

+
+ + +
+
+ +
+ onChange({ S: spot })} + onChange={v => onChange({ S: v })} + /> + onChange({ K: v })} + /> + onChange({ T: v })} + /> + onChange({ r: v })} + /> + onChange({ sigma: v })} + /> + onChange({ q: v })} + /> +
+ +
+
+
Fair Value
+
+ $ + {fmt(fair)} +
+
+ Δ {fmt(g.delta, 4)} +
+
+
+
Market Mid
+
${isNaN(mid) ? '—' : mid.toFixed(2)}
+
+
+ IV +
+
+
+
+ {fmtPct(sigma)} +
+
+ +
+
+ Δ + {fmt(g.delta, 4)} + Delta +
+
+ Γ + {fmt(g.gamma, 4)} + Gamma +
+
+ ν + {fmt(g.vega, 4)} + Vega +
+
+ Θ + {fmt(g.theta, 4)} + Theta/d +
+
+ ρ + {fmt(g.rho, 4)} + Rho +
+
+
+ ); +} + +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 ( +
+
Analytics
+
+ σ + Implied Vol + {isNaN(iv) ? '—' : (iv * 100).toFixed(2) + '%'} +
+
+ + Breakeven + ${isNaN(breakeven) ? '—' : breakeven.toFixed(2)} +
+
+ + Intrinsic + ${fmt(intrinsic)} +
+
+ + Extrinsic + ${fmt(extrinsic)} +
+
+ ); +} 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 ( +
+
+

Vol Surface polar

+
+
+ + {ivRings.map(iv => { + const r = ivToR(iv); + return ( + + + {(iv * 100).toFixed(0)}% + + ); + })} + {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 ( + + + + {strike.toFixed(0)} + + + ); + })} + {curves.map((pts, idx) => { + const isSel = idx === selectedExpiryIdx; + if (isSel) return null; + const color = legendColors[Math.min(idx, legendColors.length - 1)]; + return ( + + ); + })} + {selCurve && ( + + )} + + + + {type === 'C' ? 'Call' : 'Put'} + ${fairVal.toFixed(2)} + +
+
+ {expiries.map((e, idx) => { + const color = legendColors[Math.min(idx, legendColors.length - 1)]; + const isSel = idx === selectedExpiryIdx; + return ( +
+
+ {e.label} +
+ ); + })} +
+
+ ); +} + +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 ( +
+
+

IV Heatmap surface

+
+
+
+ {expiries.map(e => ( + {e.label} + ))} +
+
+
+ {ivGrid.map((row, eIdx) => + row.map((iv, kIdx) => { + const K = strikes[kIdx]; + const isCursor = eIdx === selectedExpiryIdx && K === selectedK; + return ( +
onSelect(eIdx, K)} + > + {(iv * 100).toFixed(0)}% +
+ ); + }) + )} +
+
+ {strikes.map(K => ( + {K} + ))} +
+
+
+
+ ); +} 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 } -- cgit v1.3-2-g0d8e