mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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 & Madan (2005). Signals are screens, not trade advice — check depth, fees and margin.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user