feat: Positioning Desk — Deribit dealer GEX, gamma flip, walls, vanna/charm, max pain

Live BTC/ETH positioning from Deribit open interest: Black-76 Greeks on each
expiry's forward, dealer gamma exposure per 1% move under switchable dealer
conventions, gamma profile and flip level, call/put walls, vanna and charm
exposure, per-expiry max pain, ATM IV and implied moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpiqUMAyo4RL5DdQHMWbwE
This commit is contained in:
Claude
2026-10-19 05:10:57 +00:00
parent 8328114379
commit 3a29553495
10 changed files with 515 additions and 2 deletions
+1
View File
@@ -46,6 +46,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
| **Cross-Asset Desk** | Gold · silver · BTC · ETH · EURUSD · DXY · US10Y · WTI correlation matrices (30D/90D/1Y/EWMA) with a correlation-break monitor; gold/silver ratio z-scores, OU half-life, Engle-Granger cointegration and Kalman hedge ratio; Margrabe outperformance-option pricer with correlation sensitivity |
| **Jump Lab** | Merton and Bates (Heston + jumps, COS pricing) calibrated to every live Deribit BTC/ETH expiry: model-vs-market smiles, fit RMSE, jump intensity/size, jump share of variance, crash-jump odds, 30-day risk-neutral density vs lognormal; manual Merton for metals/FX event and gap risk; one-click hand-off of jump parameters to Hedge Lab |
| **Event Vol** | Event & weekend "vol time" term structure: strips FOMC / CPI / NFP variance lumps and the weekend weight out of the ATM curve (live Deribit fit for BTC/ETH; assumptions or pasted broker quotes for XAU/XAG/EUR/any pair), event-implied moves and straddle breakevens, forward vols and calendar-arbitrage flags, fair vol for any expiry incl. 0DTE/1-day, built-in 2026 FOMC/CPI/NFP calendar merged with live Forex Factory events, realised day-of-week / weekend-weight / FOMC-lump study |
| **Positioning** | Live BTC / ETH options positioning from Deribit open interest: dealer gamma exposure (USD per 1% move) with switchable dealer-side convention, gamma profile across spot and gamma-flip level, call / put walls, vanna and charm flows, dealer delta, put/call, and per-expiry max pain, ATM IV and implied 1σ move |
| **Exotics Desk** | Structured-products pricer: all eight single barriers (Reiner-Rubinstein/Haug, rebates) with Broadie-Glasserman-Kou discrete-monitoring correction, barrier price/delta profile through the barrier, touch ladder of hit probabilities, smile-consistent digitals (−∂C/∂K on the vanna-volga smile); Dual Currency Investment / crypto "Dual Investment" fair-APR ladder with an offer audit (platform margin, implied vol you are paid); KO accumulator Monte Carlo with zero-cost strike, KO odds and geared-loss tail, optional Merton jumps |
| **TARF Desk** | Target redemption forwards (importer buys / exporter sells, leverage, full / exact / no-payout target) priced side by side under flat vol, Dupire local vol from an arbitrage-free SSVI surface calibrated to ATM / RR / BF, and Merton jumps: zero-cost strike, model-risk spread, knock-out timing and survival, expected cash flows per fixing, client P&L distribution and ES vs the capped gain, delta / vega |
+157
View File
@@ -0,0 +1,157 @@
"""
positioning.py — Options positioning from Deribit open interest: dealer gamma exposure (GEX),
gamma-flip level, call / put walls, vanna and charm flows, max pain and implied moves per
expiry for BTC and ETH.
Greeks are Black-76 on each expiry's forward (Deribit options are coin-settled; r ≈ 0 on
the forward), with the mark implied vol of each strike (sticky-strike when spot is moved):
Γ = φ(d1) / (F σ √T) per 1 coin of underlying
vanna = ∂Δ/∂σ = −φ(d1) d2 / σ
charm = −∂Δ/∂T per day = φ(d1) d2 / (2T) / 365 (delta drift as time passes)
Dealer exposure needs an assumption about who is short the options. Two conventions:
"classic" (SpotGamma): dealers long calls (sold to them by overwriters / covered-call
vaults), short puts (bought by hedgers): sign +1 calls, −1 puts.
"short_all": customers net long all options, dealers short both: sign −1.
GEX in USD per 1 % spot move: GEX = Σ sign · Γ · OI · S² · 0.01 (OI in coins).
The gamma flip is where the re-computed total GEX crosses zero as spot moves (SpotGamma
methodology); above it dealers' hedging dampens moves (mean reversion), below it amplifies
them (trend / vol expansion).
Max pain (per expiry) = the settlement price minimising the intrinsic value paid to option
holders, a folk heuristic for expiry pinning that only bites when dealers are long gamma.
References:
Black, F. (1976). "The pricing of commodity contracts", J. Financial Economics 3.
SpotGamma, "Gamma Exposure (GEX)" methodology notes; FlashAlpha, "How to calculate GEX".
Barbon, A. & Buraschi, A. (2021). "Gamma fragility", working paper (dealer gamma and
intraday momentum / reversal).
"""
from collections import defaultdict
import numpy as np
from scipy.stats import norm
from .vol_analytics import parse_deribit_instrument
SECONDS_PER_YEAR = 365 * 86400
def parse_book(summaries, now_ms, max_days=120):
"""Rows of (expiry, T, K, is_call, iv, oi, F) from Deribit book summaries."""
rows = []
for r in summaries:
p = parse_deribit_instrument(r.get("instrument_name", ""))
iv, F, oi = r.get("mark_iv"), r.get("underlying_price"), r.get("open_interest")
if not p or not iv or not F or iv <= 0 or F <= 0 or oi is None:
continue
exp, K, cp = p
T = (exp.timestamp() * 1000 - now_ms) / 1000 / SECONDS_PER_YEAR
if T <= 0 or T * 365 > max_days:
continue
rows.append((exp.strftime("%Y-%m-%d"), max(T, 1 / 8760), float(K), cp == "call",
float(iv) / 100, float(oi), float(F)))
return rows
def _greeks(S_over_F_scale, T, K, iv, F):
"""Gamma, vanna, charm (per day) and call delta at forward F·scale (arrays)."""
Fs = F * S_over_F_scale
sq = iv * np.sqrt(T)
d1 = (np.log(Fs / K) + 0.5 * sq ** 2) / sq
d2 = d1 - sq
pdf = norm.pdf(d1)
gamma = pdf / (Fs * sq)
vanna = -pdf * d2 / iv
charm = pdf * d2 / (2 * T) / 365
return gamma, vanna, charm, norm.cdf(d1)
def _signs(is_call, convention):
if convention == "short_all":
return -np.ones_like(is_call, dtype=float)
return np.where(is_call, 1.0, -1.0)
def positioning_report(summaries, now_ms, spot, convention="classic", max_days=120, grid_pct=25):
rows = parse_book(summaries, now_ms, max_days)
if not rows:
raise ValueError("no options with open interest in the horizon")
exp, T, K, call, iv, oi, F = map(np.array, zip(*rows))
sign = _signs(call, convention)
gamma, vanna, charm, dcall = _greeks(1.0, T, K, iv, F)
gex = sign * gamma * oi * spot ** 2 * 0.01 # USD per 1 % move
vex = sign * vanna * oi * spot * 0.01 # USD of delta per +1 vol pt
cex = sign * charm * oi * spot # USD of delta per day
dex = sign * np.where(call, dcall, dcall - 1) * oi * spot # USD delta held by dealers
# Per-strike aggregation across expiries.
by_k = defaultdict(lambda: {"call_oi": 0.0, "put_oi": 0.0, "call_gex": 0.0, "put_gex": 0.0})
for k_, c_, o_, g_ in zip(K, call, oi, gex):
d = by_k[float(k_)]
d["call_oi" if c_ else "put_oi"] += o_
d["call_gex" if c_ else "put_gex"] += g_
lo, hi = spot * (1 - grid_pct / 100), spot * (1 + grid_pct / 100)
strikes = sorted(k for k in by_k if lo <= k <= hi)
strike_rows = [{"strike": k, **by_k[k], "net_gex": by_k[k]["call_gex"] + by_k[k]["put_gex"]} for k in strikes]
# Gamma profile across hypothetical spot levels (forwards scale with spot, IV sticky-strike).
grid = np.linspace(lo, hi, 121)
profile = []
for s in grid:
g, *_ = _greeks(s / spot, T, K, iv, F)
profile.append(float((sign * g * oi * s ** 2 * 0.01).sum()))
profile = np.array(profile)
flip = None
cross = np.where(np.diff(np.sign(profile)) != 0)[0]
if len(cross):
# The crossing nearest to spot.
i = cross[np.argmin(np.abs(grid[cross] - spot))]
x0, x1, y0, y1 = grid[i], grid[i + 1], profile[i], profile[i + 1]
flip = float(x0 - y0 * (x1 - x0) / (y1 - y0))
call_gex_k = {r["strike"]: r["call_gex"] for r in strike_rows}
put_gex_k = {r["strike"]: r["put_gex"] for r in strike_rows}
call_oi_k = {r["strike"]: r["call_oi"] for r in strike_rows}
put_oi_k = {r["strike"]: r["put_oi"] for r in strike_rows}
walls = {
"call_wall": max(call_oi_k, key=lambda k: abs(call_gex_k[k])) if call_oi_k else None,
"put_wall": max(put_oi_k, key=lambda k: abs(put_gex_k[k])) if put_oi_k else None,
"max_call_oi": max(call_oi_k, key=call_oi_k.get) if call_oi_k else None,
"max_put_oi": max(put_oi_k, key=put_oi_k.get) if put_oi_k else None,
}
# Per-expiry: OI, notional, put/call, max pain, ATM vol and implied move.
expiries = []
for e in sorted(set(exp)):
m = exp == e
Ke, ce, oe, ive, Fe, Te = K[m], call[m], oi[m], iv[m], F[m][0], T[m][0]
cand = np.unique(Ke)
pain = [(oe * np.where(ce, np.maximum(x - Ke, 0), np.maximum(Ke - x, 0))).sum() for x in cand]
mp = float(cand[int(np.argmin(pain))])
atm_iv = float(np.interp(np.log(Fe), np.log(Ke[np.argsort(Ke)]), ive[np.argsort(Ke)]))
move = Fe * atm_iv * np.sqrt(Te)
expiries.append({"expiry": e, "days": float(Te * 365), "forward": float(Fe),
"call_oi": float(oe[ce].sum()), "put_oi": float(oe[~ce].sum()),
"put_call": float(oe[~ce].sum() / max(oe[ce].sum(), 1e-9)),
"notional_usd": float(oe.sum() * spot), "max_pain": mp,
"max_pain_vs_spot_pct": float((mp / spot - 1) * 100),
"atm_iv": atm_iv, "implied_move": float(move), "implied_move_pct": float(move / Fe * 100),
"range_lo": float(Fe - move), "range_hi": float(Fe + move),
"gex": float(gex[m].sum())})
total = float(gex.sum())
regime = ("positive gamma — dealer hedging dampens moves (mean reversion, pinning)" if total > 0 else
"negative gamma — dealer hedging amplifies moves (trend, vol expansion)")
if flip is not None:
regime += f"; spot is {'above' if spot > flip else 'below'} the flip"
return {
"spot": float(spot), "convention": convention, "n_options": int(len(rows)), "horizon_days": max_days,
"totals": {"gex": total, "vex": float(vex.sum()), "cex": float(cex.sum()), "dealer_delta_usd": float(dex.sum()),
"call_oi": float(oi[call].sum()), "put_oi": float(oi[~call].sum()),
"put_call": float(oi[~call].sum() / max(oi[call].sum(), 1e-9)),
"notional_usd": float(oi.sum() * spot)},
"gamma_flip": flip, "regime": regime, "walls": walls,
"strikes": strike_rows, "profile": {"spot": grid.tolist(), "gex": profile.tolist()},
"expiries": expiries,
}
+2 -2
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge, positioning
app = FastAPI(title="QuantRisk FX Terminal API",
description="Forex options risk analytics — Garman-Kohlhagen model.",
@@ -9,7 +9,7 @@ app = FastAPI(title="QuantRisk FX Terminal API",
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge]:
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge, positioning]:
app.include_router(router.router, prefix="/api")
@app.get("/")
+57
View File
@@ -0,0 +1,57 @@
"""
positioning.py — Positioning Desk: live BTC / ETH dealer gamma exposure, gamma flip, walls,
vanna / charm flows, max pain and implied moves from Deribit open interest (public API,
no key). Fails with 503 when Deribit is unreachable.
"""
import time
import numpy as np
from fastapi import APIRouter, HTTPException
from ..core.positioning import positioning_report
router = APIRouter(prefix="/positioning", tags=["positioning"])
def _book(currency):
from .vol import _cached, _deribit
def load():
rows = _deribit("get_book_summary_by_currency", currency=currency, kind="option")
idx = _deribit("get_index_price", index_name=f"{currency.lower()}_usd")["index_price"]
return rows, float(idx), int(time.time() * 1000)
return _cached(f"posbook:{currency}", 60, load)
@router.get("/{currency}")
def positioning(currency: str, convention: str = "classic", max_days: int = 120):
currency = currency.upper()
if currency not in ("BTC", "ETH"):
raise HTTPException(404, "Positioning is available for BTC and ETH (Deribit).")
if convention not in ("classic", "short_all"):
raise HTTPException(422, "convention must be 'classic' or 'short_all'.")
if not 1 <= max_days <= 400:
raise HTTPException(422, "max_days must be between 1 and 400.")
try:
rows, spot, now_ms = _book(currency)
except Exception as e:
raise HTTPException(503, f"Deribit data unavailable: {e}")
try:
rep = positioning_report(rows, now_ms, spot, convention, max_days)
except ValueError as e:
raise HTTPException(503, str(e))
return _clean({"currency": currency, "source": "Deribit public API (book summaries, 60 s cache)", **rep})
def _clean(obj):
if isinstance(obj, (bool, np.bool_)):
return bool(obj)
if isinstance(obj, (float, np.floating)):
x = float(obj)
return round(x, 6) if np.isfinite(x) else None
if isinstance(obj, dict):
return {str(k): _clean(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, np.ndarray)):
return [_clean(v) for v in obj]
return obj
+82
View File
@@ -0,0 +1,82 @@
"""Positioning Desk: Black-76 Greeks vs finite differences, GEX scaling, max pain, flip, endpoint."""
from datetime import timedelta
import numpy as np
import pytest
from fastapi.testclient import TestClient
from scipy.stats import norm
from app.core.positioning import _greeks, positioning_report
from app.main import app
from app.routers import positioning as pos_router
from tests.fixtures import MON, NOW, NOW_MS, deribit_book
client = TestClient(app)
def _row(K, cp, oi, days=30, iv=50.0, F=60000.0, ccy="BTC"):
exp = (NOW + timedelta(days=days)).replace(hour=8, minute=0)
return {"instrument_name": f"{ccy}-{exp.day}{MON[exp.month - 1]}{exp.strftime('%y')}-{int(K)}-{cp}",
"mark_iv": iv, "underlying_price": F, "open_interest": oi}
def test_greeks_match_finite_differences():
T, K, iv, F = 30 / 365, 62000.0, 0.55, 60000.0
g, va, ch, d = _greeks(1.0, T, K, iv, F)
delta = lambda f=F, s=iv, t=T: norm.cdf((np.log(f / K) + 0.5 * s * s * t) / (s * np.sqrt(t)))
h = F * 1e-4
assert g == pytest.approx((delta(F + h) - delta(F - h)) / (2 * h), rel=1e-5)
assert va == pytest.approx((delta(s=iv + 1e-5) - delta(s=iv - 1e-5)) / 2e-5, rel=1e-4)
assert ch == pytest.approx((delta(t=T - 1 / 365 / 2) - delta(t=T + 1 / 365 / 2)), rel=1e-3) # per day
def test_gex_is_dollar_delta_change_per_1pct():
rows = [_row(60000, "C", 100.0)]
rep = positioning_report(rows, NOW_MS, 60000.0)
T = rep["expiries"][0]["days"] / 365
sq = 0.5 * np.sqrt(T)
d = lambda F: norm.cdf((np.log(F / 60000) + 0.5 * sq * sq) / sq)
fd = (d(60000 * 1.005) - d(60000 * 0.995)) * 100 * 60000 # Δ$ change for a 1 % move
assert rep["totals"]["gex"] == pytest.approx(fd, rel=1e-3)
short = positioning_report(rows, NOW_MS, 60000.0, convention="short_all")
assert short["totals"]["gex"] == pytest.approx(-rep["totals"]["gex"])
def test_max_pain_and_put_call():
rows = [_row(50000, "P", 300), _row(55000, "P", 200), _row(60000, "C", 50), _row(65000, "C", 400),
_row(70000, "C", 100)]
rep = positioning_report(rows, NOW_MS, 60000.0)
e = rep["expiries"][0]
K = np.array([50000, 55000, 60000, 65000, 70000.0])
pain = lambda x: 300 * max(50000 - x, 0) + 200 * max(55000 - x, 0) + 50 * max(x - 60000, 0) + \
400 * max(x - 65000, 0) + 100 * max(x - 70000, 0)
assert e["max_pain"] == K[np.argmin([pain(x) for x in K])]
assert e["put_call"] == pytest.approx(500 / 550)
def test_flip_between_put_and_call_gamma():
"""Dealers short puts below and long calls above: GEX negative low, positive high → flip between."""
rows = [_row(50000, "P", 1000), _row(70000, "C", 1000)]
rep = positioning_report(rows, NOW_MS, 60000.0)
assert rep["gamma_flip"] is not None and 50000 < rep["gamma_flip"] < 70000
prof = np.array(rep["profile"]["gex"])
assert prof[0] < 0 < prof[-1]
assert rep["walls"]["call_wall"] == 70000 and rep["walls"]["put_wall"] == 50000
def test_endpoint(monkeypatch):
rows, _ = deribit_book(spot=83000)
monkeypatch.setattr(pos_router, "_book", lambda ccy: (rows, 83000.0, NOW_MS))
r = client.get("/api/positioning/BTC")
assert r.status_code == 200, r.text
js = r.json()
assert js["n_options"] > 50 and len(js["expiries"]) >= 4 and len(js["profile"]["gex"]) == 121
assert all(e["implied_move"] > 0 for e in js["expiries"])
assert client.get("/api/positioning/XRP").status_code == 404
assert client.get("/api/positioning/BTC?convention=nope").status_code == 422
def boom(ccy):
raise RuntimeError("offline")
monkeypatch.setattr(pos_router, "_book", boom)
assert client.get("/api/positioning/ETH").status_code == 503
+4
View File
@@ -66,3 +66,7 @@ export const priceTarf = (p) => api.post('/tarf/price', p).then(r=>r.data)
// AI Hedger (deep hedging)
export const trainDeepHedge = (p) => api.post('/deephedge/train', p, { timeout: 120000 }).then(r=>r.data)
// Positioning Desk (Deribit OI)
export const fetchPositioning = (ccy, convention = 'classic', maxDays = 120) =>
api.get(`/positioning/${ccy}`, { params: { convention, max_days: maxDays } }).then(r=>r.data)
+210
View File
@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore, isCrypto } from '../store/portfolio'
import { fetchPositioning } from '../api/client'
const LAYOUT = {
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:62, r:16, t:12, b:42 },
legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.22 },
hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' },
}
const CFG = { responsive:true, displayModeBar:false }
const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
const usd = (v) => {
if (v == null) return '—'
const a = Math.abs(v), s = v < 0 ? '−' : ''
return a >= 1e9 ? `${s}$${(a / 1e9).toFixed(2)}B` : a >= 1e6 ? `${s}$${(a / 1e6).toFixed(1)}M` : a >= 1e3 ? `${s}$${(a / 1e3).toFixed(0)}K` : `${s}$${a.toFixed(0)}`
}
const px = (v) => (v == null ? '—' : Number(v).toLocaleString(undefined, { maximumFractionDigits: v > 1000 ? 0 : 2 }))
const Chip = ({ active, onClick, children }) => (
<button onClick={onClick} className="font-mono" style={{ padding:'3px 9px', borderRadius:4, fontSize:9, cursor:'pointer',
border:'1px solid ' + (active ? 'var(--cyan)' : 'var(--border)'), background: active ? 'rgba(0,212,255,0.1)' : 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)' }}>{children}</button>
)
function Card({ title, right, children }) {
return (
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', margin:'2px 6px 4px', gap:8, flexWrap:'wrap' }}>
<span className="section-title" style={{ fontSize:10 }}>{title}</span>{right}
</div>
{children}
</div>
)
}
function Kpis({ items }) {
return (
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
{items.map(k => (
<div key={k.label} className="glass" title={k.hint} style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
<div style={{ color:'var(--text-muted)', fontSize:9, letterSpacing:'0.05em' }}>{k.label}</div>
<div className="font-mono" style={{ color: k.color ?? 'var(--cyan)', fontWeight:700, fontSize:14 }}>{k.value}</div>
{k.sub && <div className="font-mono" style={{ fontSize:9, color:'var(--text-muted)' }}>{k.sub}</div>}
</div>
))}
</div>
)
}
export default function Positioning() {
const { pair } = usePortfolioStore()
return <Desk key={pair} pair={pair} />
}
function Desk({ pair }) {
const [ccy, setCcy] = useState(isCrypto(pair) ? pair.slice(0, 3) : 'BTC')
const [conv, setConv] = useState('classic')
const [days, setDays] = useState(120)
const [nonce, setNonce] = useState(0)
const key = `${ccy}|${conv}|${days}|${nonce}`
const [res, setRes] = useState({ key:null, data:null, error:null })
useEffect(() => {
let stale = false
fetchPositioning(ccy, conv, days)
.then(data => { if (!stale) setRes({ key, data, error:null }) })
.catch(e => { if (!stale) setRes({ key, data:null, error: e?.response?.data?.detail?.toString() ?? 'Deribit data unavailable' }) })
return () => { stale = true }
}, [key, ccy, conv, days])
const d = res.data
const loading = res.key !== key
return (
<div style={{ padding:16 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap:12, marginBottom:12, flexWrap:'wrap' }}>
<div>
<div className="section-title">Positioning Desk — dealer gamma, flip, walls &amp; max pain ({ccy}, Deribit)</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Where dealer hedging dampens or amplifies moves · live open interest · Black-76 Greeks on each expiry&apos;s forward
</div>
</div>
<div style={{ display:'flex', gap:4, flexWrap:'wrap', alignItems:'center' }}>
{['BTC', 'ETH'].map(c => <Chip key={c} active={ccy === c} onClick={() => setCcy(c)}>{c}</Chip>)}
<span style={{ width:8 }} />
{[[7, '≤7d'], [30, '≤30d'], [120, '≤120d']].map(([v, l]) => <Chip key={v} active={days === v} onClick={() => setDays(v)}>{l}</Chip>)}
<span style={{ width:8 }} />
<Chip active={conv === 'classic'} onClick={() => setConv('classic')}>Dealers long calls / short puts</Chip>
<Chip active={conv === 'short_all'} onClick={() => setConv('short_all')}>Dealers short all</Chip>
<Chip onClick={() => setNonce(n => n + 1)}>↻</Chip>
</div>
</div>
{!isCrypto(pair) && <div style={{ fontSize:9, color:'var(--text-muted)', marginBottom:8 }}>
Listed-option open interest is only free for crypto (Deribit); showing {ccy}. Gold / FX positioning needs CME data.</div>}
{res.error && !loading && <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{res.error}</div>}
{!d && !res.error && <div style={{ fontSize:11, color:'var(--text-muted)', padding:12 }}>Loading Deribit open interest…</div>}
{d && <Body d={d} loading={loading} />}
</div>
)
}
function Body({ d, loading }) {
const t = d.totals
const pos = t.gex > 0
const flipDist = d.gamma_flip ? (d.spot / d.gamma_flip - 1) * 100 : null
const near = d.expiries[0]
return (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<Kpis items={[
{ label:'Index', value:px(d.spot), sub:`${d.n_options} options · ≤${d.horizon_days}d` },
{ label:'Net dealer GEX', value:`${usd(t.gex)} / 1%`, color: pos ? 'var(--green)' : 'var(--red)',
sub: pos ? 'long gamma → dampening' : 'short gamma → amplifying', hint:'USD dealers must trade for each 1% spot move' },
{ label:'Gamma flip', value:px(d.gamma_flip), color:'#ffd700',
sub: flipDist == null ? 'no zero crossing in ±25%' : `spot ${flipDist >= 0 ? '+' : ''}${flipDist.toFixed(1)}% vs flip` },
{ label:'Call wall', value:px(d.walls.call_wall), color:'var(--green)', sub:`max call OI ${px(d.walls.max_call_oi)}` },
{ label:'Put wall', value:px(d.walls.put_wall), color:'var(--red)', sub:`max put OI ${px(d.walls.max_put_oi)}` },
{ label:'Put / call OI', value:t.put_call.toFixed(2), sub:`${usd(t.notional_usd)} notional` },
{ label:'Vanna (per +1 vol)', value:usd(t.vex), sub:'dealer delta change', color:'#c084fc' },
{ label:'Charm (per day)', value:usd(t.cex), sub:'dealer delta drift', color:'#fb923c' },
...(near ? [{ label:`Next expiry ${near.expiry.slice(5)}`, value:`±${near.implied_move_pct.toFixed(1)}%`,
sub:`max pain ${px(near.max_pain)} (${near.max_pain_vs_spot_pct >= 0 ? '+' : ''}${near.max_pain_vs_spot_pct.toFixed(1)}%)` }] : []),
]} />
<div className="glass" style={{ borderRadius:8, padding:'6px 10px', marginBottom:12, fontSize:11 }}>
<span style={{ color:'var(--text-muted)' }}>Regime: </span>
<span style={{ color: pos ? 'var(--green)' : 'var(--red)' }}>{d.regime}</span>
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(440px, 1fr))', gap:12 }}>
<Card title="Gamma profile — net dealer GEX if spot moved here">
<Plot data={[
{ x:d.profile.spot, y:d.profile.gex.map(v => (v > 0 ? v : null)), type:'scatter', mode:'lines', fill:'tozeroy', name:'Long gamma',
line:{ color:'#00ff88', width:1.5 }, fillcolor:'rgba(0,255,136,0.12)' },
{ x:d.profile.spot, y:d.profile.gex.map(v => (v <= 0 ? v : null)), type:'scatter', mode:'lines', fill:'tozeroy', name:'Short gamma',
line:{ color:'#ff3d5a', width:1.5 }, fillcolor:'rgba(255,61,90,0.12)' },
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Spot'), yaxis:axis('USD per 1% move'),
shapes:[
{ type:'line', x0:d.spot, x1:d.spot, yref:'paper', y0:0, y1:1, line:{ color:'#e2e8f0', width:1.2 } },
...(d.gamma_flip ? [{ type:'line', x0:d.gamma_flip, x1:d.gamma_flip, yref:'paper', y0:0, y1:1, line:{ color:'#ffd700', dash:'dot', width:1.5 } }] : []),
],
annotations:[{ x:d.spot, y:1, yref:'paper', text:'spot', showarrow:false, font:{ size:9, color:'#e2e8f0' }, yanchor:'bottom' },
...(d.gamma_flip ? [{ x:d.gamma_flip, y:0.92, yref:'paper', text:'flip', showarrow:false, font:{ size:9, color:'#ffd700' } }] : [])] }}
config={CFG} style={{ width:'100%', height:300 }} />
</Card>
<Card title="GEX by strike (calls + / puts −)">
<Plot data={[
{ x:d.strikes.map(s => s.strike), y:d.strikes.map(s => s.call_gex), type:'bar', name:'Call GEX', marker:{ color:'rgba(0,255,136,0.6)' } },
{ x:d.strikes.map(s => s.strike), y:d.strikes.map(s => s.put_gex), type:'bar', name:'Put GEX', marker:{ color:'rgba(255,61,90,0.6)' } },
{ x:d.strikes.map(s => s.strike), y:d.strikes.map(s => s.net_gex), type:'scatter', mode:'lines', name:'Net', line:{ color:'#00d4ff', width:1.5 } },
]}
layout={{ ...LAYOUT, barmode:'relative', hovermode:'x unified', xaxis:axis('Strike'), yaxis:axis('USD per 1% move'),
shapes:[{ type:'line', x0:d.spot, x1:d.spot, yref:'paper', y0:0, y1:1, line:{ color:'#e2e8f0', width:1 } }] }}
config={CFG} style={{ width:'100%', height:300 }} />
</Card>
<Card title="Open interest by strike">
<Plot data={[
{ x:d.strikes.map(s => s.strike), y:d.strikes.map(s => s.call_oi), type:'bar', name:'Calls', marker:{ color:'rgba(0,255,136,0.55)' } },
{ x:d.strikes.map(s => s.strike), y:d.strikes.map(s => -s.put_oi), type:'bar', name:'Puts (−)', marker:{ color:'rgba(255,61,90,0.55)' } },
]}
layout={{ ...LAYOUT, barmode:'relative', hovermode:'x unified', xaxis:axis('Strike'), yaxis:axis('Contracts (coins)'),
shapes:[{ type:'line', x0:d.spot, x1:d.spot, yref:'paper', y0:0, y1:1, line:{ color:'#e2e8f0', width:1 } }] }}
config={CFG} style={{ width:'100%', height:300 }} />
</Card>
<Card title="Implied 1σ range and max pain by expiry">
<Plot data={[
{ x:d.expiries.map(e => e.expiry), y:d.expiries.map(e => e.forward), type:'scatter', mode:'markers', name:'Forward ±1σ',
marker:{ color:'#00d4ff', size:7 },
error_y:{ type:'data', symmetric:true, array:d.expiries.map(e => e.implied_move), color:'rgba(0,212,255,0.5)', thickness:1.5 } },
{ x:d.expiries.map(e => e.expiry), y:d.expiries.map(e => e.max_pain), type:'scatter', mode:'markers', name:'Max pain',
marker:{ color:'#ffd700', size:9, symbol:'diamond' } },
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Expiry', { type:'category' }), yaxis:axis('Price'),
shapes:[{ type:'line', xref:'paper', x0:0, x1:1, y0:d.spot, y1:d.spot, line:{ color:'rgba(255,255,255,0.3)', dash:'dot' } }] }}
config={CFG} style={{ width:'100%', height:300 }} />
</Card>
</div>
<div style={{ marginTop:12 }}>
<Card title="Expiry ladder">
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
{['Expiry', 'Days', 'Call OI', 'Put OI', 'P/C', 'Notional', 'ATM IV', '±1σ move', 'Max pain', 'vs spot', 'Net GEX'].map((h, i) =>
<th key={h} style={{ padding:'4px 8px', textAlign: i ? 'right' : 'left' }}>{h}</th>)}</tr></thead>
<tbody>{d.expiries.map(e => (
<tr key={e.expiry} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right' }}>
<td style={{ textAlign:'left', padding:'5px 8px' }}>{e.expiry}</td>
<td style={{ padding:'5px 8px' }}>{e.days.toFixed(1)}</td>
<td style={{ padding:'5px 8px', color:'var(--green)' }}>{px(e.call_oi)}</td>
<td style={{ padding:'5px 8px', color:'var(--red)' }}>{px(e.put_oi)}</td>
<td style={{ padding:'5px 8px' }}>{e.put_call.toFixed(2)}</td>
<td style={{ padding:'5px 8px' }}>{usd(e.notional_usd)}</td>
<td style={{ padding:'5px 8px' }}>{(e.atm_iv * 100).toFixed(1)}%</td>
<td style={{ padding:'5px 8px', color:'var(--cyan)' }}>±{e.implied_move_pct.toFixed(1)}%</td>
<td style={{ padding:'5px 8px', color:'#ffd700' }}>{px(e.max_pain)}</td>
<td style={{ padding:'5px 8px' }}>{e.max_pain_vs_spot_pct >= 0 ? '+' : ''}{e.max_pain_vs_spot_pct.toFixed(1)}%</td>
<td style={{ padding:'5px 8px', color: e.gex >= 0 ? 'var(--green)' : 'var(--red)' }}>{usd(e.gex)}</td>
</tr>))}</tbody>
</table>
</div>
</Card>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
{d.source}. GEX = Σ sign·Γ·OI·S²·1% (USD dealers trade per 1% move); flip = zero crossing of GEX re-computed across spot (sticky-strike IV).
The dealer-side sign is an assumption (SpotGamma &quot;classic&quot;: dealers long calls sold by overwriters / vaults, short puts bought by hedgers) —
switch conventions to see how much the conclusion depends on it. Max pain is a heuristic; it pins only when dealers are long gamma. Not trading advice.
</div>
</div>
)
}
+2
View File
@@ -19,6 +19,7 @@ import EventVol from '../components/EventVol'
import RiskDesk from '../components/RiskDesk'
import TarfDesk from '../components/TarfDesk'
import AiHedger from '../components/AiHedger'
import Positioning from '../components/Positioning'
const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap'))
@@ -52,6 +53,7 @@ const TABS = [
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
{ id:'eventvol', icon:'📆', label:'Event Vol', component: EventVol },
{ id:'positioning', icon:'🧲', label:'Positioning', component: Positioning },
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
{ id:'risk', icon:'🛡', label:'Risk Desk', component: RiskDesk },
{ id:'aihedger', icon:'🧠', label:'AI Hedger', component: AiHedger },
Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB