diff --git a/README.md b/README.md index b024fb1..cac9b23 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/backend/app/core/positioning.py b/backend/app/core/positioning.py new file mode 100644 index 0000000..19b91d7 --- /dev/null +++ b/backend/app/core/positioning.py @@ -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, + } diff --git a/backend/app/main.py b/backend/app/main.py index 1f7e35f..e583ef6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/routers/positioning.py b/backend/app/routers/positioning.py new file mode 100644 index 0000000..614fa57 --- /dev/null +++ b/backend/app/routers/positioning.py @@ -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 diff --git a/backend/tests/test_positioning.py b/backend/tests/test_positioning.py new file mode 100644 index 0000000..d5ec131 --- /dev/null +++ b/backend/tests/test_positioning.py @@ -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 diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index b80e65f..80d7c55 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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) diff --git a/frontend/src/components/Positioning.jsx b/frontend/src/components/Positioning.jsx new file mode 100644 index 0000000..9434fcf --- /dev/null +++ b/frontend/src/components/Positioning.jsx @@ -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 }) => ( + +) + +function Card({ title, right, children }) { + return ( +
+
+ {title}{right} +
+ {children} +
+ ) +} + +function Kpis({ items }) { + return ( +
+ {items.map(k => ( +
+
{k.label}
+
{k.value}
+ {k.sub &&
{k.sub}
} +
+ ))} +
+ ) +} + +export default function Positioning() { + const { pair } = usePortfolioStore() + return +} + +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 ( +
+
+
+
Positioning Desk — dealer gamma, flip, walls & max pain ({ccy}, Deribit)
+
+ Where dealer hedging dampens or amplifies moves · live open interest · Black-76 Greeks on each expiry's forward +
+
+
+ {['BTC', 'ETH'].map(c => setCcy(c)}>{c})} + + {[[7, '≤7d'], [30, '≤30d'], [120, '≤120d']].map(([v, l]) => setDays(v)}>{l})} + + setConv('classic')}>Dealers long calls / short puts + setConv('short_all')}>Dealers short all + setNonce(n => n + 1)}>↻ +
+
+ {!isCrypto(pair) &&
+ Listed-option open interest is only free for crypto (Deribit); showing {ccy}. Gold / FX positioning needs CME data.
} + {res.error && !loading &&
{res.error}
} + {!d && !res.error &&
Loading Deribit open interest…
} + {d && } +
+ ) +} + +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 ( +
+ = 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)}%)` }] : []), + ]} /> +
+ Regime: + {d.regime} +
+
+ + (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 }} /> + + + 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 }} /> + + + 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 }} /> + + + 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 }} /> + +
+
+ +
+ + + {['Expiry', 'Days', 'Call OI', 'Put OI', 'P/C', 'Notional', 'ATM IV', '±1σ move', 'Max pain', 'vs spot', 'Net GEX'].map((h, i) => + )} + {d.expiries.map(e => ( + + + + + + + + + + + + + ))} +
{h}
{e.expiry}{e.days.toFixed(1)}{px(e.call_oi)}{px(e.put_oi)}{e.put_call.toFixed(2)}{usd(e.notional_usd)}{(e.atm_iv * 100).toFixed(1)}%±{e.implied_move_pct.toFixed(1)}%{px(e.max_pain)}{e.max_pain_vs_spot_pct >= 0 ? '+' : ''}{e.max_pain_vs_spot_pct.toFixed(1)}%= 0 ? 'var(--green)' : 'var(--red)' }}>{usd(e.gex)}
+
+
+
+
+ {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. +
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 00c70ea..66a9fd1 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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 }, diff --git a/screenshots/positioning-btc.png b/screenshots/positioning-btc.png new file mode 100644 index 0000000..43b4cf0 Binary files /dev/null and b/screenshots/positioning-btc.png differ diff --git a/screenshots/positioning-eth.png b/screenshots/positioning-eth.png new file mode 100644 index 0000000..98c7314 Binary files /dev/null and b/screenshots/positioning-eth.png differ