Add Carry & RV Scanner: Deribit basis/funding, SVI rich/cheap net of bid/ask, parity and static-arb checks

- core/rv_scanner.py: futures basis curve and perp funding; options-implied forward vs listed
  future; raw-SVI residuals per option netted against half the bid/ask in vol (Black-76 coin
  inversion) and ranked by USD edge; coin-settled put-call parity with executable edge; vertical
  and butterfly static-arbitrage checks on marks and at the touch; VRP vs 30d realised.
- routers/rvscan.py: GET /api/rvscan/{BTC|ETH}, Deribit public API with 60 s cache, 503 on failure.
- RV Scanner tab: carry chart, smile-residual scatter with spread bars, trade ideas, expiry ladder,
  parity and static-arbitrage panel.
- Tests on a synthetic Black-76 book: parity/inversion, carry, clean book, planted mispricings.

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-25 05:15:57 +00:00
parent 96c6cbd518
commit e768be9ec9
11 changed files with 596 additions and 2 deletions
+1
View File
@@ -43,6 +43,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
| **Hedge Lab** | Monte Carlo delta-hedging / gamma-scalping P&L of your portfolio: realised vs implied vol, GBM / Merton jumps / historical block bootstrap, weekend gaps for 24/5 markets, time or delta-band hedging, transaction costs and carry, with Derman-Kamal and Leland benchmarks and a hedge-frequency risk-vs-cost sweep |
| **Risk Desk** | Market risk of the portfolio option book with full revaluation on joint spot × implied-vol history (Deribit DVOL, Cboe GVZ / VXSLV, else EWMA proxy): VaR 99% / ES 97.5% (FRTB) by historical, filtered-historical (EWMA-scaled), delta-gamma Cornish-Fisher, Student-t Monte Carlo and delta-normal; 1- and 10-day horizons; stressed window (worst 250 days); rolling backtest with Kupiec / Christoffersen tests and Basel traffic light; spot × vol ladder; worst-day and what-if P&L explain by Greek (Δ, Γ, vega, vanna, volga, θ, unexplained) |
| **Margin Desk** | Exchange margin for the option book: CME SPAN-style 16-scenario scan risk (price / vol scan, ±3× extreme at 33 %, short-option minimum; COMEX %-of-notional defaults) and Deribit-style portfolio margin (±16 % × vol-shock matrix, dampened extended table, delta contingency, MM = 0.8 × IM); margin-hike shock and cash call; equity-vs-maintenance ladder with call levels; Monte-Carlo margin-call and liquidation probabilities with jumps and spot-vol co-movement |
| **RV Scanner** | Deribit BTC / ETH carry and relative value: dated-futures basis curve, perpetual funding (8h × 3 × 365) and options-implied forwards vs listed futures; raw-SVI fit per expiry with each option's residual netted against half its bid/ask in vol (coin premiums inverted with Black-76) → rich / cheap ideas ranked by USD edge; coin-settled put-call parity C − P = (F − K)/F with executable edge at the touch; vertical and butterfly static-arbitrage checks on marks and at the touch; ATM vol vs 30-day realised (VRP) per expiry |
| **AI Hedger** | Deep hedging of a short option under proportional transaction costs and jumps, trained server-side in pure numpy (no GPU, no API key): a No-Transaction-Band network (Imaki et al. 2021, back-propagation through time) and a feed-forward deep hedge (Buehler et al. 2019) minimising CVaR, benchmarked out of sample against BS delta and a tuned Whalley-Wilmott band; regime-shift tests (vol +50%, crash jumps); learned band vs Whalley-Wilmott, P&L distributions, turnover, risk-adjusted price, training curves |
| **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 |
+186
View File
@@ -0,0 +1,186 @@
"""
rv_scanner.py — Carry & relative-value scanner for Deribit BTC / ETH.
Carry
Dated futures: annualised basis b = (F/S − 1) · 365/days (simple) against the index S.
Perpetual: annualised funding = funding_8h × 3 × 365.
Options-implied forward per expiry (Deribit's `underlying_price`, the synthetic forward the
options are marked on) vs the listed future of the same expiry → forward-basis dislocation.
Relative value on the smile
Raw SVI is fitted to each expiry's OTM mark IVs (Gatheral 2004); each option's residual
r = σ_mark − σ_SVI is compared with its bid/ask in vol terms (bid/ask coin prices inverted with
Black-76 on the forward). An option is flagged rich (sell) / cheap (buy) only when |r| exceeds
half the bid/ask spread; edge in USD = (|r| − ½ spread) · vega per vol point.
Arbitrage / consistency checks (Deribit options are European, coin-settled):
put-call parity in coin terms C − P = (F − K)/F (premium in coin, marked on the forward F);
executable parity edge using ask for the leg bought and bid for the leg sold;
static no-arbitrage on marks: call prices non-increasing and convex in strike
(vertical-spread and butterfly bounds, Carr & Madan 2005).
Vol carry
ATM vol per expiry against the 30-day realised vol of the perpetual → vol risk premium.
References:
Gatheral, J. (2004). "A parsimonious arbitrage-free implied volatility parameterization".
Carr, P. & Madan, D. (2005). "A note on sufficient conditions for no arbitrage", FRL 2(3).
Black, F. (1976). "The pricing of commodity contracts".
"""
from collections import defaultdict
import numpy as np
from scipy.optimize import brentq
from scipy.stats import norm
from .vol_analytics import _svi_vol, fit_svi, parse_deribit_instrument
YEAR_MS = 365 * 86400 * 1000
def black76_coin(F, K, T, sigma, call):
"""Deribit-style premium in coin: Black-76 undiscounted USD value divided by the forward."""
sq = sigma * np.sqrt(T)
d1 = (np.log(F / K) + 0.5 * sq * sq) / sq
d2 = d1 - sq
usd = F * norm.cdf(d1) - K * norm.cdf(d2) if call else K * norm.cdf(-d2) - F * norm.cdf(-d1)
return usd / F
def implied_vol_coin(price, F, K, T, call):
"""Invert a coin premium to Black-76 vol; None if outside no-arbitrage bounds."""
intrinsic = max((F - K) / F, 0) if call else max((K - F) / F, 0)
upper = 1.0 if call else K / F
if price is None or price <= intrinsic + 1e-10 or price >= upper:
return None
try:
return float(brentq(lambda s: black76_coin(F, K, T, s, call) - price, 1e-4, 10.0, xtol=1e-8))
except ValueError:
return None
def carry_report(futures, index, now_ms):
"""Futures basis curve and perpetual funding."""
rows, perp = [], None
for f in futures:
name = f.get("instrument_name", "")
mark = f.get("mark_price")
if not mark:
continue
if name.endswith("PERPETUAL"):
f8 = f.get("funding_8h")
perp = {"instrument": name, "mark": float(mark), "premium_pct": (float(mark) / index - 1) * 100,
"funding_8h": f8, "funding_annual": float(f8) * 3 * 365 if f8 is not None else None}
continue
try:
from datetime import datetime, timezone
exp = datetime.strptime(name.split("-")[1], "%d%b%y").replace(hour=8, tzinfo=timezone.utc)
except (IndexError, ValueError):
continue
days = (exp.timestamp() * 1000 - now_ms) / 86400000
if days <= 0.5:
continue
rows.append({"instrument": name, "expiry": exp.strftime("%Y-%m-%d"), "days": days, "mark": float(mark),
"basis_pct": (float(mark) / index - 1) * 100,
"basis_annual": (float(mark) / index - 1) * 365 / days})
return {"index": index, "futures": sorted(rows, key=lambda r: r["days"]), "perpetual": perp}
def scan(options, index, now_ms, futures_curve=None, realised_30d=None, min_oi=0.0, max_days=200):
"""Smile residuals, executable edges, parity and static-arbitrage checks per expiry."""
by_exp = defaultdict(list)
for r in options:
p = parse_deribit_instrument(r.get("instrument_name", ""))
if not p:
continue
exp, K, cp = p
T = (exp.timestamp() * 1000 - now_ms) / YEAR_MS
F = r.get("underlying_price")
iv = r.get("mark_iv")
if T <= 1 / 365 or T * 365 > max_days or not F or not iv:
continue
by_exp[exp.strftime("%Y-%m-%d")].append({
"name": r["instrument_name"], "K": float(K), "call": cp == "call", "F": float(F), "T": T,
"iv": float(iv) / 100, "mark": r.get("mark_price"), "bid": r.get("bid_price"), "ask": r.get("ask_price"),
"oi": float(r.get("open_interest") or 0)})
fut_by_exp = {f["expiry"]: f for f in (futures_curve or {}).get("futures", [])}
ideas, expiries, parity, arbs = [], [], [], []
for e in sorted(by_exp):
rows = by_exp[e]
F = float(np.median([x["F"] for x in rows]))
T = rows[0]["T"]
otm = [x for x in rows if (x["call"] and x["K"] >= F) or (not x["call"] and x["K"] < F)]
if len(otm) < 5:
continue
k = np.log(np.array([x["K"] for x in otm]) / F)
params, rmse = fit_svi(k, np.array([x["iv"] for x in otm]), T)
atm = _svi_vol(params, 0.0, T)
res_rows = []
for x in otm:
kk = np.log(x["K"] / F)
fit = _svi_vol(params, kk, T)
resid = x["iv"] - fit
bid_iv = implied_vol_coin(x["bid"], F, x["K"], T, x["call"]) if x["bid"] else None
ask_iv = implied_vol_coin(x["ask"], F, x["K"], T, x["call"]) if x["ask"] else None
half = (ask_iv - bid_iv) / 2 if (bid_iv and ask_iv and ask_iv > bid_iv) else None
sq = x["iv"] * np.sqrt(T)
d1 = (np.log(F / x["K"]) + 0.5 * sq * sq) / sq
vega_pt = F * norm.pdf(d1) * np.sqrt(T) / 100 # USD per vol point per coin
edge_vol = abs(resid) - (half or 0.0)
row = {"instrument": x["name"], "expiry": e, "days": T * 365, "strike": x["K"], "type": "call" if x["call"] else "put",
"moneyness": float(kk), "mark_iv": x["iv"], "fit_iv": fit, "residual": resid,
"bid_iv": bid_iv, "ask_iv": ask_iv, "half_spread": half, "vega_usd_per_pt": vega_pt,
"edge_vol": edge_vol, "edge_usd": max(edge_vol, 0) * 100 * vega_pt, "oi": x["oi"]}
res_rows.append(row)
if edge_vol > 0 and x["oi"] >= min_oi and (half is not None or abs(resid) > 0.01):
ideas.append({**row, "side": "sell (rich)" if resid > 0 else "buy (cheap)"})
# Put-call parity per strike (both legs listed).
legs = defaultdict(dict)
for x in rows:
legs[x["K"]]["C" if x["call"] else "P"] = x
for K, lp in legs.items():
if "C" not in lp or "P" not in lp or not lp["C"]["mark"] or not lp["P"]["mark"]:
continue
c, p = lp["C"], lp["P"]
theo = (F - K) # USD, forward-style (coin-settled)
mark_dev = (c["mark"] - p["mark"]) * F - theo
exe = None
if c["ask"] and p["bid"] and c["bid"] and p["ask"]:
long_syn = theo - (c["ask"] - p["bid"]) * F # buy C, sell P, short forward
short_syn = (c["bid"] - p["ask"]) * F - theo # sell C, buy P, long forward
exe = max(long_syn, short_syn)
parity.append({"expiry": e, "strike": K, "mark_dev_usd": mark_dev, "mark_dev_bp": mark_dev / F * 1e4,
"executable_edge_usd": exe})
# Static no-arbitrage on call marks; each flag also priced at the touch (buy at ask, sell at bid).
calls = sorted([(x["K"], x["mark"] * F, (x["bid"] or 0) * F or None, (x["ask"] or 0) * F or None)
for x in rows if x["call"] and x["mark"]])
def flag(kind, strikes, viol, exe):
arbs.append({"expiry": e, "type": kind, "strikes": strikes, "violation_usd": viol,
"executable_usd": exe, "executable": bool(exe is not None and exe > 0)})
for (k1, c1, b1, a1), (k2, c2, b2, a2) in zip(calls, calls[1:]):
if c2 > c1 + 1e-9 * F:
flag("vertical (call price rises with strike)", [k1, k2], c2 - c1, b2 - a1 if (b2 and a1) else None)
if c1 - c2 > (k2 - k1) + 1e-9 * F:
flag("vertical (spread > strike width)", [k1, k2], c1 - c2 - (k2 - k1),
b1 - a2 - (k2 - k1) if (b1 and a2) else None)
for (k1, c1, b1, a1), (k2, c2, b2, a2), (k3, c3, b3, a3) in zip(calls, calls[1:], calls[2:]):
w = (k3 - k2) / (k3 - k1)
fly = w * c1 + (1 - w) * c3 - c2
if fly < -1e-6 * F:
flag("butterfly (convexity)", [k1, k2, k3], -fly,
b2 - w * a1 - (1 - w) * a3 if (a1 and b2 and a3) else None)
fut = fut_by_exp.get(e)
expiries.append({"expiry": e, "days": T * 365, "forward": F, "atm_iv": atm, "svi_rmse": rmse,
"forward_basis_annual": (F / index - 1) * 365 / (T * 365),
"future": fut["mark"] if fut else None,
"fwd_vs_future_bp": (F / fut["mark"] - 1) * 1e4 if fut else None,
"vrp": atm - realised_30d if realised_30d else None,
"residuals": res_rows})
ideas.sort(key=lambda r: -r["edge_usd"])
parity.sort(key=lambda r: -abs(r["mark_dev_usd"]))
return {"expiries": expiries, "ideas": ideas[:25], "parity": parity[:25],
"parity_max_executable": max((p["executable_edge_usd"] for p in parity if p["executable_edge_usd"] is not None), default=None),
"static_arbitrage": sorted(arbs, key=lambda a: (not a["executable"], -a["violation_usd"])),
"static_arbitrage_executable": int(sum(bool(a["executable"]) for a in arbs)), "realised_30d": realised_30d}
+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, positioning, smiledyn, margin
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, smiledyn, margin, rvscan
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, positioning, smiledyn, margin]:
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, smiledyn, margin, rvscan]:
app.include_router(router.router, prefix="/api")
@app.get("/")
+65
View File
@@ -0,0 +1,65 @@
"""
rvscan.py — Carry & RV Scanner: Deribit futures basis and perp funding, options-implied forwards,
SVI-residual rich/cheap scan net of bid/ask, put-call parity and static-arbitrage checks, and
the vol risk premium per expiry. Public Deribit API, no key; 503 when unreachable.
"""
import time
import numpy as np
from fastapi import APIRouter, HTTPException
from ..core.rv_scanner import carry_report, scan
router = APIRouter(prefix="/rvscan", tags=["rvscan"])
def _load(currency):
from .vol import _cached, _crypto_closes, _deribit
def load():
opts = _deribit("get_book_summary_by_currency", currency=currency, kind="option")
futs = _deribit("get_book_summary_by_currency", currency=currency, kind="future")
idx = float(_deribit("get_index_price", index_name=f"{currency.lower()}_usd")["index_price"])
return opts, futs, idx, int(time.time() * 1000)
opts, futs, idx, now = _cached(f"rvscan:{currency}", 60, load)
rv = None
try:
closes = _cached(f"closes:{currency}USD", 900, lambda: _crypto_closes(currency))
r = np.diff(np.log(np.asarray(closes[-31:], float)))
rv = float(np.std(r, ddof=1) * np.sqrt(365)) if len(r) >= 20 else None
except Exception:
pass
return opts, futs, idx, now, rv
@router.get("/{currency}")
def rv_scan(currency: str, min_oi: float = 0.0, max_days: int = 200):
currency = currency.upper()
if currency not in ("BTC", "ETH"):
raise HTTPException(404, "The RV scanner covers BTC and ETH (Deribit).")
try:
opts, futs, idx, now, rv = _load(currency)
except Exception as e:
raise HTTPException(503, f"Deribit data unavailable: {e}")
carry = carry_report(futs, idx, now)
rep = scan(opts, idx, now, carry, rv, min_oi=min_oi, max_days=max_days)
if not rep["expiries"]:
raise HTTPException(503, "No Deribit expiries with enough quotes.")
return _clean({"currency": currency, "source": "Deribit public API (book summaries, 60 s cache)",
"carry": carry, **rep})
def _clean(obj):
if isinstance(obj, (bool, np.bool_)):
return bool(obj)
if isinstance(obj, np.integer):
return int(obj)
if isinstance(obj, (float, np.floating)):
x = float(obj)
return round(x, 8) 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
+40
View File
@@ -0,0 +1,40 @@
"""Synthetic Deribit book priced exactly with Black-76 (coin premiums) on a known SVI smile."""
from datetime import datetime, timedelta, timezone
import numpy as np
from app.core.rv_scanner import black76_coin
from app.core.vol_analytics import svi_total_variance
NOW = datetime(2026, 10, 25, 5, 0, tzinfo=timezone.utc)
NOW_MS = NOW.timestamp() * 1000
MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]
def name(ccy, exp, K, cp):
return f"{ccy}-{exp.day}{MON[exp.month - 1]}{exp.strftime('%y')}-{int(K)}-{cp}"
def book(spot=83000.0, ccy="BTC", spread_vol=0.005, basis=0.06, days_list=(9, 30, 58)):
opts, futs = [], []
for days in days_list:
exp = (NOW + timedelta(days=days)).replace(hour=8, minute=0)
T = (exp - NOW).total_seconds() / (365 * 86400)
F = spot * (1 + basis * T)
atm = 0.45
p = (atm ** 2 * T * 0.8, 0.1 * np.sqrt(T), -0.3, 0.0, 0.15)
w0 = svi_total_variance(0, *p)
p = (p[0] + atm ** 2 * T - w0,) + p[1:]
for K in np.arange(round(F * 0.7 / 1000) * 1000, F * 1.4, 1000.0):
iv = float(np.sqrt(svi_total_variance(np.log(K / F), *p) / T))
for cp in "CP":
call = cp == "C"
mark = black76_coin(F, K, T, iv, call)
bid = black76_coin(F, K, T, iv - spread_vol, call)
ask = black76_coin(F, K, T, iv + spread_vol, call)
opts.append({"instrument_name": name(ccy, exp, K, cp), "mark_iv": iv * 100, "underlying_price": F,
"mark_price": mark, "bid_price": bid, "ask_price": ask, "open_interest": 100.0})
futs.append({"instrument_name": f"{ccy}-{exp.day}{MON[exp.month - 1]}{exp.strftime('%y')}", "mark_price": F})
futs.append({"instrument_name": f"{ccy}-PERPETUAL", "mark_price": spot * 1.0002, "funding_8h": 0.0001})
return opts, futs
+85
View File
@@ -0,0 +1,85 @@
"""RV Scanner: coin Black-76 inversion, carry maths, clean book → no signals, planted mispricings found, endpoint."""
import numpy as np
import pytest
from fastapi.testclient import TestClient
from app.core.rv_scanner import black76_coin, carry_report, implied_vol_coin, scan
from app.main import app
from app.routers import rvscan as rv_router
from tests.rv_book import NOW_MS, book
client = TestClient(app)
SPOT = 83000.0
def test_coin_black76_parity_and_inversion():
F, K, T = 85000.0, 90000.0, 30 / 365
c, p = black76_coin(F, K, T, 0.5, True), black76_coin(F, K, T, 0.5, False)
assert (c - p) * F == pytest.approx(F - K, abs=1e-6) # forward-style parity in coin
assert implied_vol_coin(c, F, K, T, True) == pytest.approx(0.5, abs=1e-6)
assert implied_vol_coin(0.0, F, K, T, True) is None # below intrinsic bound
def test_carry():
_, futs = book()
carry = carry_report(futs, SPOT, NOW_MS)
for f in carry["futures"]:
assert f["basis_annual"] == pytest.approx(0.06, rel=0.05) # planted 6 % simple basis
assert carry["perpetual"]["funding_annual"] == pytest.approx(0.0001 * 3 * 365)
def test_clean_book_has_no_signals():
opts, futs = book()
rep = scan(opts, SPOT, NOW_MS, carry_report(futs, SPOT, NOW_MS), realised_30d=0.40)
assert rep["ideas"] == [] and rep["static_arbitrage"] == []
assert max(abs(p["mark_dev_bp"]) for p in rep["parity"]) < 0.01
assert rep["parity_max_executable"] < 0 # spreads kill parity "arb"
for e in rep["expiries"]:
assert e["svi_rmse"] < 1e-3 and abs(e["fwd_vs_future_bp"]) < 1e-6
assert e["vrp"] == pytest.approx(e["atm_iv"] - 0.40)
def test_planted_mispricings_are_found():
opts, futs = book()
target = next(o for o in opts if o["instrument_name"].endswith("-95000-C") and "24NOV26" in o["instrument_name"])
# Rich call: mark 6 vol over the smile, quotes moved with it.
F, K = target["underlying_price"], 95000.0
T = 30 / 365 - 5 / (24 * 365)
iv = target["mark_iv"] / 100 + 0.06
for key, s in (("mark_price", 0), ("bid_price", -0.005), ("ask_price", 0.005)):
target[key] = black76_coin(F, K, T, iv + s, True)
target["mark_iv"] = iv * 100
rep = scan(opts, SPOT, NOW_MS, carry_report(futs, SPOT, NOW_MS))
top = rep["ideas"][0]
assert top["instrument"] == target["instrument_name"] and top["side"].startswith("sell")
assert top["residual"] > 0.04 and top["edge_usd"] > 0
# A vertical-spread violation: a higher strike call priced above a lower one.
opts2, futs2 = book()
c1 = next(o for o in opts2 if o["instrument_name"].endswith("-90000-C") and "-3NOV26-" in o["instrument_name"])
c2 = next(o for o in opts2 if o["instrument_name"].endswith("-91000-C") and "-3NOV26-" in o["instrument_name"])
c2["mark_price"] = c1["mark_price"] * 1.05
rep2 = scan(opts2, SPOT, NOW_MS, carry_report(futs2, SPOT, NOW_MS))
assert any(a["type"].startswith("vertical") and a["strikes"] == [90000.0, 91000.0] for a in rep2["static_arbitrage"])
assert any(p["strike"] == 91000.0 and abs(p["mark_dev_bp"]) > 1 for p in rep2["parity"])
v = next(a for a in rep2["static_arbitrage"] if a["strikes"] == [90000.0, 91000.0])
assert not v["executable"] and v["executable_usd"] < 0 # marks only; the touch is fine
c2["bid_price"] = c1["ask_price"] * 1.02 # now sell 91k bid > buy 90k ask
rep3 = scan(opts2, SPOT, NOW_MS)
v3 = next(a for a in rep3["static_arbitrage"] if a["strikes"] == [90000.0, 91000.0])
assert v3["executable"] and v3["executable_usd"] > 0 and rep3["static_arbitrage"][0]["executable"]
def test_endpoint(monkeypatch):
opts, futs = book()
monkeypatch.setattr(rv_router, "_load", lambda ccy: (opts, futs, SPOT, NOW_MS, 0.42))
r = client.get("/api/rvscan/BTC")
assert r.status_code == 200, r.text
js = r.json()
assert len(js["expiries"]) == 3 and js["carry"]["perpetual"]["funding_annual"] > 0
assert client.get("/api/rvscan/XRP").status_code == 404
def boom(ccy):
raise RuntimeError("offline")
monkeypatch.setattr(rv_router, "_load", boom)
assert client.get("/api/rvscan/ETH").status_code == 503
+3
View File
@@ -77,3 +77,6 @@ export const fetchSmileDynQuotes = (p) => api.post('/smiledyn/quotes', p).then(r
// Margin Desk
export const fetchMarginBook = (p) => api.post('/margin/book', p, { timeout: 60000 }).then(r=>r.data)
// Carry & RV Scanner
export const fetchRvScan = (ccy) => api.get(`/rvscan/${ccy}`).then(r=>r.data)
+212
View File
@@ -0,0 +1,212 @@
import { useEffect, useState } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore, isCrypto } from '../store/portfolio'
import { fetchRvScan } from '../api/client'
const LAYOUT = {
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:56, 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 pct = (v, d = 1) => (v == null ? '—' : (v * 100).toFixed(d) + '%')
const usd = (v, d = 0) => (v == null ? '—' : `${v < 0 ? '−' : ''}$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits:d })}`)
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>
)
}
const th = (h, i) => <th key={h} style={{ padding:'4px 8px', textAlign: i ? 'right' : 'left' }}>{h}</th>
const td = (v, extra = {}) => <td style={{ padding:'5px 8px', ...extra }}>{v}</td>
export default function RvScanner() {
const { pair } = usePortfolioStore()
return <Desk key={pair} pair={pair} />
}
function Desk({ pair }) {
const [ccy, setCcy] = useState(isCrypto(pair) ? pair.slice(0, 3) : 'BTC')
const [nonce, setNonce] = useState(0)
const key = `${ccy}|${nonce}`
const [res, setRes] = useState({ key:null, data:null, error:null })
useEffect(() => {
let stale = false
fetchRvScan(ccy)
.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])
const d = res.data
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">Carry &amp; RV Scanner — basis, funding, smile mispricings, parity ({ccy}, Deribit)</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Where the carry is, which options are rich or cheap after the bid/ask, and whether the book is arbitrage-free
</div>
</div>
<div style={{ display:'flex', gap:4 }}>
{['BTC', 'ETH'].map(c => <Chip key={c} active={ccy === c} onClick={() => setCcy(c)}>{c}</Chip>)}
<Chip onClick={() => setNonce(n => n + 1)}>↻ Rescan</Chip>
</div>
</div>
{!isCrypto(pair) && <div style={{ fontSize:9, color:'var(--text-muted)', marginBottom:8 }}>Free listed option and futures books exist for crypto (Deribit); showing {ccy}.</div>}
{res.error && <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 }}>Scanning Deribit…</div>}
{d && <Body d={d} loading={res.key !== key} />}
</div>
)
}
function Body({ d, loading }) {
const [sel, setSel] = useState(d.expiries.find(e => e.days > 20)?.expiry ?? d.expiries[0].expiry)
const e = d.expiries.find(x => x.expiry === sel) ?? d.expiries[0]
const c = d.carry
const front = c.futures[0]
const back = c.futures.at(-1)
const best = d.ideas[0]
const near = d.expiries.find(x => x.days > 20) ?? d.expiries[0]
return (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<Kpis items={[
{ label:'Index', value:px(c.index) },
...(c.perpetual ? [{ label:'Perp funding (ann.)', value:pct(c.perpetual.funding_annual, 2),
sub:`premium ${c.perpetual.premium_pct.toFixed(3)}%`, color: c.perpetual.funding_annual > 0 ? 'var(--green)' : 'var(--red)' }] : []),
...(front ? [{ label:'Basis curve (ann.)', value:`${pct(front.basis_annual, 1)} → ${pct(back.basis_annual, 1)}`,
sub:`${front.days.toFixed(0)}d → ${back.days.toFixed(0)}d`, hint:'Cash-and-carry yield: long spot, short future' }] : []),
{ label:`VRP ${near.expiry.slice(5)}`, value: near.vrp == null ? '—' : `${(near.vrp * 100).toFixed(1)} vol`,
sub:`ATM ${pct(near.atm_iv)} vs RV30 ${pct(d.realised_30d)}`, color: (near.vrp ?? 0) > 0 ? 'var(--green)' : 'var(--red)' },
{ label:'Smile signals', value:String(d.ideas.length), sub: best ? `best ${best.side.split(' ')[0]} ${best.instrument}` : 'none beyond bid/ask', color:'#ffd700' },
...(best ? [{ label:'Best edge', value:usd(best.edge_usd), sub:`${(best.edge_vol * 100).toFixed(2)} vol net of ½ spread · per coin` }] : []),
{ label:'Parity: best executable', value: d.parity_max_executable == null ? '—' : usd(d.parity_max_executable),
color: (d.parity_max_executable ?? -1) > 0 ? 'var(--red)' : 'var(--green)', sub:(d.parity_max_executable ?? -1) > 0 ? 'arb before fees' : 'no arb after spreads' },
{ label:'Static arbitrage', value:`${d.static_arbitrage_executable ?? 0} / ${d.static_arbitrage.length}`,
color: d.static_arbitrage_executable ? 'var(--red)' : d.static_arbitrage.length ? '#ffd700' : 'var(--green)', sub:'executable at touch / on marks' },
]} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(440px, 1fr))', gap:12 }}>
<Card title="Carry — futures basis, options-implied forwards, perp funding (annualised)">
<Plot data={[
{ x:c.futures.map(f => f.days), y:c.futures.map(f => f.basis_annual * 100), type:'scatter', mode:'lines+markers', name:'Dated futures',
line:{ color:'#00d4ff', width:2 }, text:c.futures.map(f => f.instrument), hovertemplate:'%{text}: %{y:.2f}%<extra></extra>' },
{ x:d.expiries.map(x => x.days), y:d.expiries.map(x => x.forward_basis_annual * 100), type:'scatter', mode:'markers', name:'Options-implied forward',
marker:{ color:'#ffd700', size:8, symbol:'diamond' } },
...(c.perpetual?.funding_annual != null ? [{ x:[0], y:[c.perpetual.funding_annual * 100], type:'scatter', mode:'markers', name:'Perp funding',
marker:{ color:'#c084fc', size:11, symbol:'star' } }] : []),
]}
layout={{ ...LAYOUT, hovermode:'closest', xaxis:axis('Days to expiry'), yaxis:axis('% per year') }}
config={CFG} style={{ width:'100%', height:300 }} />
</Card>
<Card title={`Smile residuals vs SVI · ${e.expiry} (${e.days.toFixed(0)}d)`}
right={<div style={{ display:'flex', gap:3, flexWrap:'wrap' }}>{d.expiries.map(x =>
<Chip key={x.expiry} active={x.expiry === e.expiry} onClick={() => setSel(x.expiry)}>{x.expiry.slice(5)}</Chip>)}</div>}>
<Plot data={[
{ x:e.residuals.map(r => r.strike), y:e.residuals.map(r => r.residual * 100), type:'scatter', mode:'markers', name:'Mark − SVI',
marker:{ size:e.residuals.map(r => 6 + Math.min(14, Math.sqrt(r.oi) / 3)),
color:e.residuals.map(r => (r.edge_vol > 0 ? (r.residual > 0 ? '#ff3d5a' : '#00ff88') : '#475569')) },
error_y:{ type:'data', array:e.residuals.map(r => (r.half_spread ?? 0) * 100), color:'rgba(148,163,184,0.5)', thickness:1 },
text:e.residuals.map(r => r.instrument), hovertemplate:'%{text}<br>%{y:.2f} vol<extra></extra>' },
]}
layout={{ ...LAYOUT, showlegend:false, xaxis:axis('Strike'), yaxis:axis('Vol points', { zeroline:true, zerolinecolor:'#334155' }),
shapes:[{ type:'line', x0:e.forward, x1:e.forward, yref:'paper', y0:0, y1:1, line:{ color:'rgba(255,255,255,0.3)', dash:'dot' } }] }}
config={CFG} style={{ width:'100%', height:300 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
Bars = ½ bid/ask in vol. Red = rich beyond the spread (sell), green = cheap (buy), grey = inside the spread. Bubble size = open interest.
</div>
</Card>
</div>
<div style={{ marginTop:12 }}>
<Card title="Trade ideas — mispricings net of bid/ask, ranked by USD edge per coin">
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
{['Instrument', 'Side', 'Days', 'Mark IV', 'SVI fit', 'Residual', '½ spread', 'Net edge', 'Edge $', 'Vega $/pt', 'OI'].map(th)}</tr></thead>
<tbody>{d.ideas.slice(0, 15).map(r => (
<tr key={r.instrument} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right' }}>
{td(r.instrument, { textAlign:'left' })}
{td(r.side, { color: r.side.startsWith('sell') ? 'var(--red)' : 'var(--green)' })}
{td(r.days.toFixed(0))}{td(pct(r.mark_iv, 2))}{td(pct(r.fit_iv, 2))}
{td(`${r.residual >= 0 ? '+' : ''}${(r.residual * 100).toFixed(2)}`, { color:'#ffd700' })}
{td(r.half_spread == null ? '—' : (r.half_spread * 100).toFixed(2))}
{td((r.edge_vol * 100).toFixed(2), { color:'var(--cyan)' })}{td(usd(r.edge_usd))}{td(usd(r.vega_usd_per_pt, 1))}{td(px(r.oi))}
</tr>))}
{!d.ideas.length && <tr><td colSpan={11} style={{ padding:8, color:'var(--text-muted)' }}>No residual exceeds its half bid/ask — the smile is fairly priced.</td></tr>}
</tbody>
</table>
</div>
</Card>
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(440px, 1fr))', gap:12, marginTop:12 }}>
<Card title="Expiry ladder — vol carry and forward consistency">
<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', 'ATM IV', 'VRP', 'Fwd basis', 'Fwd − future', 'SVI RMSE'].map(th)}</tr></thead>
<tbody>{d.expiries.map(x => (
<tr key={x.expiry} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right' }}>
{td(x.expiry, { textAlign:'left' })}{td(x.days.toFixed(0))}{td(pct(x.atm_iv))}
{td(x.vrp == null ? '—' : `${(x.vrp * 100).toFixed(1)}`, { color:(x.vrp ?? 0) > 0 ? 'var(--green)' : 'var(--red)' })}
{td(pct(x.forward_basis_annual, 2))}{td(x.fwd_vs_future_bp == null ? '—' : `${x.fwd_vs_future_bp.toFixed(1)} bp`)}
{td(`${(x.svi_rmse * 100).toFixed(2)} vol`)}
</tr>))}</tbody>
</table>
</div>
</Card>
<Card title="Put-call parity & static arbitrage">
<div style={{ overflowX:'auto', maxHeight:260 }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
{['Expiry', 'Strike', 'Mark deviation', 'bp', 'Executable edge'].map(th)}</tr></thead>
<tbody>{d.parity.slice(0, 8).map(p => (
<tr key={`${p.expiry}-${p.strike}`} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right' }}>
{td(p.expiry, { textAlign:'left' })}{td(px(p.strike))}{td(usd(p.mark_dev_usd, 2))}{td(p.mark_dev_bp.toFixed(2))}
{td(p.executable_edge_usd == null ? '—' : usd(p.executable_edge_usd, 2), { color:(p.executable_edge_usd ?? -1) > 0 ? 'var(--red)' : 'var(--text-muted)' })}
</tr>))}</tbody>
</table>
<div style={{ fontSize:10, padding:'6px 8px', color: d.static_arbitrage_executable ? 'var(--red)' : d.static_arbitrage.length ? 'var(--text-muted)' : 'var(--green)' }}>
{d.static_arbitrage.length ? d.static_arbitrage.slice(0, 6).map(a => `${a.executable ? '⚠ EXECUTABLE ' : ''}${a.expiry} ${a.type} ${a.strikes.join('/')} (marks ${usd(a.violation_usd, 2)}${a.executable_usd == null ? '' : `, touch ${usd(a.executable_usd, 2)}`})`).join(' · ')
: 'No vertical-spread or butterfly violations on mark prices.'}
</div>
</div>
</Card>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
{d.source}. Basis: (F/S − 1)·365/days; funding: funding_8h × 3 × 365. Residuals vs a raw-SVI fit per expiry (Gatheral 2004) on OTM mark IVs;
bid/ask coin premiums inverted with Black-76 on the forward. Parity in coin terms C − P = (F − K)/F; executable edge uses ask for the leg bought,
bid for the leg sold, before fees. Static checks: Carr &amp; Madan (2005). Signals are screens, not trade advice — check depth, fees and margin.
</div>
</div>
)
}
+2
View File
@@ -22,6 +22,7 @@ import AiHedger from '../components/AiHedger'
import Positioning from '../components/Positioning'
import SmileDynamics from '../components/SmileDynamics'
import MarginDesk from '../components/MarginDesk'
import RvScanner from '../components/RvScanner'
const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap'))
@@ -56,6 +57,7 @@ const TABS = [
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
{ id:'eventvol', icon:'📆', label:'Event Vol', component: EventVol },
{ id:'positioning', icon:'🧲', label:'Positioning', component: Positioning },
{ id:'rvscan', icon:'🔍', label:'RV Scanner', component: RvScanner },
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
{ id:'risk', icon:'🛡', label:'Risk Desk', component: RiskDesk },
{ id:'margin', icon:'🏦', label:'Margin Desk', component: MarginDesk },
Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB