diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-20 00:22:32 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-20 00:22:32 -0700 |
| commit | 25360aacb8aab46e7e579707eb9704759af9536d (patch) | |
| tree | 028f654f97dc23c7bc088bc3b625185f4fb71287 | |
| parent | 68b4f52829cdb2d6951faf8037fb002083ebd0a5 (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.css | 1 | ||||
| -rw-r--r-- | frontend/app/options.css | 490 | ||||
| -rw-r--r-- | frontend/app/page.tsx | 87 | ||||
| -rw-r--r-- | frontend/components/prism/options/OptionsChain.tsx | 157 | ||||
| -rw-r--r-- | frontend/components/prism/options/OptionsCharts.tsx | 335 | ||||
| -rw-r--r-- | frontend/components/prism/options/OptionsPage.tsx | 292 | ||||
| -rw-r--r-- | frontend/components/prism/options/OptionsPricer.tsx | 274 | ||||
| -rw-r--r-- | frontend/components/prism/options/OptionsSurface.tsx | 272 | ||||
| -rw-r--r-- | frontend/components/prism/options/types.ts | 47 | ||||
| -rw-r--r-- | frontend/lib/blackScholes.ts | 81 | ||||
| -rw-r--r-- | frontend/lib/overview.ts | 2 |
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">Δ</th> + <th className="k">K</th> + <th className="side-p">Δ</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">Δ</th> + <th className="k">K</th> + <th className="side-p">Δ</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="Δ" 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="Γ" 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="ν" 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="Θ" 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–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)} · {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">▦</span> Terminal + </button> + <button + type="button" + className={view === 'surface' ? 'active' : ''} + onClick={() => setView('surface')} + > + <span className="glyph">⚴</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Δ 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Δ 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="σ" + 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)}`}> + Δ {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">Δ</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">Γ</span> + <span className="v">{fmt(g.gamma, 4)}</span> + <span className="n">Gamma</span> + </div> + <div className="opt-greek"> + <span className="g">ν</span> + <span className="v">{fmt(g.vega, 4)}</span> + <span className="n">Vega</span> + </div> + <div className="opt-greek"> + <span className="g">Θ</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">ρ</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">σ</span> + <span className="l">Implied Vol</span> + <span className="v">{isNaN(iv) ? '—' : (iv * 100).toFixed(2) + '%'}</span> + </div> + <div className="row"> + <span className="g">↕</span> + <span className="l">Breakeven</span> + <span className="v">${isNaN(breakeven) ? '—' : breakeven.toFixed(2)}</span> + </div> + <div className="row"> + <span className="g">◆</span> + <span className="l">Intrinsic</span> + <span className="v">${fmt(intrinsic)}</span> + </div> + <div className="row"> + <span className="g">◇</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 } |
