8ecc1a24a9
- /health/ready: ping di tutti i client (exchange, env) cached con timeout 2s, status ready|degraded|not_ready, opt-in 503 via READY_FAILS_ON_DEGRADED. - Middleware mcp.request: 1 riga JSON per HTTP request con request_id, method, path, status_code, duration_ms, actor, bot_tag, exchange, tool, client_ip, user_agent. - request_id propagato in request.state, audit log e error envelope per correlazione cross-cutting. - Aggiunto async health() come probe minimo a bybit/alpaca/macro/ sentiment/deribit (hyperliquid lo aveva già). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1681 lines
62 KiB
Python
1681 lines
62 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from cerbero_mcp.common import indicators as ind
|
|
from cerbero_mcp.common import microstructure as micro
|
|
from cerbero_mcp.common import options as opt
|
|
from cerbero_mcp.common.http import async_client
|
|
|
|
BASE_LIVE = "https://www.deribit.com/api/v2"
|
|
BASE_TESTNET = "https://test.deribit.com/api/v2"
|
|
|
|
RESOLUTION_MAP = {
|
|
"1m": "1",
|
|
"5m": "5",
|
|
"15m": "15",
|
|
"1h": "60",
|
|
"4h": "240",
|
|
"1d": "1D",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class DeribitClient:
|
|
client_id: str
|
|
client_secret: str
|
|
testnet: bool = True
|
|
base_url_override: str | None = None
|
|
_token: str | None = field(default=None, init=False, repr=False)
|
|
_token_expires_at: float = field(default=0.0, init=False, repr=False)
|
|
|
|
@property
|
|
def base_url(self) -> str:
|
|
if self.base_url_override:
|
|
return self.base_url_override
|
|
return BASE_TESTNET if self.testnet else BASE_LIVE
|
|
|
|
# ── Auth ─────────────────────────────────────────────────────
|
|
|
|
async def _authenticate(self) -> str:
|
|
url = f"{self.base_url}/public/auth"
|
|
params = {
|
|
"grant_type": "client_credentials",
|
|
"client_id": self.client_id,
|
|
"client_secret": self.client_secret,
|
|
}
|
|
async with async_client(timeout=15.0) as http:
|
|
resp = await http.get(url, params=params)
|
|
data = resp.json()
|
|
result = data["result"]
|
|
self._token = result["access_token"]
|
|
self._token_expires_at = time.monotonic() + result.get("expires_in", 900) - 30
|
|
return self._token
|
|
|
|
async def _get_token(self) -> str:
|
|
if self._token is None or time.monotonic() >= self._token_expires_at:
|
|
await self._authenticate()
|
|
return self._token # type: ignore[return-value]
|
|
|
|
async def _request(self, method: str, params: dict[str, Any] | None = None) -> dict:
|
|
is_private = method.startswith("private/")
|
|
if is_private:
|
|
await self._get_token()
|
|
|
|
url = f"{self.base_url}/{method}"
|
|
request_params = dict(params) if params else {}
|
|
headers: dict[str, str] = {}
|
|
if is_private and self._token:
|
|
headers["Authorization"] = f"Bearer {self._token}"
|
|
|
|
async with async_client(timeout=15.0) as http:
|
|
resp = await http.get(url, params=request_params, headers=headers)
|
|
data = resp.json()
|
|
|
|
if "result" not in data:
|
|
error = data.get("error", {})
|
|
error_code = error.get("code", 0) if isinstance(error, dict) else 0
|
|
error_msg = error.get("message", str(data)) if isinstance(error, dict) else str(error)
|
|
# Re-authenticate on auth errors and retry once
|
|
if is_private and error_code in (13004, 13009, 13015):
|
|
self._token = None
|
|
await self._authenticate()
|
|
headers["Authorization"] = f"Bearer {self._token}"
|
|
resp = await http.get(url, params=request_params, headers=headers)
|
|
data = resp.json()
|
|
if "result" in data:
|
|
return data # type: ignore[no-any-return]
|
|
return {"result": None, "error": error_msg}
|
|
|
|
return data # type: ignore[no-any-return]
|
|
|
|
# ── Read tools ───────────────────────────────────────────────
|
|
|
|
def is_testnet(self) -> dict:
|
|
return {"testnet": self.testnet, "base_url": self.base_url}
|
|
|
|
async def health(self) -> dict:
|
|
"""Probe minimo per /health/ready: nessuna chiamata di rete."""
|
|
return {"status": "ok", "testnet": self.testnet}
|
|
|
|
async def get_ticker(self, instrument_name: str) -> dict:
|
|
import datetime as _dt
|
|
raw = await self._request("public/ticker", {"instrument_name": instrument_name})
|
|
r = raw.get("result") or {}
|
|
is_perp = instrument_name.upper().endswith("-PERPETUAL")
|
|
greeks = r.get("greeks")
|
|
if is_perp and greeks is None:
|
|
greeks = {"delta": 1.0, "gamma": 0.0, "vega": 0.0, "theta": 0.0, "rho": 0.0}
|
|
return {
|
|
"instrument_name": instrument_name,
|
|
"last_price": r.get("last_price"),
|
|
"mark_price": r.get("mark_price"),
|
|
"bid": r.get("best_bid_price"),
|
|
"ask": r.get("best_ask_price"),
|
|
"volume_24h": r.get("stats", {}).get("volume"),
|
|
"open_interest": r.get("open_interest"),
|
|
"greeks": greeks,
|
|
"mark_iv": r.get("mark_iv"),
|
|
"testnet": self.testnet,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
}
|
|
|
|
async def get_ticker_batch(self, instrument_names: list[str]) -> dict:
|
|
"""Fetch multiple tickers in parallel (max 20)."""
|
|
import asyncio
|
|
import datetime as _dt
|
|
|
|
if not instrument_names:
|
|
return {"tickers": [], "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat()}
|
|
if len(instrument_names) > 20:
|
|
return {"error": "max 20 instruments per batch"}
|
|
|
|
results = await asyncio.gather(
|
|
*[self.get_ticker(n) for n in instrument_names],
|
|
return_exceptions=True,
|
|
)
|
|
tickers = []
|
|
errors = []
|
|
for name, res in zip(instrument_names, results, strict=True):
|
|
if isinstance(res, Exception):
|
|
errors.append({"instrument": name, "error": str(res)})
|
|
else:
|
|
tickers.append(res)
|
|
return {
|
|
"tickers": tickers,
|
|
"errors": errors,
|
|
"count": len(tickers),
|
|
"testnet": self.testnet,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
}
|
|
|
|
async def get_instruments(
|
|
self,
|
|
currency: str,
|
|
kind: str | None = None,
|
|
expired: bool = False,
|
|
expiry_from: str | None = None,
|
|
expiry_to: str | None = None,
|
|
strike_min: float | None = None,
|
|
strike_max: float | None = None,
|
|
min_open_interest: float | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
) -> dict:
|
|
import asyncio
|
|
import datetime as _dt
|
|
|
|
def _parse_iso(s: str | None) -> int | None:
|
|
if not s:
|
|
return None
|
|
try:
|
|
dt = _dt.datetime.fromisoformat(s)
|
|
except ValueError:
|
|
dt = _dt.datetime.strptime(s, "%Y-%m-%d")
|
|
return int(dt.timestamp() * 1000)
|
|
|
|
expiry_from_ms = _parse_iso(expiry_from)
|
|
expiry_to_ms = _parse_iso(expiry_to)
|
|
|
|
params: dict[str, Any] = {"currency": currency, "expired": expired}
|
|
if kind:
|
|
params["kind"] = kind
|
|
|
|
# CER-008: fetch metadata + book_summary in parallelo per popolare OI.
|
|
# `public/get_instruments` non include open_interest; book_summary sì.
|
|
summary_params: dict[str, Any] = {"currency": currency}
|
|
if kind:
|
|
summary_params["kind"] = kind
|
|
instruments_raw, summary_raw = await asyncio.gather(
|
|
self._request("public/get_instruments", params),
|
|
self._request("public/get_book_summary_by_currency", summary_params),
|
|
return_exceptions=True,
|
|
)
|
|
raw = instruments_raw if isinstance(instruments_raw, dict) else {} # type: ignore[has-type]
|
|
summary_items = (
|
|
summary_raw.get("result") if isinstance(summary_raw, dict) else None # type: ignore[has-type]
|
|
) or []
|
|
oi_by_name: dict[str, float] = {}
|
|
for s in summary_items:
|
|
name = s.get("instrument_name")
|
|
oi = s.get("open_interest")
|
|
if name and oi is not None:
|
|
with contextlib.suppress(TypeError, ValueError):
|
|
oi_by_name[name] = float(oi)
|
|
|
|
all_items = raw.get("result") or []
|
|
filtered: list[dict] = []
|
|
for i in all_items:
|
|
exp_ms = i.get("expiration_timestamp")
|
|
strike = i.get("strike")
|
|
name = i.get("instrument_name")
|
|
# CER-008: popola OI dal book_summary se mancante
|
|
oi = i.get("open_interest")
|
|
if oi is None and name in oi_by_name:
|
|
oi = oi_by_name[name]
|
|
i["open_interest"] = oi
|
|
if expiry_from_ms is not None and exp_ms is not None and exp_ms < expiry_from_ms:
|
|
continue
|
|
if expiry_to_ms is not None and exp_ms is not None and exp_ms > expiry_to_ms:
|
|
continue
|
|
if strike_min is not None and strike is not None and strike < strike_min:
|
|
continue
|
|
if strike_max is not None and strike is not None and strike > strike_max:
|
|
continue
|
|
if (
|
|
min_open_interest is not None
|
|
and oi is not None
|
|
and oi < min_open_interest
|
|
):
|
|
continue
|
|
filtered.append(i)
|
|
|
|
total = len(filtered)
|
|
page = filtered[offset : offset + limit]
|
|
instruments = [
|
|
{
|
|
"name": i.get("instrument_name"),
|
|
"strike": i.get("strike"),
|
|
"expiry": i.get("expiration_timestamp"),
|
|
"option_type": i.get("option_type"),
|
|
"tick_size": i.get("tick_size"),
|
|
"min_trade_amount": i.get("min_trade_amount"),
|
|
"open_interest": i.get("open_interest"),
|
|
}
|
|
for i in page
|
|
]
|
|
return {
|
|
"instruments": instruments,
|
|
"total": total,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": offset + limit < total,
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_orderbook(self, instrument_name: str, depth: int = 10) -> dict:
|
|
import datetime as _dt
|
|
raw = await self._request(
|
|
"public/get_order_book", {"instrument_name": instrument_name, "depth": depth}
|
|
)
|
|
r = raw.get("result") or {}
|
|
return {
|
|
"bids": r.get("bids", []),
|
|
"asks": r.get("asks", []),
|
|
"timestamp": r.get("timestamp"),
|
|
"testnet": self.testnet,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
}
|
|
|
|
async def get_orderbook_imbalance(self, instrument_name: str, depth: int = 10) -> dict:
|
|
"""Microstructure: bid/ask imbalance + microprice + slope su top-N livelli."""
|
|
ob = await self.get_orderbook(instrument_name, depth=max(depth, 10))
|
|
result = micro.orderbook_imbalance(ob.get("bids") or [], ob.get("asks") or [], depth=depth)
|
|
return {
|
|
"instrument_name": instrument_name,
|
|
"depth": depth,
|
|
**result,
|
|
"timestamp": ob.get("timestamp"),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_positions(self, currency: str = "USDC") -> list:
|
|
raw = await self._request("private/get_positions", {"currency": currency})
|
|
result = raw.get("result") or []
|
|
positions = []
|
|
for p in result:
|
|
size = p.get("size", 0)
|
|
if size == 0:
|
|
continue
|
|
positions.append({
|
|
"instrument": p.get("instrument_name"),
|
|
"size": abs(size),
|
|
"direction": "long" if size > 0 else "short",
|
|
"avg_price": p.get("average_price"),
|
|
"mark_price": p.get("mark_price"),
|
|
"unrealized_pnl": p.get("floating_profit_loss"),
|
|
"realized_pnl": p.get("realized_profit_loss"),
|
|
"leverage": p.get("leverage"),
|
|
})
|
|
return positions
|
|
|
|
async def get_account_summary(self, currency: str = "USDC") -> dict:
|
|
raw = await self._request("private/get_account_summary", {"currency": currency})
|
|
r = raw.get("result")
|
|
if not r:
|
|
return {
|
|
"equity": 0,
|
|
"balance": 0,
|
|
"margin_balance": 0,
|
|
"available_funds": 0,
|
|
"unrealized_pnl": 0,
|
|
"total_pnl": 0,
|
|
"testnet": self.testnet,
|
|
"error": raw.get("error", "no result"),
|
|
}
|
|
return {
|
|
"equity": r.get("equity", 0),
|
|
"balance": r.get("balance", 0),
|
|
"margin_balance": r.get("margin_balance", 0),
|
|
"available_funds": r.get("available_funds", 0),
|
|
"unrealized_pnl": r.get("unrealized_pnl", 0),
|
|
"total_pnl": r.get("total_pnl", 0),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_trade_history(
|
|
self, limit: int = 100, instrument_name: str | None = None
|
|
) -> list:
|
|
if instrument_name:
|
|
raw = await self._request(
|
|
"private/get_user_trades_by_instrument",
|
|
{"instrument_name": instrument_name, "count": limit},
|
|
)
|
|
else:
|
|
raw = await self._request(
|
|
"private/get_user_trades_by_currency",
|
|
{"currency": "BTC", "count": limit},
|
|
)
|
|
result = raw.get("result") or {}
|
|
if not isinstance(result, dict):
|
|
return []
|
|
trades_raw = result.get("trades") or []
|
|
trades = []
|
|
for t in trades_raw:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
trades.append({
|
|
"instrument": t.get("instrument_name"),
|
|
"direction": t.get("direction"),
|
|
"price": t.get("price"),
|
|
"amount": t.get("amount"),
|
|
"fee": t.get("fee"),
|
|
"timestamp": t.get("timestamp"),
|
|
"order_id": t.get("order_id"),
|
|
})
|
|
return trades
|
|
|
|
async def get_historical(
|
|
self, instrument: str, start_date: str, end_date: str, resolution: str
|
|
) -> dict:
|
|
# Convert ISO date strings to millisecond timestamps
|
|
import datetime as _dt
|
|
|
|
def _to_ms(date_str: str) -> int:
|
|
try:
|
|
dt = _dt.datetime.fromisoformat(date_str)
|
|
except ValueError:
|
|
dt = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
|
return int(dt.timestamp() * 1000)
|
|
|
|
start_ms = _to_ms(start_date)
|
|
end_ms = _to_ms(end_date)
|
|
res = RESOLUTION_MAP.get(resolution, resolution)
|
|
raw = await self._request(
|
|
"public/get_tradingview_chart_data",
|
|
{
|
|
"instrument_name": instrument,
|
|
"start_timestamp": start_ms,
|
|
"end_timestamp": end_ms,
|
|
"resolution": res,
|
|
},
|
|
)
|
|
r = raw.get("result") or {}
|
|
candles = []
|
|
ticks = r.get("ticks", []) or []
|
|
opens = r.get("open", []) or []
|
|
highs = r.get("high", []) or []
|
|
lows = r.get("low", []) or []
|
|
closes = r.get("close", []) or []
|
|
volumes = r.get("volume", []) or []
|
|
for idx, ts in enumerate(ticks):
|
|
if idx >= min(len(opens), len(highs), len(lows), len(closes), len(volumes)):
|
|
break
|
|
candles.append({
|
|
"timestamp": ts,
|
|
"open": opens[idx],
|
|
"high": highs[idx],
|
|
"low": lows[idx],
|
|
"close": closes[idx],
|
|
"volume": volumes[idx],
|
|
})
|
|
return {"candles": candles}
|
|
|
|
async def get_dvol(
|
|
self,
|
|
currency: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
resolution: str = "1D",
|
|
) -> dict:
|
|
import datetime as _dt
|
|
|
|
def _to_ms(date_str: str) -> int:
|
|
try:
|
|
dt = _dt.datetime.fromisoformat(date_str)
|
|
except ValueError:
|
|
dt = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
|
return int(dt.timestamp() * 1000)
|
|
|
|
start_ms = _to_ms(start_date)
|
|
end_ms = _to_ms(end_date)
|
|
res = RESOLUTION_MAP.get(resolution, resolution)
|
|
raw = await self._request(
|
|
"public/get_volatility_index_data",
|
|
{
|
|
"currency": currency.upper(),
|
|
"start_timestamp": start_ms,
|
|
"end_timestamp": end_ms,
|
|
"resolution": res,
|
|
},
|
|
)
|
|
r = raw.get("result") or {}
|
|
rows = r.get("data") or []
|
|
candles = [
|
|
{
|
|
"timestamp": row[0],
|
|
"open": row[1],
|
|
"high": row[2],
|
|
"low": row[3],
|
|
"close": row[4],
|
|
}
|
|
for row in rows
|
|
if len(row) >= 5
|
|
]
|
|
latest = candles[-1]["close"] if candles else None
|
|
return {"currency": currency.upper(), "latest": latest, "candles": candles}
|
|
|
|
async def get_gex(
|
|
self,
|
|
currency: str,
|
|
expiry_from: str | None = None,
|
|
expiry_to: str | None = None,
|
|
top_n_strikes: int = 50,
|
|
) -> dict:
|
|
import asyncio
|
|
import datetime as _dt
|
|
|
|
currency = currency.upper()
|
|
try:
|
|
idx_tk = await self.get_ticker(f"{currency}-PERPETUAL")
|
|
spot = float(idx_tk.get("mark_price") or 0)
|
|
except Exception:
|
|
spot = 0.0
|
|
|
|
chain = await self.get_instruments(
|
|
currency=currency,
|
|
kind="option",
|
|
expiry_from=expiry_from,
|
|
expiry_to=expiry_to,
|
|
limit=2000,
|
|
)
|
|
items = chain.get("instruments", [])
|
|
items.sort(key=lambda x: -(x.get("open_interest") or 0))
|
|
top = items[:top_n_strikes]
|
|
|
|
async def _ticker(name: str) -> dict:
|
|
try:
|
|
return await self.get_ticker(name)
|
|
except Exception:
|
|
return {}
|
|
|
|
tickers = await asyncio.gather(*[_ticker(i["name"]) for i in top])
|
|
|
|
by_strike: dict[float, dict[str, float]] = {}
|
|
for meta, tk in zip(top, tickers, strict=True):
|
|
strike = meta.get("strike")
|
|
if strike is None:
|
|
continue
|
|
greeks = tk.get("greeks") or {}
|
|
gamma = greeks.get("gamma")
|
|
oi = meta.get("open_interest") or 0
|
|
if gamma is None or spot <= 0:
|
|
continue
|
|
gex_contribution = float(gamma) * oi * (spot ** 2) * 0.01
|
|
entry = by_strike.setdefault(
|
|
float(strike), {"strike": float(strike), "call_gex": 0.0, "put_gex": 0.0}
|
|
)
|
|
if meta.get("option_type") == "call":
|
|
entry["call_gex"] += gex_contribution
|
|
else:
|
|
entry["put_gex"] -= gex_contribution
|
|
|
|
rows = []
|
|
for s in sorted(by_strike.keys()):
|
|
e = by_strike[s]
|
|
e["net_gex"] = e["call_gex"] + e["put_gex"]
|
|
rows.append(e)
|
|
|
|
total_gex = sum(r["net_gex"] for r in rows)
|
|
zero_gamma = None
|
|
for a, b in zip(rows, rows[1:], strict=False):
|
|
if (a["net_gex"] < 0 <= b["net_gex"]) or (a["net_gex"] > 0 >= b["net_gex"]):
|
|
denom = b["net_gex"] - a["net_gex"]
|
|
if denom != 0:
|
|
frac = -a["net_gex"] / denom
|
|
zero_gamma = round(a["strike"] + frac * (b["strike"] - a["strike"]), 2)
|
|
break
|
|
|
|
max_gex_level = max(rows, key=lambda r: r["net_gex"])["strike"] if rows else None
|
|
min_gex_level = min(rows, key=lambda r: r["net_gex"])["strike"] if rows else None
|
|
|
|
return {
|
|
"currency": currency,
|
|
"expiry_from": expiry_from,
|
|
"expiry_to": expiry_to,
|
|
"spot_price": spot,
|
|
"total_gex_usd": round(total_gex, 2),
|
|
"zero_gamma_level": zero_gamma,
|
|
"gex_by_strike": [
|
|
{
|
|
"strike": r["strike"],
|
|
"call_gex": round(r["call_gex"], 2),
|
|
"put_gex": round(r["put_gex"], 2),
|
|
"net_gex": round(r["net_gex"], 2),
|
|
}
|
|
for r in rows
|
|
],
|
|
"max_gex_level": max_gex_level,
|
|
"min_gex_level": min_gex_level,
|
|
"strikes_analyzed": len(rows),
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def _fetch_chain_legs(
|
|
self,
|
|
currency: str,
|
|
expiry_from: str | None = None,
|
|
expiry_to: str | None = None,
|
|
top_n_strikes: int = 50,
|
|
) -> tuple[float, list[dict[str, Any]]]:
|
|
"""Fetch chain options + ticker per top-N strikes per OI; restituisce
|
|
(spot, legs[]) con campi normalizzati per le funzioni in cerbero_mcp.common.options.
|
|
"""
|
|
import asyncio
|
|
|
|
currency = currency.upper()
|
|
try:
|
|
idx_tk = await self.get_ticker(f"{currency}-PERPETUAL")
|
|
spot = float(idx_tk.get("mark_price") or 0)
|
|
except Exception:
|
|
spot = 0.0
|
|
|
|
chain = await self.get_instruments(
|
|
currency=currency,
|
|
kind="option",
|
|
expiry_from=expiry_from,
|
|
expiry_to=expiry_to,
|
|
limit=2000,
|
|
)
|
|
items = chain.get("instruments", [])
|
|
items.sort(key=lambda x: -(x.get("open_interest") or 0))
|
|
top = items[:top_n_strikes]
|
|
|
|
async def _ticker(name: str) -> dict:
|
|
try:
|
|
return await self.get_ticker(name)
|
|
except Exception:
|
|
return {}
|
|
|
|
tickers = await asyncio.gather(*[_ticker(i["name"]) for i in top])
|
|
legs: list[dict[str, Any]] = []
|
|
for meta, tk in zip(top, tickers, strict=True):
|
|
greeks = tk.get("greeks") or {}
|
|
legs.append({
|
|
"strike": meta.get("strike"),
|
|
"option_type": meta.get("option_type"),
|
|
"oi": meta.get("open_interest") or 0,
|
|
"iv": tk.get("mark_iv"),
|
|
"delta": greeks.get("delta"),
|
|
"gamma": greeks.get("gamma"),
|
|
"vanna": greeks.get("vanna"),
|
|
"charm": greeks.get("charm"),
|
|
"vega": greeks.get("vega"),
|
|
})
|
|
return spot, legs
|
|
|
|
async def get_dealer_gamma_profile(
|
|
self,
|
|
currency: str,
|
|
expiry_from: str | None = None,
|
|
expiry_to: str | None = None,
|
|
top_n_strikes: int = 50,
|
|
) -> dict:
|
|
"""Net dealer gamma per strike (assume dealer short calls/long puts).
|
|
Identifica il gamma flip level: sopra → mercato pinning, sotto → squeeze.
|
|
"""
|
|
import datetime as _dt
|
|
spot, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes)
|
|
result = opt.dealer_gamma_profile(legs, spot)
|
|
return {
|
|
"currency": currency.upper(),
|
|
"spot_price": spot,
|
|
**result,
|
|
"strikes_analyzed": len(result["by_strike"]),
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_vanna_charm(
|
|
self,
|
|
currency: str,
|
|
expiry_from: str | None = None,
|
|
expiry_to: str | None = None,
|
|
top_n_strikes: int = 50,
|
|
) -> dict:
|
|
"""Vanna (∂delta/∂IV) e Charm (∂delta/∂t) aggregati pesati per OI.
|
|
Vanna positiva: dealer compra spot quando IV sale.
|
|
Charm negativa: time decay erode delta hedging.
|
|
"""
|
|
import datetime as _dt
|
|
spot, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes)
|
|
result = opt.vanna_charm_aggregate(legs, spot)
|
|
return {
|
|
"currency": currency.upper(),
|
|
**result,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_oi_weighted_skew(
|
|
self,
|
|
currency: str,
|
|
expiry_from: str | None = None,
|
|
expiry_to: str | None = None,
|
|
top_n_strikes: int = 100,
|
|
) -> dict:
|
|
"""Skew aggregato pesato OI: IV media puts - calls. Positivo = paura.
|
|
"""
|
|
import datetime as _dt
|
|
_, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes)
|
|
result = opt.oi_weighted_skew(legs)
|
|
return {
|
|
"currency": currency.upper(),
|
|
**result,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_smile_asymmetry(
|
|
self,
|
|
currency: str,
|
|
expiry_from: str | None = None,
|
|
expiry_to: str | None = None,
|
|
top_n_strikes: int = 100,
|
|
) -> dict:
|
|
"""Asymmetry IV otm-puts vs otm-calls. Positivo = put-side richer."""
|
|
import datetime as _dt
|
|
spot, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes)
|
|
result = opt.smile_asymmetry(legs, spot)
|
|
return {
|
|
"currency": currency.upper(),
|
|
"spot_price": spot,
|
|
**result,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_atm_vs_wings_vol(
|
|
self,
|
|
currency: str,
|
|
expiry_from: str | None = None,
|
|
expiry_to: str | None = None,
|
|
top_n_strikes: int = 100,
|
|
) -> dict:
|
|
"""IV ATM vs IV alle ali 25-delta. wing_richness > 0 → smile (kurtosis)."""
|
|
import datetime as _dt
|
|
spot, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes)
|
|
result = opt.atm_vs_wings_vol(legs, spot)
|
|
return {
|
|
"currency": currency.upper(),
|
|
"spot_price": spot,
|
|
**result,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_pc_ratio(self, currency: str) -> dict:
|
|
import datetime as _dt
|
|
|
|
currency = currency.upper()
|
|
raw = await self._request(
|
|
"public/get_book_summary_by_currency",
|
|
{"currency": currency, "kind": "option"},
|
|
)
|
|
rows = raw.get("result") or []
|
|
call_oi = 0.0
|
|
put_oi = 0.0
|
|
call_vol = 0.0
|
|
put_vol = 0.0
|
|
for r in rows:
|
|
name = r.get("instrument_name", "")
|
|
oi = r.get("open_interest") or 0
|
|
vol = r.get("volume") or 0
|
|
if name.endswith("-C"):
|
|
call_oi += oi
|
|
call_vol += vol
|
|
elif name.endswith("-P"):
|
|
put_oi += oi
|
|
put_vol += vol
|
|
|
|
def _ratio(put: float, call: float) -> float | None:
|
|
return round(put / call, 4) if call > 0 else None
|
|
|
|
def _interp(ratio: float | None) -> str:
|
|
if ratio is None:
|
|
return "insufficient_data"
|
|
if ratio > 1.0:
|
|
return "puts_dominant"
|
|
if ratio < 0.7:
|
|
return "calls_dominant"
|
|
return "balanced"
|
|
|
|
pc_oi = _ratio(put_oi, call_oi)
|
|
pc_vol = _ratio(put_vol, call_vol)
|
|
|
|
return {
|
|
"currency": currency,
|
|
"pc_ratio_oi": pc_oi,
|
|
"pc_ratio_volume_24h": pc_vol,
|
|
"total_call_oi": call_oi,
|
|
"total_put_oi": put_oi,
|
|
"total_call_volume_24h": call_vol,
|
|
"total_put_volume_24h": put_vol,
|
|
"interpretation": {
|
|
"oi": _interp(pc_oi),
|
|
"volume_24h": _interp(pc_vol),
|
|
"percentile_90d": None,
|
|
},
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_skew_25d(self, currency: str, expiry: str) -> dict:
|
|
import asyncio
|
|
import datetime as _dt
|
|
|
|
currency = currency.upper()
|
|
put_task = self.find_by_delta(currency, expiry, -0.25, "put", 1, 0, 0)
|
|
call_task = self.find_by_delta(currency, expiry, 0.25, "call", 1, 0, 0)
|
|
try:
|
|
idx_tk = await self.get_ticker(f"{currency}-PERPETUAL")
|
|
spot = float(idx_tk.get("mark_price") or 0)
|
|
except Exception:
|
|
spot = 0.0
|
|
put_res, call_res = await asyncio.gather(put_task, call_task)
|
|
|
|
put_best = put_res.get("best_match") or {}
|
|
call_best = call_res.get("best_match") or {}
|
|
put_iv = put_best.get("mark_iv")
|
|
call_iv = call_best.get("mark_iv")
|
|
|
|
# ATM IV: find instrument closest to spot on this expiry
|
|
exp_dt = _dt.datetime.fromisoformat(expiry) if "T" in expiry or "-" in expiry else _dt.datetime.strptime(expiry, "%Y-%m-%d")
|
|
chain = await self.get_instruments(
|
|
currency=currency,
|
|
kind="option",
|
|
expiry_from=expiry,
|
|
expiry_to=(exp_dt + _dt.timedelta(days=1)).strftime("%Y-%m-%d"),
|
|
limit=500,
|
|
)
|
|
items = chain.get("instruments", [])
|
|
atm_iv = None
|
|
if items and spot > 0:
|
|
call_items = [i for i in items if i.get("option_type") == "call"]
|
|
if call_items:
|
|
call_items.sort(key=lambda x: abs((x.get("strike") or 0) - spot))
|
|
atm_tk = await self.get_ticker(call_items[0]["name"])
|
|
atm_iv = atm_tk.get("mark_iv")
|
|
|
|
skew = None
|
|
if put_iv is not None and call_iv is not None:
|
|
skew = round(put_iv - call_iv, 4)
|
|
|
|
if skew is None:
|
|
skew_sign = "unknown"
|
|
elif skew > 1:
|
|
skew_sign = "puts_rich"
|
|
elif skew < -1:
|
|
skew_sign = "calls_rich"
|
|
else:
|
|
skew_sign = "neutral"
|
|
|
|
butterfly = None
|
|
if put_iv is not None and call_iv is not None and atm_iv is not None:
|
|
butterfly = round((put_iv + call_iv) / 2 - atm_iv, 4)
|
|
|
|
return {
|
|
"currency": currency,
|
|
"expiry": expiry,
|
|
"put_25d_iv": put_iv,
|
|
"call_25d_iv": call_iv,
|
|
"atm_iv": atm_iv,
|
|
"skew_25d": skew,
|
|
"skew_sign": skew_sign,
|
|
"risk_reversal_25d": round(-skew, 4) if skew is not None else None,
|
|
"butterfly_25d": butterfly,
|
|
"skew_percentile_90d": None,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_term_structure(self, currency: str) -> dict:
|
|
import asyncio
|
|
import datetime as _dt
|
|
|
|
currency = currency.upper()
|
|
try:
|
|
idx_tk = await self.get_ticker(f"{currency}-PERPETUAL")
|
|
spot = float(idx_tk.get("mark_price") or 0)
|
|
except Exception:
|
|
spot = 0.0
|
|
|
|
chain = await self.get_instruments(
|
|
currency=currency, kind="option", limit=2000, offset=0
|
|
)
|
|
items = chain.get("instruments", [])
|
|
now_ms = int(_dt.datetime.now(_dt.UTC).timestamp() * 1000)
|
|
by_exp: dict[int, list[dict]] = {}
|
|
for i in items:
|
|
exp = i.get("expiry")
|
|
if exp is None or exp < now_ms:
|
|
continue
|
|
by_exp.setdefault(int(exp), []).append(i)
|
|
|
|
atm_names: list[tuple[int, str]] = []
|
|
for exp_ms, ins in sorted(by_exp.items()):
|
|
if spot > 0:
|
|
ins.sort(key=lambda x: abs((x.get("strike") or 0) - spot))
|
|
atm_names.append((exp_ms, ins[0]["name"]))
|
|
|
|
async def _ticker(name: str) -> dict:
|
|
try:
|
|
return await self.get_ticker(name)
|
|
except Exception:
|
|
return {}
|
|
|
|
tickers = await asyncio.gather(*[_ticker(n) for _, n in atm_names])
|
|
|
|
ts = []
|
|
for (exp_ms, name), tk in zip(atm_names, tickers, strict=True):
|
|
iv = tk.get("mark_iv")
|
|
if iv is None:
|
|
continue
|
|
exp_dt = _dt.datetime.fromtimestamp(exp_ms / 1000, _dt.UTC)
|
|
dte = max(0, (exp_dt - _dt.datetime.now(_dt.UTC)).days)
|
|
ts.append({
|
|
"expiry": exp_dt.strftime("%Y-%m-%d"),
|
|
"dte": dte,
|
|
"atm_iv": iv,
|
|
"atm_instrument": name,
|
|
})
|
|
|
|
shape = "flat"
|
|
contango_steep = False
|
|
calendar_opp = False
|
|
if len(ts) >= 2:
|
|
diffs = [ts[i + 1]["atm_iv"] - ts[i]["atm_iv"] for i in range(len(ts) - 1)]
|
|
up = sum(1 for d in diffs if d > 0)
|
|
down = sum(1 for d in diffs if d < 0)
|
|
if up > len(diffs) / 2:
|
|
shape = "contango"
|
|
elif down > len(diffs) / 2:
|
|
shape = "backwardation"
|
|
short_term = next((x for x in ts if 8 <= x["dte"] <= 14), None)
|
|
mid_term = next((x for x in ts if 35 <= x["dte"] <= 45), None)
|
|
if short_term and mid_term and mid_term["atm_iv"] - short_term["atm_iv"] > 5:
|
|
contango_steep = True
|
|
calendar_opp = True
|
|
|
|
return {
|
|
"currency": currency,
|
|
"spot": spot,
|
|
"term_structure": ts,
|
|
"shape": shape,
|
|
"contango_steep": contango_steep,
|
|
"calendar_spread_opportunity": calendar_opp,
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def run_backtest(
|
|
self,
|
|
strategy_name: str,
|
|
underlying: str = "BTC",
|
|
lookback_days: int = 30,
|
|
resolution: str = "4h",
|
|
entry_rules: dict | None = None,
|
|
exit_rules: dict | None = None,
|
|
) -> dict:
|
|
"""Heuristic backtest: OHLCV lookback + simple rule-based entry/exit.
|
|
Approximate; does not simulate full options chain.
|
|
"""
|
|
import datetime as _dt
|
|
|
|
end = _dt.datetime.now(_dt.UTC)
|
|
start = end - _dt.timedelta(days=lookback_days)
|
|
historical = await self.get_historical(
|
|
f"{underlying.upper()}-PERPETUAL",
|
|
start.strftime("%Y-%m-%d"),
|
|
end.strftime("%Y-%m-%d"),
|
|
resolution,
|
|
)
|
|
candles = historical.get("candles", [])
|
|
if len(candles) < 20:
|
|
return {
|
|
"strategy_name": strategy_name,
|
|
"error": "insufficient history",
|
|
"trades_simulated": 0,
|
|
"recommendation": "reject",
|
|
}
|
|
|
|
closes = [c["close"] for c in candles]
|
|
from cerbero_mcp.common import indicators as _ind
|
|
|
|
rsi_thr_low = (entry_rules or {}).get("rsi_below", 35)
|
|
rsi_thr_high = (entry_rules or {}).get("rsi_above", 65)
|
|
target_pct = (exit_rules or {}).get("target_pct", 2.0)
|
|
stop_pct = (exit_rules or {}).get("stop_pct", -1.5)
|
|
max_hold_bars = (exit_rules or {}).get("max_hold_bars", 20)
|
|
|
|
trades: list[dict] = []
|
|
i = 14
|
|
while i < len(closes) - 1:
|
|
sub = closes[: i + 1]
|
|
rsi_val = _ind.rsi(sub)
|
|
if rsi_val is None:
|
|
i += 1
|
|
continue
|
|
side: str | None = None
|
|
if rsi_val < rsi_thr_low:
|
|
side = "long"
|
|
elif rsi_val > rsi_thr_high:
|
|
side = "short"
|
|
if side is None:
|
|
i += 1
|
|
continue
|
|
entry = closes[i]
|
|
exit_bar = None
|
|
exit_price = entry
|
|
for j in range(1, min(max_hold_bars, len(closes) - i)):
|
|
p = closes[i + j]
|
|
pct = (p - entry) / entry * 100 * (1 if side == "long" else -1)
|
|
if pct >= target_pct or pct <= stop_pct:
|
|
exit_bar = j
|
|
exit_price = p
|
|
break
|
|
if exit_bar is None:
|
|
exit_bar = min(max_hold_bars, len(closes) - i - 1)
|
|
exit_price = closes[i + exit_bar]
|
|
pnl_pct = (exit_price - entry) / entry * 100 * (1 if side == "long" else -1)
|
|
trades.append({
|
|
"entry_idx": i,
|
|
"side": side,
|
|
"entry": entry,
|
|
"exit": exit_price,
|
|
"bars_held": exit_bar,
|
|
"pnl_pct": pnl_pct,
|
|
})
|
|
i += exit_bar + 1
|
|
|
|
if not trades:
|
|
return {
|
|
"strategy_name": strategy_name,
|
|
"trades_simulated": 0,
|
|
"recommendation": "reject",
|
|
"notes": "no entries triggered",
|
|
}
|
|
|
|
wins = [t for t in trades if t["pnl_pct"] > 0]
|
|
losses = [t for t in trades if t["pnl_pct"] <= 0]
|
|
win_rate = len(wins) / len(trades)
|
|
total_profit = sum(t["pnl_pct"] for t in wins)
|
|
total_loss = -sum(t["pnl_pct"] for t in losses)
|
|
profit_factor = (total_profit / total_loss) if total_loss > 0 else float("inf")
|
|
equity = 0.0
|
|
peak = 0.0
|
|
max_dd = 0.0
|
|
for t in trades:
|
|
equity += t["pnl_pct"]
|
|
peak = max(peak, equity)
|
|
dd = peak - equity
|
|
max_dd = max(max_dd, dd)
|
|
mean_r = sum(t["pnl_pct"] for t in trades) / len(trades)
|
|
var_r = sum((t["pnl_pct"] - mean_r) ** 2 for t in trades) / len(trades)
|
|
std_r = var_r ** 0.5
|
|
sharpe = round(mean_r / std_r, 2) if std_r > 0 else None
|
|
|
|
recommendation = "reject"
|
|
if win_rate > 0.55 and max_dd < 4.0 and profit_factor > 1.5:
|
|
recommendation = "accept"
|
|
elif win_rate > 0.50 and max_dd < 5.0 and profit_factor > 1.35:
|
|
recommendation = "marginal"
|
|
|
|
return {
|
|
"strategy_name": strategy_name,
|
|
"underlying": underlying.upper(),
|
|
"lookback_days": lookback_days,
|
|
"resolution": resolution,
|
|
"trades_simulated": len(trades),
|
|
"win_rate": round(win_rate, 3),
|
|
"max_drawdown_pct": round(max_dd, 2),
|
|
"profit_factor": round(profit_factor, 2) if profit_factor != float("inf") else None,
|
|
"sharpe": sharpe,
|
|
"recommendation": recommendation,
|
|
"notes": "heuristic RSI-based backtest — approximate, not full options simulation",
|
|
"testnet": self.testnet,
|
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
}
|
|
|
|
async def calculate_spread_payoff(
|
|
self,
|
|
legs: list[dict],
|
|
quote_currency: str = "USD",
|
|
) -> dict:
|
|
import asyncio
|
|
import re as _re
|
|
|
|
if not legs or len(legs) > 4:
|
|
return {"error": "legs must be 1..4"}
|
|
|
|
async def _fetch(name: str) -> dict:
|
|
try:
|
|
return await self.get_ticker(name)
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
tickers = await asyncio.gather(*[_fetch(l["instrument_name"]) for l in legs])
|
|
|
|
enriched: list[dict] = []
|
|
spot_est: float | None = None
|
|
for leg, tk in zip(legs, tickers, strict=True):
|
|
name = leg["instrument_name"]
|
|
match = _re.match(
|
|
r"^([A-Z]+)-(\d{1,2}[A-Z]{3}\d{2})-(\d+(?:\.\d+)?)-(C|P)$", name
|
|
)
|
|
if not match:
|
|
return {"error": f"invalid option name: {name}"}
|
|
coin, exp_str, strike_s, opt_type = match.groups()
|
|
strike = float(strike_s)
|
|
action = leg.get("action", "long").lower()
|
|
qty = float(leg.get("quantity", 1))
|
|
sign = 1 if action == "long" else -1
|
|
mark_price = tk.get("mark_price") or 0.0
|
|
greeks = tk.get("greeks") or {}
|
|
if spot_est is None and mark_price:
|
|
spot_est = float(strike)
|
|
enriched.append({
|
|
"name": name,
|
|
"coin": coin,
|
|
"expiry": exp_str,
|
|
"strike": strike,
|
|
"type": opt_type,
|
|
"action": action,
|
|
"quantity": qty,
|
|
"sign": sign,
|
|
"mark_price": float(mark_price),
|
|
"greeks": greeks,
|
|
})
|
|
|
|
strikes = [l["strike"] for l in enriched]
|
|
spot = spot_est or (sum(strikes) / len(strikes)) if strikes else 0.0
|
|
if enriched and enriched[0]["coin"]:
|
|
try:
|
|
idx_name = f"{enriched[0]['coin']}-PERPETUAL"
|
|
idx_tk = await self.get_ticker(idx_name)
|
|
if idx_tk.get("mark_price"):
|
|
spot = float(idx_tk["mark_price"])
|
|
except Exception:
|
|
pass
|
|
|
|
net_premium = sum(l["sign"] * l["quantity"] * l["mark_price"] for l in enriched)
|
|
credit = net_premium < 0
|
|
|
|
greeks_net = {k: 0.0 for k in ("delta", "gamma", "vega", "theta", "rho")}
|
|
for l in enriched:
|
|
for k in greeks_net:
|
|
v = l["greeks"].get(k)
|
|
if v is not None:
|
|
greeks_net[k] += l["sign"] * l["quantity"] * float(v)
|
|
|
|
def _payoff(s: float) -> float:
|
|
total = 0.0
|
|
for l in enriched:
|
|
k = l["strike"]
|
|
intrinsic = max(0.0, s - k) if l["type"] == "C" else max(0.0, k - s)
|
|
total += l["sign"] * l["quantity"] * (intrinsic - l["mark_price"])
|
|
return total
|
|
|
|
lo = max(spot * 0.5, min(strikes) * 0.7) if strikes else spot * 0.5
|
|
hi = max(spot * 1.5, max(strikes) * 1.3) if strikes else spot * 1.5
|
|
steps = 14
|
|
points = []
|
|
for i in range(steps + 1):
|
|
s = lo + (hi - lo) * (i / steps)
|
|
points.append({"spot": round(s, 2), "pnl": round(_payoff(s), 4)})
|
|
key_points = sorted({int(lo), int(spot), int(hi), *(int(k) for k in strikes)})
|
|
for s in key_points:
|
|
points.append({"spot": float(s), "pnl": round(_payoff(float(s)), 4)})
|
|
points.sort(key=lambda p: p["spot"])
|
|
|
|
pnls = [p["pnl"] for p in points]
|
|
max_profit = max(pnls) if pnls else 0.0
|
|
max_loss = min(pnls) if pnls else 0.0
|
|
|
|
break_evens: list[float] = []
|
|
for a, b in zip(points, points[1:], strict=False):
|
|
if a["pnl"] == 0:
|
|
break_evens.append(a["spot"])
|
|
elif (a["pnl"] < 0 < b["pnl"]) or (a["pnl"] > 0 > b["pnl"]):
|
|
frac = -a["pnl"] / (b["pnl"] - a["pnl"])
|
|
break_evens.append(round(a["spot"] + frac * (b["spot"] - a["spot"]), 2))
|
|
|
|
structure = self._guess_structure(enriched)
|
|
|
|
sum(l["quantity"] * spot for l in enriched) if spot else 0.0
|
|
fee_per_leg = min(0.0003 * (spot or 1) * sum(l["quantity"] for l in enriched),
|
|
0.125 * abs(net_premium)) if spot else 0.0
|
|
fees_open = round(fee_per_leg, 4)
|
|
fees_total = round(fees_open * 2, 4)
|
|
|
|
warnings = []
|
|
for l in enriched:
|
|
if l["action"] == "short":
|
|
any_long_same_type = any(
|
|
o["type"] == l["type"] and o["action"] == "long" and o["coin"] == l["coin"]
|
|
for o in enriched
|
|
)
|
|
if not any_long_same_type:
|
|
warnings.append(
|
|
f"naked short {l['type']} {l['name']} — viola hard prohibition"
|
|
)
|
|
|
|
pl_ratio = (
|
|
round(abs(max_profit / max_loss), 2)
|
|
if max_loss and max_loss != 0 else None
|
|
)
|
|
|
|
return {
|
|
"structure_name_guess": structure,
|
|
"net_premium": round(abs(net_premium), 4),
|
|
"net_premium_sign": "credit" if credit else "debit",
|
|
"max_profit": round(max_profit, 4),
|
|
"max_loss": round(max_loss, 4),
|
|
"break_even": break_evens,
|
|
"profit_loss_ratio": pl_ratio,
|
|
"greeks_net": {k: round(v, 6) for k, v in greeks_net.items()},
|
|
"fees_estimate": {
|
|
"open": fees_open,
|
|
"close_estimate": fees_open,
|
|
"total": fees_total,
|
|
"cap_hit": fees_total >= 0.125 * abs(net_premium) if net_premium else False,
|
|
},
|
|
"payoff_table": points,
|
|
"spot_assumed": round(spot, 2),
|
|
"warnings": warnings,
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
@staticmethod
|
|
def _guess_structure(legs: list[dict]) -> str:
|
|
n = len(legs)
|
|
if n == 1:
|
|
l = legs[0]
|
|
return f"long {l['type']}" if l["action"] == "long" else f"short {l['type']}"
|
|
if n == 2:
|
|
types = {l["type"] for l in legs}
|
|
actions = {l["action"] for l in legs}
|
|
strikes = sorted(set(l["strike"] for l in legs))
|
|
if types == {"P"} and actions == {"long", "short"}:
|
|
short = next(l for l in legs if l["action"] == "short")
|
|
long_ = next(l for l in legs if l["action"] == "long")
|
|
return "bull put spread" if short["strike"] > long_["strike"] else "bear put spread"
|
|
if types == {"C"} and actions == {"long", "short"}:
|
|
short = next(l for l in legs if l["action"] == "short")
|
|
long_ = next(l for l in legs if l["action"] == "long")
|
|
return "bear call spread" if short["strike"] < long_["strike"] else "bull call spread"
|
|
if types == {"C", "P"} and len(strikes) == 1 and len(actions) == 1:
|
|
return f"{list(actions)[0]} straddle"
|
|
if types == {"C", "P"} and len(strikes) == 2 and len(actions) == 1:
|
|
return f"{list(actions)[0]} strangle"
|
|
if len({l["expiry"] for l in legs}) == 2 and len(strikes) == 1:
|
|
return "calendar spread"
|
|
if n == 4:
|
|
types_list = [l["type"] for l in legs]
|
|
if types_list.count("P") == 2 and types_list.count("C") == 2:
|
|
return "iron condor"
|
|
return "custom"
|
|
|
|
async def find_by_delta(
|
|
self,
|
|
currency: str,
|
|
expiry: str,
|
|
target_delta: float,
|
|
option_type: str,
|
|
max_results: int = 3,
|
|
min_open_interest: float = 100.0,
|
|
min_volume_24h: float = 20.0,
|
|
) -> dict:
|
|
import asyncio
|
|
import datetime as _dt
|
|
|
|
currency = currency.upper()
|
|
option_type = option_type.lower()
|
|
try:
|
|
exp_dt = _dt.datetime.fromisoformat(expiry)
|
|
except ValueError:
|
|
exp_dt = _dt.datetime.strptime(expiry, "%Y-%m-%d")
|
|
exp_ms = int(exp_dt.replace(tzinfo=_dt.UTC).timestamp() * 1000)
|
|
day_ms = 24 * 3600 * 1000
|
|
|
|
chain = await self.get_instruments(
|
|
currency=currency,
|
|
kind="option",
|
|
expiry_from=expiry,
|
|
expiry_to=(exp_dt + _dt.timedelta(days=1)).strftime("%Y-%m-%d"),
|
|
limit=500,
|
|
offset=0,
|
|
)
|
|
items = [
|
|
i for i in chain.get("instruments", [])
|
|
if i.get("option_type") == option_type
|
|
and i.get("expiry") is not None
|
|
and abs(i["expiry"] - exp_ms) < day_ms
|
|
]
|
|
if not items:
|
|
return {"matches": [], "best_match": None, "note": "no instruments for expiry"}
|
|
|
|
async def _ticker(inst: str) -> dict:
|
|
try:
|
|
return await self.get_ticker(inst)
|
|
except Exception:
|
|
return {}
|
|
|
|
tickers = await asyncio.gather(*[_ticker(i["name"]) for i in items])
|
|
|
|
candidates = []
|
|
for meta, tk in zip(items, tickers, strict=True):
|
|
greeks = tk.get("greeks") or {}
|
|
delta = greeks.get("delta")
|
|
oi = meta.get("open_interest") or 0
|
|
vol = tk.get("volume_24h") or 0
|
|
bid = tk.get("bid") or 0
|
|
ask = tk.get("ask") or 0
|
|
if delta is None:
|
|
continue
|
|
if oi < min_open_interest or vol < min_volume_24h:
|
|
continue
|
|
mid = (bid + ask) / 2 if (bid and ask) else None
|
|
spread_pct = round(100 * (ask - bid) / mid, 2) if mid and mid > 0 else None
|
|
candidates.append({
|
|
"instrument_name": meta["name"],
|
|
"strike": meta["strike"],
|
|
"delta": delta,
|
|
"delta_distance": abs(delta - target_delta),
|
|
"mark_iv": tk.get("mark_iv"),
|
|
"mark_price_usd": tk.get("mark_price"),
|
|
"bid_ask_spread_pct": spread_pct,
|
|
"open_interest": oi,
|
|
"volume_24h": vol,
|
|
"greeks": greeks,
|
|
})
|
|
candidates.sort(key=lambda x: x["delta_distance"])
|
|
top = candidates[:max_results]
|
|
return {
|
|
"currency": currency,
|
|
"expiry": expiry,
|
|
"target_delta": target_delta,
|
|
"option_type": option_type,
|
|
"matches": top,
|
|
"best_match": top[0] if top else None,
|
|
"candidates_considered": len(candidates),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_iv_rank(self, instrument: str) -> dict:
|
|
currency = instrument.split("-", 1)[0].upper()
|
|
if currency not in ("BTC", "ETH"):
|
|
return {
|
|
"instrument": instrument,
|
|
"error": f"currency {currency} not supported for IV rank",
|
|
}
|
|
|
|
ticker_raw = await self._request(
|
|
"public/ticker", {"instrument_name": instrument}
|
|
)
|
|
tr = ticker_raw.get("result") or {}
|
|
current_iv = tr.get("mark_iv")
|
|
|
|
import datetime as _dt
|
|
|
|
now = _dt.datetime.now(_dt.UTC)
|
|
series_by_lookback: dict[int, list[float]] = {}
|
|
for lb in (30, 60, 90, 365):
|
|
start = now - _dt.timedelta(days=lb)
|
|
raw = await self._request(
|
|
"public/get_volatility_index_data",
|
|
{
|
|
"currency": currency,
|
|
"start_timestamp": int(start.timestamp() * 1000),
|
|
"end_timestamp": int(now.timestamp() * 1000),
|
|
"resolution": "1D",
|
|
},
|
|
)
|
|
rows = (raw.get("result") or {}).get("data") or []
|
|
series_by_lookback[lb] = [
|
|
float(r[4]) for r in rows if len(r) >= 5
|
|
]
|
|
|
|
if current_iv is None:
|
|
latest = series_by_lookback.get(30) or series_by_lookback.get(90) or []
|
|
current_iv = latest[-1] if latest else None
|
|
|
|
def _percentile(values: list[float], target: float | None) -> float | None:
|
|
if target is None or not values:
|
|
return None
|
|
below = sum(1 for v in values if v <= target)
|
|
return round(100.0 * below / len(values), 2)
|
|
|
|
def _rank(values: list[float], target: float | None) -> float | None:
|
|
if target is None or not values:
|
|
return None
|
|
lo, hi = min(values), max(values)
|
|
if hi == lo:
|
|
return None
|
|
return round(100.0 * (target - lo) / (hi - lo), 2)
|
|
|
|
def _stats(values: list[float]) -> tuple[float | None, float | None]:
|
|
if not values:
|
|
return None, None
|
|
m = sum(values) / len(values)
|
|
var = sum((v - m) ** 2 for v in values) / len(values)
|
|
return m, var ** 0.5
|
|
|
|
mean_30, std_30 = _stats(series_by_lookback[30])
|
|
warnings: list[str] = []
|
|
if len(series_by_lookback[30]) < 10:
|
|
warnings.append("limited_history")
|
|
return {
|
|
"instrument": instrument,
|
|
"currency": currency,
|
|
"current_iv": current_iv,
|
|
"iv_percentile_30d": _percentile(series_by_lookback[30], current_iv),
|
|
"iv_percentile_60d": _percentile(series_by_lookback[60], current_iv),
|
|
"iv_percentile_90d": _percentile(series_by_lookback[90], current_iv),
|
|
"iv_percentile_365d": _percentile(series_by_lookback[365], current_iv),
|
|
"iv_rank_30d": _rank(series_by_lookback[30], current_iv),
|
|
"iv_rank_60d": _rank(series_by_lookback[60], current_iv),
|
|
"iv_rank_90d": _rank(series_by_lookback[90], current_iv),
|
|
"iv_rank_365d": _rank(series_by_lookback[365], current_iv),
|
|
"mean_30d": mean_30,
|
|
"stddev_30d": std_30,
|
|
"data_points_30d": len(series_by_lookback[30]),
|
|
"data_timestamp": now.isoformat(),
|
|
"warnings": warnings,
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_realized_vol(
|
|
self,
|
|
currency: str,
|
|
windows: list[int] | None = None,
|
|
) -> dict:
|
|
"""Annualized realized volatility (log-return std) su daily closes di Deribit index."""
|
|
import datetime as _dt
|
|
import math
|
|
|
|
currency = currency.upper()
|
|
if currency not in ("BTC", "ETH"):
|
|
return {"currency": currency, "error": "currency not supported"}
|
|
windows = windows or [14, 30]
|
|
max_w = max(windows)
|
|
|
|
now = _dt.datetime.now(_dt.UTC)
|
|
start = now - _dt.timedelta(days=max_w + 10)
|
|
raw = await self._request(
|
|
"public/get_tradingview_chart_data",
|
|
{
|
|
"instrument_name": f"{currency}-PERPETUAL",
|
|
"start_timestamp": int(start.timestamp() * 1000),
|
|
"end_timestamp": int(now.timestamp() * 1000),
|
|
"resolution": "1D",
|
|
},
|
|
)
|
|
result = raw.get("result") or {}
|
|
closes = [float(c) for c in (result.get("close") or [])]
|
|
rv_by_window: dict[str, float | None] = {}
|
|
for w in windows:
|
|
if len(closes) <= w:
|
|
rv_by_window[f"{w}d"] = None
|
|
continue
|
|
segment = closes[-(w + 1):]
|
|
rets = [
|
|
math.log(segment[i] / segment[i - 1])
|
|
for i in range(1, len(segment))
|
|
if segment[i - 1] > 0
|
|
]
|
|
if len(rets) < 2:
|
|
rv_by_window[f"{w}d"] = None
|
|
continue
|
|
m = sum(rets) / len(rets)
|
|
var = sum((r - m) ** 2 for r in rets) / (len(rets) - 1)
|
|
rv_by_window[f"{w}d"] = round(math.sqrt(var * 365) * 100, 2)
|
|
|
|
iv_current = None
|
|
try:
|
|
dvol_raw = await self._request(
|
|
"public/get_volatility_index_data",
|
|
{
|
|
"currency": currency,
|
|
"start_timestamp": int((now - _dt.timedelta(days=2)).timestamp() * 1000),
|
|
"end_timestamp": int(now.timestamp() * 1000),
|
|
"resolution": "1D",
|
|
},
|
|
)
|
|
dv = (dvol_raw.get("result") or {}).get("data") or []
|
|
if dv:
|
|
iv_current = float(dv[-1][4])
|
|
except Exception:
|
|
iv_current = None
|
|
|
|
iv_rv_spread: dict[str, float | None] = {}
|
|
for w_key, rv in rv_by_window.items():
|
|
iv_rv_spread[w_key] = (
|
|
round(iv_current - rv, 2) if (iv_current is not None and rv is not None) else None
|
|
)
|
|
|
|
return {
|
|
"currency": currency,
|
|
"realized_vol_pct": rv_by_window,
|
|
"iv_current_pct": iv_current,
|
|
"iv_minus_rv_pct": iv_rv_spread,
|
|
"data_points": len(closes),
|
|
"data_timestamp": now.isoformat(),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_dvol_history(
|
|
self,
|
|
currency: str,
|
|
lookback_days: int = 90,
|
|
) -> dict:
|
|
import datetime as _dt
|
|
|
|
now = _dt.datetime.now(_dt.UTC)
|
|
start = now - _dt.timedelta(days=lookback_days)
|
|
raw = await self._request(
|
|
"public/get_volatility_index_data",
|
|
{
|
|
"currency": currency.upper(),
|
|
"start_timestamp": int(start.timestamp() * 1000),
|
|
"end_timestamp": int(now.timestamp() * 1000),
|
|
"resolution": "1D",
|
|
},
|
|
)
|
|
rows = (raw.get("result") or {}).get("data") or []
|
|
series = [
|
|
{"timestamp": r[0], "dvol": float(r[4])}
|
|
for r in rows
|
|
if len(r) >= 5
|
|
]
|
|
values = [s["dvol"] for s in series]
|
|
values_sorted = sorted(values)
|
|
|
|
def _pct(p: float) -> float | None:
|
|
if not values_sorted:
|
|
return None
|
|
idx = int(round((len(values_sorted) - 1) * p))
|
|
return values_sorted[idx] # type: ignore[no-any-return]
|
|
|
|
mean = sum(values) / len(values) if values else None
|
|
return {
|
|
"currency": currency.upper(),
|
|
"lookback_days": lookback_days,
|
|
"series": series,
|
|
"current": values[-1] if values else None,
|
|
"mean": mean,
|
|
"p25": _pct(0.25),
|
|
"p50": _pct(0.50),
|
|
"p75": _pct(0.75),
|
|
"p95": _pct(0.95),
|
|
"data_points": len(values),
|
|
"testnet": self.testnet,
|
|
}
|
|
|
|
async def get_technical_indicators(
|
|
self,
|
|
instrument: str,
|
|
indicators: list[str],
|
|
start_date: str,
|
|
end_date: str,
|
|
resolution: str = "1h",
|
|
) -> dict:
|
|
historical = await self.get_historical(instrument, start_date, end_date, resolution)
|
|
candles = historical.get("candles", [])
|
|
closes = [c["close"] for c in candles]
|
|
highs = [c["high"] for c in candles]
|
|
lows = [c["low"] for c in candles]
|
|
|
|
result: dict[str, Any] = {}
|
|
for indicator in indicators:
|
|
name = indicator.lower()
|
|
if name == "sma":
|
|
result["sma"] = ind.sma(closes, 20)
|
|
elif name == "rsi":
|
|
result["rsi"] = ind.rsi(closes)
|
|
elif name == "atr":
|
|
result["atr"] = ind.atr(highs, lows, closes)
|
|
elif name == "macd":
|
|
result["macd"] = ind.macd(closes)
|
|
elif name == "adx":
|
|
result["adx"] = ind.adx(highs, lows, closes)
|
|
else:
|
|
result[name] = None
|
|
return result
|
|
|
|
# ── Write tools ──────────────────────────────────────────────
|
|
|
|
async def place_order(
|
|
self,
|
|
instrument_name: str,
|
|
side: str,
|
|
amount: float,
|
|
type: str = "limit",
|
|
price: float | None = None,
|
|
reduce_only: bool = False,
|
|
post_only: bool = False,
|
|
label: str | None = None,
|
|
) -> dict:
|
|
endpoint = "private/buy" if side == "buy" else "private/sell"
|
|
params: dict[str, Any] = {
|
|
"instrument_name": instrument_name,
|
|
"amount": amount,
|
|
"type": type,
|
|
}
|
|
if price is not None:
|
|
if type in ("stop_market", "take_profit_market"):
|
|
params["trigger_price"] = price
|
|
params["trigger"] = "mark_price"
|
|
else:
|
|
params["price"] = price
|
|
if label:
|
|
params["label"] = label
|
|
if reduce_only:
|
|
params["reduce_only"] = True
|
|
if post_only:
|
|
params["post_only"] = True
|
|
raw = await self._request(endpoint, params)
|
|
r = raw.get("result")
|
|
if r is None:
|
|
return {"error": raw.get("error", "unknown"), "state": "error"}
|
|
return r # type: ignore[no-any-return]
|
|
|
|
async def place_combo_order(
|
|
self,
|
|
legs: list[dict[str, Any]],
|
|
side: str,
|
|
amount: float,
|
|
type: str = "limit",
|
|
price: float | None = None,
|
|
label: str | None = None,
|
|
) -> dict:
|
|
"""Crea un combo via private/create_combo poi piazza un singolo ordine
|
|
(buy/sell) sull'instrument_name del combo. Una sola crociata di spread
|
|
invece di N (uno per leg) → minor slippage su strutture liquide.
|
|
|
|
legs: [{instrument_name, direction: 'buy'|'sell', ratio: int}].
|
|
"""
|
|
combo_raw = await self._request("private/create_combo", {"trades": legs})
|
|
combo = combo_raw.get("result")
|
|
if combo is None:
|
|
return {"state": "error", "error": combo_raw.get("error", "unknown")}
|
|
combo_instrument = combo.get("instrument_name") or combo.get("id")
|
|
order = await self.place_order(
|
|
instrument_name=combo_instrument,
|
|
side=side,
|
|
amount=amount,
|
|
type=type,
|
|
price=price,
|
|
label=label,
|
|
)
|
|
if order.get("state") == "error":
|
|
return {"state": "error", "error": order.get("error"), "combo_instrument": combo_instrument}
|
|
return {"combo_instrument": combo_instrument, **order}
|
|
|
|
async def set_leverage(self, instrument_name: str, leverage: int) -> dict:
|
|
"""CER-016: pre-set account leverage per evitare default 50x testnet."""
|
|
raw = await self._request(
|
|
"private/set_leverage",
|
|
{"instrument_name": instrument_name, "leverage": leverage},
|
|
)
|
|
if raw.get("result") is None:
|
|
return {"state": "error", "error": raw.get("error", "unknown")}
|
|
return {"state": "ok", "instrument": instrument_name, "leverage": leverage}
|
|
|
|
async def cancel_order(self, order_id: str) -> dict:
|
|
raw = await self._request("private/cancel", {"order_id": order_id})
|
|
if raw.get("result") is None:
|
|
return {
|
|
"order_id": order_id,
|
|
"state": "error",
|
|
"error": raw.get("error", "unknown"),
|
|
}
|
|
r = raw["result"]
|
|
return {
|
|
"order_id": r.get("order_id"),
|
|
"state": r.get("order_state"),
|
|
}
|
|
|
|
async def set_stop_loss(self, order_id: str, stop_price: float) -> dict:
|
|
"""
|
|
Amend an existing order to add a stop-loss trigger via edit endpoint.
|
|
Deribit does not have a standalone set_stop_loss; we use private/edit
|
|
to update stop_price on the order.
|
|
"""
|
|
raw = await self._request(
|
|
"private/edit",
|
|
{"order_id": order_id, "stop_price": stop_price},
|
|
)
|
|
if raw.get("result") is None:
|
|
return {"order_id": order_id, "error": raw.get("error", "unknown")}
|
|
r = raw["result"].get("order", raw["result"])
|
|
return {
|
|
"order_id": r.get("order_id"),
|
|
"state": r.get("order_state"),
|
|
"stop_price": r.get("stop_price"),
|
|
}
|
|
|
|
async def set_take_profit(self, order_id: str, tp_price: float) -> dict:
|
|
"""
|
|
Amend an existing order to add a take-profit trigger via private/edit.
|
|
"""
|
|
raw = await self._request(
|
|
"private/edit",
|
|
{"order_id": order_id, "stop_price": tp_price},
|
|
)
|
|
if raw.get("result") is None:
|
|
return {"order_id": order_id, "error": raw.get("error", "unknown")}
|
|
r = raw["result"].get("order", raw["result"])
|
|
return {
|
|
"order_id": r.get("order_id"),
|
|
"state": r.get("order_state"),
|
|
"tp_price": tp_price,
|
|
}
|
|
|
|
async def close_position(self, instrument_name: str) -> dict:
|
|
raw = await self._request(
|
|
"private/close_position",
|
|
{"instrument_name": instrument_name, "type": "market"},
|
|
)
|
|
r = raw.get("result")
|
|
if r is None:
|
|
return {"instrument": instrument_name, "error": raw.get("error", "unknown"), "state": "error"}
|
|
order = r.get("order", r)
|
|
return {
|
|
"order_id": order.get("order_id"),
|
|
"state": order.get("order_state"),
|
|
}
|