mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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'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 "classic": 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>
|
||||
)
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user