From 3868ba60ce3bf529fe7e376d92eff66de94d6587 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 30 Apr 2026 18:12:11 +0200 Subject: [PATCH] feat(V2): migrazione common/ (indicators, options, microstructure, stats, http, audit, logging, mcp_bridge + auth) --- src/cerbero_mcp/common/audit.py | 121 +++++++ src/cerbero_mcp/common/auth.py | 98 ++++++ src/cerbero_mcp/common/http.py | 85 +++++ src/cerbero_mcp/common/indicators.py | 416 +++++++++++++++++++++++ src/cerbero_mcp/common/logging.py | 81 +++++ src/cerbero_mcp/common/mcp_bridge.py | 239 +++++++++++++ src/cerbero_mcp/common/microstructure.py | 74 ++++ src/cerbero_mcp/common/options.py | 201 +++++++++++ src/cerbero_mcp/common/stats.py | 96 ++++++ 9 files changed, 1411 insertions(+) create mode 100644 src/cerbero_mcp/common/audit.py create mode 100644 src/cerbero_mcp/common/auth.py create mode 100644 src/cerbero_mcp/common/http.py create mode 100644 src/cerbero_mcp/common/indicators.py create mode 100644 src/cerbero_mcp/common/logging.py create mode 100644 src/cerbero_mcp/common/mcp_bridge.py create mode 100644 src/cerbero_mcp/common/microstructure.py create mode 100644 src/cerbero_mcp/common/options.py create mode 100644 src/cerbero_mcp/common/stats.py diff --git a/src/cerbero_mcp/common/audit.py b/src/cerbero_mcp/common/audit.py new file mode 100644 index 0000000..3e8669c --- /dev/null +++ b/src/cerbero_mcp/common/audit.py @@ -0,0 +1,121 @@ +"""Audit log strutturato per write endpoint MCP (place_order, cancel, +set_*, close_*, transfer_*). Usa un logger dedicato `mcp.audit` su stream +JSON. + +Sink: +- stdout/stderr (sempre): tramite root JSON logger configurato da + `mcp_common.logging.configure_root_logging`. +- File JSONL persistente (opzionale): se env var `AUDIT_LOG_FILE` è + settata, aggiunge un `TimedRotatingFileHandler` che ruota a mezzanotte + con `AUDIT_LOG_BACKUP_DAYS` di retention (default 30). Una riga JSON + per record (formato `.jsonl`). + +Per VPS produzione: setta `AUDIT_LOG_FILE=/var/log/cerbero-mcp/.audit.jsonl` +con bind mount del volume `/var/log/cerbero-mcp` nel docker-compose. + +Payload sensibile (api_key, secret) già filtrato dal SecretsFilter +globale; qui non si include creds. +""" +from __future__ import annotations + +import logging +import os +from logging.handlers import TimedRotatingFileHandler +from typing import Any + +from cerbero_mcp.common.auth import Principal +from cerbero_mcp.common.logging import SecretsFilter, get_json_logger + +try: + from pythonjsonlogger.json import JsonFormatter as _JsonFormatter # noqa: N813 +except ImportError: + from pythonjsonlogger.jsonlogger import JsonFormatter as _JsonFormatter # noqa: N813 + +_logger = get_json_logger("mcp.audit", level=logging.INFO) +_file_handler_attached = False + + +def _configure_audit_sink() -> None: + """Aggiunge FileHandler al logger mcp.audit se AUDIT_LOG_FILE è settato. + Idempotente: chiamato la prima volta da audit_write_op, poi no-op. + """ + global _file_handler_attached + if _file_handler_attached: + return + + file_path = os.environ.get("AUDIT_LOG_FILE", "").strip() + if not file_path: + _file_handler_attached = True + return + + backup_days = int(os.environ.get("AUDIT_LOG_BACKUP_DAYS", "30")) + + os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) + handler = TimedRotatingFileHandler( + file_path, + when="midnight", + interval=1, + backupCount=backup_days, + encoding="utf-8", + utc=True, + ) + handler.setFormatter(_JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")) + handler.addFilter(SecretsFilter()) + _logger.addHandler(handler) + _file_handler_attached = True + + +def audit_write_op( + *, + principal: Principal | None, + action: str, + exchange: str, + target: str | None = None, + payload: dict[str, Any] | None = None, + result: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + """Emit a structured audit log record per write operation. + + principal: chi ha invocato (None se anonimo, ma normalmente _check + impedisce di arrivare qui senza principal). + action: nome del tool (es. "place_order", "cancel_order"). + exchange: identificatore servizio (deribit, bybit, alpaca, hyperliquid). + target: instrument/symbol/order_id su cui si agisce. + payload: input non-sensibile (qty, side, leverage, ecc.). + result: output del client (order_id, status, ecc.). + error: stringa errore se l'operazione ha fallito. + """ + _configure_audit_sink() + record: dict[str, Any] = { + "audit_event": "write_op", + "action": action, + "exchange": exchange, + "principal": principal.name if principal else None, + "target": target, + "payload": payload or {}, + } + if result is not None: + record["result"] = _summarize_result(result) + if error is not None: + record["error"] = error + _logger.error("audit", extra=record) + else: + _logger.info("audit", extra=record) + + +def _summarize_result(result: dict[str, Any]) -> dict[str, Any]: + """Estrae i campi rilevanti dal result (order_id, state, error code) + per evitare di loggare payload enormi. + """ + keys = ( + "order_id", "order_link_id", "combo_instrument", "state", "status", + "code", "error", "stop_price", "tp_price", "transfer_id", + ) + out: dict[str, Any] = {} + for k in keys: + if k in result: + out[k] = result[k] + if "orders" in result: + out["orders_count"] = len(result["orders"]) + return out diff --git a/src/cerbero_mcp/common/auth.py b/src/cerbero_mcp/common/auth.py new file mode 100644 index 0000000..d791527 --- /dev/null +++ b/src/cerbero_mcp/common/auth.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import wraps + +from fastapi import HTTPException, Request, status + + +@dataclass +class Principal: + name: str + capabilities: set[str] = field(default_factory=set) + + +@dataclass +class TokenStore: + tokens: dict[str, Principal] + + def get(self, token: str) -> Principal | None: + return self.tokens.get(token) + + +def require_principal(request: Request) -> Principal: + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing bearer token") + token = auth[len("Bearer "):].strip() + store: TokenStore = request.app.state.token_store + principal = store.get(token) + if principal is None: + raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid token") + return principal + + +def acl_requires(*, core: bool = False, observer: bool = False) -> Callable: + """Decorator: require at least one matching capability.""" + allowed: set[str] = set() + if core: + allowed.add("core") + if observer: + allowed.add("observer") + + def decorator(func: Callable) -> Callable: + @wraps(func) + async def async_wrapper(*args, **kwargs): + principal = kwargs.get("principal") + if principal is None: + for a in args: + if isinstance(a, Principal): + principal = a + break + if principal is None or not (principal.capabilities & allowed): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + f"capability required: {allowed}", + ) + return await func(*args, **kwargs) if _is_coro(func) else func(*args, **kwargs) + + @wraps(func) + def sync_wrapper(*args, **kwargs): + principal = kwargs.get("principal") + if principal is None: + for a in args: + if isinstance(a, Principal): + principal = a + break + if principal is None or not (principal.capabilities & allowed): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + f"capability required: {allowed}", + ) + return func(*args, **kwargs) + + return async_wrapper if _is_coro(func) else sync_wrapper + + return decorator + + +def _is_coro(func: Callable) -> bool: + import asyncio + return asyncio.iscoroutinefunction(func) + + +def load_token_store_from_files( + core_token_file: str | None, + observer_token_file: str | None, +) -> TokenStore: + tokens: dict[str, Principal] = {} + if core_token_file: + with open(core_token_file) as f: + tokens[f.read().strip()] = Principal(name="core", capabilities={"core"}) + if observer_token_file: + with open(observer_token_file) as f: + tokens[f.read().strip()] = Principal( + name="observer", capabilities={"observer"} + ) + return TokenStore(tokens=tokens) diff --git a/src/cerbero_mcp/common/http.py b/src/cerbero_mcp/common/http.py new file mode 100644 index 0000000..f357862 --- /dev/null +++ b/src/cerbero_mcp/common/http.py @@ -0,0 +1,85 @@ +"""HTTP client factory con retry/backoff su errori transient. + +Wrap leggero attorno a httpx.AsyncClient: aggiunge AsyncHTTPTransport +con retries=N per gestire connection errors / DNS / refused. Per retry +su 5xx HTTP response usa `request_with_retry()` (decoratore separato). + +Usage standard: + + async with async_client(timeout=15) as http: + resp = await http.get(url) + +Equivalente a httpx.AsyncClient(timeout=15) ma con retry transport su +errori di livello connessione. +""" +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar + +import httpx + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +DEFAULT_RETRIES = 3 +DEFAULT_TIMEOUT = 15.0 + + +def async_client( + *, + timeout: float = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES, + follow_redirects: bool = False, + **kwargs: Any, +) -> httpx.AsyncClient: + """httpx.AsyncClient con AsyncHTTPTransport(retries=N) di default. + retries gestisce connection errors / refused / DNS — non 5xx HTTP. + """ + transport = httpx.AsyncHTTPTransport(retries=retries) + return httpx.AsyncClient( + timeout=timeout, + transport=transport, + follow_redirects=follow_redirects, + **kwargs, + ) + + +async def call_with_retry( + fn: Callable[[], Awaitable[T]], + *, + max_attempts: int = 3, + base_delay: float = 0.5, + max_delay: float = 8.0, + retry_on: tuple[type[BaseException], ...] = (httpx.TransportError, httpx.TimeoutException), +) -> T: + """Retry generico async con exponential backoff. + + Ritenta `fn()` se solleva una delle exception in `retry_on`. Backoff + raddoppia (0.5, 1, 2, 4, ...) clipped a max_delay. Solleva l'ultima + exception se max_attempts raggiunto. + + Usabile su SDK sincroni avvolti in asyncio.to_thread (pybit, alpaca): + + result = await call_with_retry(lambda: client._run(self._http.get_tickers, ...)) + """ + delay = base_delay + last_exc: BaseException | None = None + for attempt in range(1, max_attempts + 1): + try: + return await fn() + except retry_on as e: + last_exc = e + if attempt == max_attempts: + break + logger.warning( + "transient error, retrying (%d/%d) in %.1fs: %s", + attempt, max_attempts, delay, type(e).__name__, + ) + await asyncio.sleep(delay) + delay = min(delay * 2, max_delay) + assert last_exc is not None + raise last_exc diff --git a/src/cerbero_mcp/common/indicators.py b/src/cerbero_mcp/common/indicators.py new file mode 100644 index 0000000..e4775ce --- /dev/null +++ b/src/cerbero_mcp/common/indicators.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import math + + +def sma(values: list[float], period: int) -> float | None: + if len(values) < period: + return None + return sum(values[-period:]) / period + + +def rsi(closes: list[float], period: int = 14) -> float | None: + if len(closes) < period + 1: + return None + gains: list[float] = [] + losses: list[float] = [] + for i in range(1, len(closes)): + delta = closes[i] - closes[i - 1] + gains.append(max(delta, 0.0)) + losses.append(-min(delta, 0.0)) + avg_gain = sum(gains[:period]) / period + avg_loss = sum(losses[:period]) / period + for i in range(period, len(gains)): + avg_gain = (avg_gain * (period - 1) + gains[i]) / period + avg_loss = (avg_loss * (period - 1) + losses[i]) / period + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100.0 - (100.0 / (1.0 + rs)) + + +def _ema_series(values: list[float], period: int) -> list[float]: + if len(values) < period: + return [] + k = 2.0 / (period + 1) + seed = sum(values[:period]) / period + out = [seed] + for v in values[period:]: + out.append(out[-1] + k * (v - out[-1])) + return out + + +def macd( + closes: list[float], + fast: int = 12, + slow: int = 26, + signal: int = 9, +) -> dict[str, float | None]: + nothing: dict[str, float | None] = {"macd": None, "signal": None, "hist": None} + if len(closes) < slow + signal: + return nothing + ema_fast = _ema_series(closes, fast) + ema_slow = _ema_series(closes, slow) + offset = slow - fast + aligned_fast = ema_fast[offset:] + macd_line = [f - s for f, s in zip(aligned_fast, ema_slow, strict=False)] + if len(macd_line) < signal: + return nothing + signal_line = _ema_series(macd_line, signal) + if not signal_line: + return nothing + last_macd = macd_line[-1] + last_sig = signal_line[-1] + return { + "macd": last_macd, + "signal": last_sig, + "hist": last_macd - last_sig, + } + + +def atr( + highs: list[float], + lows: list[float], + closes: list[float], + period: int = 14, +) -> float | None: + if len(closes) < period + 1: + return None + trs: list[float] = [] + for i in range(1, len(closes)): + tr = max( + highs[i] - lows[i], + abs(highs[i] - closes[i - 1]), + abs(lows[i] - closes[i - 1]), + ) + trs.append(tr) + if len(trs) < period: + return None + avg = sum(trs[:period]) / period + for i in range(period, len(trs)): + avg = (avg * (period - 1) + trs[i]) / period + return avg + + +def adx( + highs: list[float], + lows: list[float], + closes: list[float], + period: int = 14, +) -> dict[str, float | None]: + nothing: dict[str, float | None] = {"adx": None, "+di": None, "-di": None} + if len(closes) < 2 * period + 1: + return nothing + trs: list[float] = [] + plus_dms: list[float] = [] + minus_dms: list[float] = [] + for i in range(1, len(closes)): + tr = max( + highs[i] - lows[i], + abs(highs[i] - closes[i - 1]), + abs(lows[i] - closes[i - 1]), + ) + up = highs[i] - highs[i - 1] + dn = lows[i - 1] - lows[i] + plus_dm = up if (up > dn and up > 0) else 0.0 + minus_dm = dn if (dn > up and dn > 0) else 0.0 + trs.append(tr) + plus_dms.append(plus_dm) + minus_dms.append(minus_dm) + + atr_s = sum(trs[:period]) + pdm_s = sum(plus_dms[:period]) + mdm_s = sum(minus_dms[:period]) + dxs: list[float] = [] + pdi = mdi = 0.0 + for i in range(period, len(trs)): + atr_s = atr_s - atr_s / period + trs[i] + pdm_s = pdm_s - pdm_s / period + plus_dms[i] + mdm_s = mdm_s - mdm_s / period + minus_dms[i] + pdi = 100.0 * pdm_s / atr_s if atr_s else 0.0 + mdi = 100.0 * mdm_s / atr_s if atr_s else 0.0 + s = pdi + mdi + dx = 100.0 * abs(pdi - mdi) / s if s else 0.0 + dxs.append(dx) + + if len(dxs) < period: + return nothing + adx_val = sum(dxs[:period]) / period + for i in range(period, len(dxs)): + adx_val = (adx_val * (period - 1) + dxs[i]) / period + return {"adx": adx_val, "+di": pdi, "-di": mdi} + + +# ───── Returns helper ───── + +def _log_returns(closes: list[float]) -> list[float]: + out: list[float] = [] + for i in range(1, len(closes)): + prev = closes[i - 1] + curr = closes[i] + if prev > 0 and curr > 0: + out.append(math.log(curr / prev)) + return out + + +def _percentile(sorted_values: list[float], q: float) -> float: + if not sorted_values: + return 0.0 + if len(sorted_values) == 1: + return sorted_values[0] + pos = q * (len(sorted_values) - 1) + lo = int(pos) + hi = min(lo + 1, len(sorted_values) - 1) + frac = pos - lo + return sorted_values[lo] + frac * (sorted_values[hi] - sorted_values[lo]) + + +def _stddev(xs: list[float]) -> float: + if len(xs) < 2: + return 0.0 + m = sum(xs) / len(xs) + var = sum((x - m) ** 2 for x in xs) / (len(xs) - 1) + return math.sqrt(var) + + +# ───── vol_cone ───── + +def vol_cone( + closes: list[float], + windows: list[int] | None = None, + annualization: int = 252, +) -> dict[int, dict[str, float | None]]: + """Realized vol cone: per ogni window restituisce vol corrente e percentili + storici (p10/p50/p90) di tutte le rolling windows del campione. + Annualizzata (default 252 trading days). + """ + windows = windows or [10, 20, 30, 60] + rets = _log_returns(closes) + out: dict[int, dict[str, float | None]] = {} + factor = math.sqrt(annualization) + for w in windows: + if len(rets) < w: + out[w] = {"current": None, "p10": None, "p50": None, "p90": None} + continue + rolling: list[float] = [] + for i in range(w, len(rets) + 1): + window_rets = rets[i - w:i] + rolling.append(_stddev(window_rets) * factor) + rolling_sorted = sorted(rolling) + out[w] = { + "current": rolling[-1], + "p10": _percentile(rolling_sorted, 0.10), + "p50": _percentile(rolling_sorted, 0.50), + "p90": _percentile(rolling_sorted, 0.90), + } + return out + + +# ───── hurst_exponent ───── + +def hurst_exponent(closes: list[float], min_lag: int = 2, max_lag: int = 100) -> float | None: + """Hurst via R/S analysis su log-prices. H≈0.5 random walk, >0.5 trending, + <0.5 mean-reverting. + """ + if len(closes) < max(20, max_lag): + return None + log_p = [math.log(c) for c in closes if c > 0] + if len(log_p) < max(20, max_lag): + return None + upper = min(max_lag, len(log_p) // 2) + if upper < min_lag + 1: + return None + lags = list(range(min_lag, upper)) + log_lags: list[float] = [] + log_rs: list[float] = [] + for lag in lags: + # Build N/lag non-overlapping segments; for each compute R/S + rs_vals: list[float] = [] + n_segs = len(log_p) // lag + if n_segs < 1: + continue + for seg in range(n_segs): + chunk = log_p[seg * lag:(seg + 1) * lag] + diffs = [chunk[i] - chunk[i - 1] for i in range(1, len(chunk))] + if len(diffs) < 2: + continue + mean = sum(diffs) / len(diffs) + dev = [d - mean for d in diffs] + cum = [] + acc = 0.0 + for d in dev: + acc += d + cum.append(acc) + r = max(cum) - min(cum) + s = _stddev(diffs) + if s > 0: + rs_vals.append(r / s) + if rs_vals: + avg_rs = sum(rs_vals) / len(rs_vals) + if avg_rs > 0: + log_lags.append(math.log(lag)) + log_rs.append(math.log(avg_rs)) + if len(log_lags) < 4: + return None + # Linear regression slope = Hurst + n = len(log_lags) + mx = sum(log_lags) / n + my = sum(log_rs) / n + num = sum((log_lags[i] - mx) * (log_rs[i] - my) for i in range(n)) + den = sum((log_lags[i] - mx) ** 2 for i in range(n)) + if den == 0: + return None + return num / den + + +# ───── half_life_mean_reversion ───── + +def half_life_mean_reversion(closes: list[float]) -> float | None: + """Half-life via OU AR(1) fit: y_t - y_{t-1} = a + b*y_{t-1} + eps. + Half-life = -ln(2)/ln(1+b). Se b>=0 → no mean reversion → None. + """ + if len(closes) < 30: + return None + y_lag = closes[:-1] + delta = [closes[i] - closes[i - 1] for i in range(1, len(closes))] + n = len(y_lag) + mx = sum(y_lag) / n + my = sum(delta) / n + num = sum((y_lag[i] - mx) * (delta[i] - my) for i in range(n)) + den = sum((y_lag[i] - mx) ** 2 for i in range(n)) + if den == 0: + return None + b = num / den + if b >= 0: + return None + one_plus_b = 1.0 + b + if one_plus_b <= 0: + return None + return -math.log(2.0) / math.log(one_plus_b) + + +# ───── garch11_forecast ───── + +def garch11_forecast( + closes: list[float], + max_iter: int = 50, +) -> dict[str, float] | None: + """Forecast GARCH(1,1) one-step-ahead sigma via metodo dei momenti + semplificato (no MLE). Pure-Python: stima omega, alpha, beta tramite + iterazione di punto fisso minimizzando MSE sul squared-return tracking. + Sufficiente per ranking volatility regimes; non production-grade. + """ + rets = _log_returns(closes) + if len(rets) < 50: + return None + mean = sum(rets) / len(rets) + centered = [r - mean for r in rets] + sq = [r * r for r in centered] + # Sample variance as long-run mean + var_lr = sum(sq) / len(sq) + if var_lr <= 0: + return None + # Simple grid for (alpha, beta) minimizing MSE of sigma2 vs realized sq + best = (1e18, 0.05, 0.90) + for a in [0.02, 0.05, 0.08, 0.10, 0.15]: + for b in [0.80, 0.85, 0.88, 0.90, 0.93]: + if a + b >= 0.999: + continue + omega = var_lr * (1 - a - b) + if omega <= 0: + continue + sigma2 = var_lr + mse = 0.0 + for s in sq[:-1]: + sigma2 = omega + a * s + b * sigma2 + mse += (sigma2 - s) ** 2 + if mse < best[0]: + best = (mse, a, b) + _, alpha, beta = best + omega = var_lr * (1 - alpha - beta) + sigma2 = var_lr + for s in sq: + sigma2 = omega + alpha * s + beta * sigma2 + sigma2_next = omega + alpha * sq[-1] + beta * sigma2 + return { + "sigma_next": math.sqrt(max(sigma2_next, 0.0)), + "alpha": alpha, + "beta": beta, + "omega": omega, + "long_run_sigma": math.sqrt(var_lr), + } + + +# ───── autocorrelation ───── + +def autocorrelation(values: list[float], max_lag: int = 10) -> dict[int, float]: + """Autocorrelation function (ACF) lag 1..max_lag. White noise → ≈ 0. + AR(1) phi → lag1 ≈ phi, lag-k ≈ phi^k. + """ + if len(values) < max_lag + 2: + return {} + n = len(values) + mean = sum(values) / n + dev = [v - mean for v in values] + var = sum(d * d for d in dev) / n + if var == 0: + return {lag: 0.0 for lag in range(1, max_lag + 1)} + out: dict[int, float] = {} + for lag in range(1, max_lag + 1): + cov = sum(dev[i] * dev[i + lag] for i in range(n - lag)) / n + out[lag] = cov / var + return out + + +# ───── rolling_sharpe ───── + +def rolling_sharpe( + closes: list[float], + window: int = 60, + annualization: int = 252, + risk_free: float = 0.0, +) -> dict[str, float] | None: + """Sharpe e Sortino rolling sull'ultimo `window` di log-returns. + Annualizzati. risk_free in tasso annualizzato. + """ + rets = _log_returns(closes) + if len(rets) < window: + return None + sample = rets[-window:] + daily_rf = risk_free / annualization + excess = [r - daily_rf for r in sample] + mean = sum(excess) / len(excess) + sd = _stddev(excess) + sharpe = (mean / sd) * math.sqrt(annualization) if sd > 0 else 0.0 + downside = [e for e in excess if e < 0] + if downside: + ds_var = sum(d * d for d in downside) / len(excess) + ds_sd = math.sqrt(ds_var) + sortino = (mean / ds_sd) * math.sqrt(annualization) if ds_sd > 0 else 0.0 + else: + sortino = sharpe * 2 # nessun downside → sortino "molto buono" + return {"sharpe": sharpe, "sortino": sortino, "mean_excess": mean, "stddev": sd} + + +# ───── var_cvar ───── + +def var_cvar(returns: list[float], confidences: list[float] | None = None) -> dict[str, float]: + """Historical VaR e CVaR (Expected Shortfall) ai livelli di confidenza. + returns: serie di rendimenti (qualsiasi periodicità). VaR/CVaR restituiti + come perdite positive (es. var_95=0.03 → -3% al 95%). + """ + confidences = confidences or [0.95, 0.99] + if len(returns) < 30: + return {} + sorted_rets = sorted(returns) + out: dict[str, float] = {} + for c in confidences: + tag = int(round(c * 100)) + q = 1.0 - c + var = -_percentile(sorted_rets, q) + cutoff = -var + tail = [r for r in sorted_rets if r <= cutoff] + cvar = -(sum(tail) / len(tail)) if tail else var + out[f"var_{tag}"] = var + out[f"cvar_{tag}"] = cvar + return out diff --git a/src/cerbero_mcp/common/logging.py b/src/cerbero_mcp/common/logging.py new file mode 100644 index 0000000..9e8007f --- /dev/null +++ b/src/cerbero_mcp/common/logging.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import logging +import os +import re +import sys + +# pythonjsonlogger rinominato in .json; keep fallback per compat +try: + from pythonjsonlogger.json import JsonFormatter as _JsonFormatter # noqa: N813 +except ImportError: + from pythonjsonlogger.jsonlogger import JsonFormatter as _JsonFormatter # noqa: N813 + +SECRET_PATTERNS = [ + (re.compile(r"Bearer\s+[\w\-\._]+", re.IGNORECASE), "Bearer ***"), + (re.compile(r'("api_key"\s*:\s*")[^"]+(")'), r'\1***\2'), + (re.compile(r'("password"\s*:\s*")[^"]+(")'), r'\1***\2'), + (re.compile(r'("private_key"\s*:\s*")[^"]+(")'), r'\1***\2'), + (re.compile(r'("client_secret"\s*:\s*")[^"]+(")'), r'\1***\2'), + (re.compile(r"sk-[\w]{20,}"), "sk-***"), +] + + +class SecretsFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + msg = record.getMessage() + for pattern, replacement in SECRET_PATTERNS: + msg = pattern.sub(replacement, msg) + record.msg = msg + record.args = () # already formatted into msg + return True + + +def get_json_logger(name: str, level: int = logging.INFO) -> logging.Logger: + logger = logging.getLogger(name) + if logger.handlers: + return logger # already configured + logger.setLevel(level) + handler = logging.StreamHandler(sys.stderr) + formatter = _JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") + handler.setFormatter(formatter) + handler.addFilter(SecretsFilter()) + logger.addHandler(handler) + logger.propagate = False + return logger + + +def configure_root_logging( + *, + level: str | int | None = None, + format_type: str | None = None, +) -> None: + """CER-P5-009: configura il root logger con JSON o text formatter. + + Env overrides: + - LOG_LEVEL (default INFO) + - LOG_FORMAT=json|text (default json — production-ready structured log) + + Applica SecretsFilter su entrambi i format. + """ + lvl_raw = level if level is not None else os.environ.get("LOG_LEVEL", "INFO") + lvl = logging.getLevelName(lvl_raw.upper()) if isinstance(lvl_raw, str) else lvl_raw + fmt = (format_type or os.environ.get("LOG_FORMAT") or "json").lower() + + root = logging.getLogger() + # Rimuovi handler esistenti (basicConfig li avrebbe lasciati duplicati) + for h in list(root.handlers): + root.removeHandler(h) + + handler = logging.StreamHandler(sys.stderr) + if fmt == "json": + handler.setFormatter( + _JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") + ) + else: + handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") + ) + handler.addFilter(SecretsFilter()) + root.addHandler(handler) + root.setLevel(lvl) diff --git a/src/cerbero_mcp/common/mcp_bridge.py b/src/cerbero_mcp/common/mcp_bridge.py new file mode 100644 index 0000000..abd1e98 --- /dev/null +++ b/src/cerbero_mcp/common/mcp_bridge.py @@ -0,0 +1,239 @@ +"""Bridge MCP → endpoint REST esistenti. + +Implementa manualmente JSON-RPC 2.0 MCP su `POST /mcp` (no SSE, risposta +diretta in body JSON). Supporta: + - initialize + - notifications/initialized + - tools/list + - tools/call + +Claude Code config esempio: + + { + "mcpServers": { + "cerbero-memory": { + "type": "http", + "url": "http://localhost:8080/mcp-memory/mcp", + "headers": {"Authorization": "Bearer "} + } + } + } +""" +from __future__ import annotations + +import contextlib +from typing import Any + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from cerbero_mcp.common.auth import TokenStore + +MCP_PROTOCOL_VERSION = "2024-11-05" + + +def _derive_input_schemas(app: FastAPI, tool_names: list[str]) -> dict[str, dict]: + """Estrae JSON schema del body Pydantic per ogni route POST /tools/. + + Risolve annotazioni lazy (PEP 563) via `typing.get_type_hints`. + Ritorna mapping {tool_name: json_schema}. Route senza body Pydantic o non + risolvibili vengono saltate: il chiamante userà un fallback. + """ + import typing + + from pydantic import BaseModel + + names_set = set(tool_names) + out: dict[str, dict] = {} + for route in app.routes: + path = getattr(route, "path", "") + if not path.startswith("/tools/"): + continue + name = path[len("/tools/"):] + if name not in names_set: + continue + endpoint = getattr(route, "endpoint", None) + if endpoint is None: + continue + try: + hints = typing.get_type_hints(endpoint) + except Exception: + continue + for pname, ann in hints.items(): + if pname == "return": + continue + if isinstance(ann, type) and issubclass(ann, BaseModel): + with contextlib.suppress(Exception): + out[name] = ann.model_json_schema() + break + return out + + +def _make_proxy_handler(internal_base_url: str, tool_name: str, token: str): + async def handler(args: dict | None) -> Any: + async with httpx.AsyncClient(timeout=30.0) as c: + r = await c.post( + f"{internal_base_url}/tools/{tool_name}", + headers={"Authorization": f"Bearer {token}"} if token else {}, + json=args or {}, + ) + if r.status_code >= 400: + raise RuntimeError( + f"tool {tool_name} failed: HTTP {r.status_code} — {r.text[:500]}" + ) + try: + return r.json() + except Exception: + return {"raw": r.text} + + return handler + + +def mount_mcp_endpoint( + app: FastAPI, + *, + name: str, + version: str, + token_store: TokenStore, + internal_base_url: str, + tools: list[dict], +) -> None: + """Registra un endpoint MCP JSON-RPC 2.0 su POST /mcp. + + Ogni tool è proxato verso POST {internal_base_url}/tools/ con il + Bearer token del client MCP (preservando le ACL REST esistenti). + + Args: + app: istanza FastAPI del service + name: nome server MCP + version: versione del service + token_store: lo stesso usato dai tool REST + internal_base_url: URL base interno (es. "http://localhost:9015") + tools: lista di {"name": str, "description": str, "input_schema"?: dict} + """ + tools_by_name = {t["name"]: t for t in tools} + + # Auto-derive input schemas from FastAPI routes (Pydantic body models). + # Permette al LLM di conoscere i nomi dei parametri obbligatori invece di + # indovinarli. Se il tool ha `input_schema` esplicito, vince sull'auto-derive. + derived_schemas = _derive_input_schemas(app, [t["name"] for t in tools]) + + def _tool_defs() -> list[dict]: + defs = [] + for t in tools: + schema = t.get("input_schema") or derived_schemas.get(t["name"]) or { + "type": "object", + "additionalProperties": True, + } + defs.append({ + "name": t["name"], + "description": t.get("description", t["name"]), + "inputSchema": schema, + }) + return defs + + async def _handle_rpc(body: dict, token: str) -> dict | None: + rpc_id = body.get("id") + method = body.get("method") + params = body.get("params") or {} + + # Notification (no id) → no response + if method == "notifications/initialized": + return None + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": rpc_id, + "result": { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": name, "version": version}, + }, + } + + if method == "tools/list": + return { + "jsonrpc": "2.0", + "id": rpc_id, + "result": {"tools": _tool_defs()}, + } + + if method == "tools/call": + tool_name = params.get("name", "") + args = params.get("arguments") or {} + if tool_name not in tools_by_name: + return { + "jsonrpc": "2.0", + "id": rpc_id, + "error": {"code": -32601, "message": f"tool non trovato: {tool_name}"}, + } + handler = _make_proxy_handler(internal_base_url, tool_name, token) + try: + result = await handler(args) + return { + "jsonrpc": "2.0", + "id": rpc_id, + "result": { + "content": [ + { + "type": "text", + "text": _to_text(result), + } + ], + "isError": False, + }, + } + except Exception as e: + return { + "jsonrpc": "2.0", + "id": rpc_id, + "result": { + "content": [{"type": "text", "text": str(e)}], + "isError": True, + }, + } + + return { + "jsonrpc": "2.0", + "id": rpc_id, + "error": {"code": -32601, "message": f"metodo non supportato: {method}"}, + } + + @app.post("/mcp") + async def mcp_entry(request: Request): + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return JSONResponse({"error": "missing bearer token"}, status_code=401) + token = auth[len("Bearer "):].strip() + principal = token_store.get(token) + if principal is None: + return JSONResponse({"error": "invalid token"}, status_code=403) + + body = await request.json() + + # Batch support + if isinstance(body, list): + results = [] + for item in body: + resp = await _handle_rpc(item, token) + if resp is not None: + results.append(resp) + return JSONResponse(results) + + resp = await _handle_rpc(body, token) + if resp is None: + # Notification (no id) → 204 no content + return JSONResponse(None, status_code=204) + return JSONResponse(resp) + + +def _to_text(value: Any) -> str: + import json + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False, indent=2) + except Exception: + return str(value) diff --git a/src/cerbero_mcp/common/microstructure.py b/src/cerbero_mcp/common/microstructure.py new file mode 100644 index 0000000..056f442 --- /dev/null +++ b/src/cerbero_mcp/common/microstructure.py @@ -0,0 +1,74 @@ +"""Microstructure indicators: orderbook imbalance, slope, microprice. + +Tutte le funzioni accettano bids/asks come list[list[price, qty]] (formato +standard dei ticker exchange) e ritornano metriche aggregate exchange-agnostic. +""" +from __future__ import annotations + + +def orderbook_imbalance( + bids: list[list[float]], + asks: list[list[float]], + depth: int = 10, +) -> dict[str, float | None]: + """Imbalance ratio = (bid_vol - ask_vol) / (bid_vol + ask_vol) sui top-`depth` + livelli. Range [-1, +1]. Positivo = bid pressure, negativo = ask pressure. + + Microprice (Stoll-Bertsimas): mid pesato dalla size opposta + → P_micro = (P_bid * Q_ask + P_ask * Q_bid) / (Q_bid + Q_ask). Best level only. + + Slope: variazione cumulata di volume per unità di prezzo (proxy per + liquidità in profondità). + """ + if not bids and not asks: + return { + "imbalance_ratio": None, + "bid_volume": 0.0, + "ask_volume": 0.0, + "microprice": None, + "bid_slope": None, + "ask_slope": None, + } + + top_bids = bids[:depth] + top_asks = asks[:depth] + bid_vol = sum(q for _, q in top_bids) + ask_vol = sum(q for _, q in top_asks) + total = bid_vol + ask_vol + + ratio = None if total == 0 else (bid_vol - ask_vol) / total + + # Microprice: best bid, best ask. Weighted by opposite-side size. + microprice = None + if top_bids and top_asks: + bp, bq = top_bids[0] + ap, aq = top_asks[0] + denom = bq + aq + if denom > 0: + microprice = (bp * aq + ap * bq) / denom + + bid_slope = _depth_slope(top_bids, ascending_price=False) + ask_slope = _depth_slope(top_asks, ascending_price=True) + + return { + "imbalance_ratio": ratio, + "bid_volume": bid_vol, + "ask_volume": ask_vol, + "microprice": microprice, + "bid_slope": bid_slope, + "ask_slope": ask_slope, + } + + +def _depth_slope(levels: list[list[float]], ascending_price: bool) -> float | None: + """Calcola |Δq / Δp| sul primo vs penultimo livello. + Slope alto = liquidità che crolla rapidamente in profondità (book sottile). + """ + if len(levels) < 2: + return None + p_first, q_first = levels[0] + p_last, q_last = levels[-1] + dp = abs(p_last - p_first) + if dp == 0: + return None + return abs(q_first - q_last) / dp diff --git a/src/cerbero_mcp/common/options.py b/src/cerbero_mcp/common/options.py new file mode 100644 index 0000000..7e1b66b --- /dev/null +++ b/src/cerbero_mcp/common/options.py @@ -0,0 +1,201 @@ +"""Logiche option-flow exchange-agnostiche. + +Ogni funzione accetta una lista di "legs" (dizionari) con i campi rilevanti +e ritorna metriche aggregate. La normalizzazione exchange-specific dei dati +spetta al chiamante (es. mcp-deribit costruisce le legs da chain + ticker). +""" +from __future__ import annotations + +from typing import Any + +# Convention dealer gamma: i dealer sono SHORT calls (le vendono al retail) e +# LONG puts. Quando spot sale e dealer sono short calls, comprano underlying +# (positive feedback → vol amplificata). Quando spot scende e dealer long puts, +# vendono underlying (positive feedback). Net dealer gamma negativo → mercato +# instabile (squeeze in entrambe le direzioni). + + +def oi_weighted_skew(legs: list[dict[str, Any]]) -> dict[str, float | int | None]: + """Skew aggregato pesato per OI: IV media puts - IV media calls. + Positivo = puts richer (paura), negativo = calls richer (greed). + """ + call_num = call_den = 0.0 + put_num = put_den = 0.0 + for leg in legs: + iv = leg.get("iv") + oi = leg.get("oi") or 0 + if iv is None or oi <= 0: + continue + if leg.get("option_type") == "call": + call_num += iv * oi + call_den += oi + elif leg.get("option_type") == "put": + put_num += iv * oi + put_den += oi + call_iv = call_num / call_den if call_den > 0 else None + put_iv = put_num / put_den if put_den > 0 else None + skew = (put_iv - call_iv) if (call_iv is not None and put_iv is not None) else None + return { + "skew": skew, + "call_iv_weighted": call_iv, + "put_iv_weighted": put_iv, + "total_oi": int(call_den + put_den), + } + + +def smile_asymmetry(legs: list[dict[str, Any]], spot: float) -> dict[str, float | None]: + """Smile asymmetry: differenza media IV otm puts vs otm calls a parità + di moneyness. Positivo = put-side richer (skew negativo classico equity). + """ + if spot <= 0 or not legs: + return {"atm_iv": None, "asymmetry": None, "otm_put_iv": None, "otm_call_iv": None} + + # ATM IV: media IV strike entro ±1% da spot + atm_ivs = [leg["iv"] for leg in legs if leg.get("iv") is not None and abs(leg.get("strike", 0) - spot) / spot < 0.01] + atm_iv = sum(atm_ivs) / len(atm_ivs) if atm_ivs else None + + otm_put_ivs = [ + leg["iv"] for leg in legs + if leg.get("iv") is not None and leg.get("option_type") == "put" and leg.get("strike", 0) < spot * 0.95 + ] + otm_call_ivs = [ + leg["iv"] for leg in legs + if leg.get("iv") is not None and leg.get("option_type") == "call" and leg.get("strike", 0) > spot * 1.05 + ] + otm_put = sum(otm_put_ivs) / len(otm_put_ivs) if otm_put_ivs else None + otm_call = sum(otm_call_ivs) / len(otm_call_ivs) if otm_call_ivs else None + asym = (otm_put - otm_call) if (otm_put is not None and otm_call is not None) else None + return { + "atm_iv": atm_iv, + "asymmetry": asym, + "otm_put_iv": otm_put, + "otm_call_iv": otm_call, + } + + +def atm_vs_wings_vol(legs: list[dict[str, Any]], spot: float) -> dict[str, float | None]: + """IV ATM vs IV alle ali 25-delta. Wing richness > 0 → smile (kurtosis vol). + """ + if not legs: + return {"atm_iv": None, "wing_25d_call_iv": None, "wing_25d_put_iv": None, "wing_richness": None} + + def _closest(target_delta: float, opt_type: str, tol: float = 0.1) -> float | None: + best = None + best_dist = float("inf") + for leg in legs: + d = leg.get("delta") + iv = leg.get("iv") + if d is None or iv is None or leg.get("option_type") != opt_type: + continue + dist = abs(abs(d) - abs(target_delta)) + if dist < best_dist: + best_dist = dist + best = iv + return best if best_dist <= tol else None + + # ATM IV: leg con delta più vicino a 0.5 (call) o -0.5 (put) + atm_call_iv = _closest(0.5, "call") + atm_put_iv = _closest(-0.5, "put") + atm_ivs = [v for v in (atm_call_iv, atm_put_iv) if v is not None] + atm_iv = sum(atm_ivs) / len(atm_ivs) if atm_ivs else None + + wing_call = _closest(0.25, "call") + wing_put = _closest(-0.25, "put") + wing_avg = None + if wing_call is not None and wing_put is not None: + wing_avg = (wing_call + wing_put) / 2 + + richness = (wing_avg - atm_iv) if (wing_avg is not None and atm_iv is not None) else None + return { + "atm_iv": atm_iv, + "wing_25d_call_iv": wing_call, + "wing_25d_put_iv": wing_put, + "wing_richness": richness, + } + + +def dealer_gamma_profile( + legs: list[dict[str, Any]], + spot: float, +) -> dict[str, Any]: + """Net dealer gamma per strike (assume dealer short calls + long puts). + Restituisce per strike: call_dealer_gamma (negativo), put_dealer_gamma + (positivo), net. Aggregato totale + zero-cross strike (gamma flip). + """ + by_strike: dict[float, dict[str, float]] = {} + for leg in legs: + strike = leg.get("strike") + gamma = leg.get("gamma") + oi = leg.get("oi") or 0 + if strike is None or gamma is None or oi <= 0 or spot <= 0: + continue + contrib = float(gamma) * oi * (spot ** 2) * 0.01 + entry = by_strike.setdefault( + float(strike), + {"strike": float(strike), "call_dealer_gamma": 0.0, "put_dealer_gamma": 0.0}, + ) + if leg.get("option_type") == "call": + entry["call_dealer_gamma"] -= contrib # dealer short calls + elif leg.get("option_type") == "put": + entry["put_dealer_gamma"] += contrib # dealer long puts + + rows: list[dict[str, float]] = [] + for s in sorted(by_strike.keys()): + e = by_strike[s] + e["net_dealer_gamma"] = e["call_dealer_gamma"] + e["put_dealer_gamma"] + rows.append(e) + + flip_level = None + for a, b in zip(rows, rows[1:], strict=False): + if (a["net_dealer_gamma"] < 0 <= b["net_dealer_gamma"]) or ( + a["net_dealer_gamma"] > 0 >= b["net_dealer_gamma"] + ): + denom = b["net_dealer_gamma"] - a["net_dealer_gamma"] + if denom != 0: + frac = -a["net_dealer_gamma"] / denom + flip_level = round(a["strike"] + frac * (b["strike"] - a["strike"]), 2) + break + + total = sum(r["net_dealer_gamma"] for r in rows) + return { + "by_strike": [ + { + "strike": r["strike"], + "call_dealer_gamma": round(r["call_dealer_gamma"], 2), + "put_dealer_gamma": round(r["put_dealer_gamma"], 2), + "net_dealer_gamma": round(r["net_dealer_gamma"], 2), + } + for r in rows + ], + "total_net_dealer_gamma": round(total, 2), + "gamma_flip_level": flip_level, + } + + +def vanna_charm_aggregate( + legs: list[dict[str, Any]], + spot: float, +) -> dict[str, Any]: + """Vanna (∂delta/∂IV) e Charm (∂delta/∂t) aggregati pesati per OI. + Vanna positiva → IV up, calls hedge buys; charm negativa → time decay + pushes delta down (calls only). + """ + total_vanna = 0.0 + total_charm = 0.0 + legs_used = 0 + for leg in legs: + vanna = leg.get("vanna") + charm = leg.get("charm") + oi = leg.get("oi") or 0 + if vanna is None or charm is None or oi <= 0: + continue + sign = 1 if leg.get("option_type") == "call" else -1 + total_vanna += float(vanna) * oi * sign + total_charm += float(charm) * oi * sign + legs_used += 1 + return { + "total_vanna": total_vanna, + "total_charm": total_charm, + "legs_analyzed": legs_used, + "spot": spot, + } diff --git a/src/cerbero_mcp/common/stats.py b/src/cerbero_mcp/common/stats.py new file mode 100644 index 0000000..79c1de3 --- /dev/null +++ b/src/cerbero_mcp/common/stats.py @@ -0,0 +1,96 @@ +"""Test statistici puri (cointegration, ADF, half-life già in indicators). +Nessuna dipendenza esterna: pure-Python. +""" +from __future__ import annotations + +import math + + +def _ols_slope_intercept(xs: list[float], ys: list[float]) -> tuple[float, float] | None: + if len(xs) != len(ys) or len(xs) < 3: + return None + n = len(xs) + mx = sum(xs) / n + my = sum(ys) / n + num = sum((xs[i] - mx) * (ys[i] - my) for i in range(n)) + den = sum((xs[i] - mx) ** 2 for i in range(n)) + if den == 0: + return None + slope = num / den + intercept = my - slope * mx + return slope, intercept + + +def _adf_t_stat(series: list[float]) -> float | None: + """Augmented Dickey-Fuller test stat semplificato (lag=0 → DF): + Δy_t = a + b*y_{t-1} + eps. t-stat di b vs zero. + Più negativo = più stazionario. Approssimazione: critical value ~ -2.86 al 5%. + """ + if len(series) < 30: + return None + y_lag = series[:-1] + delta = [series[i] - series[i - 1] for i in range(1, len(series))] + res = _ols_slope_intercept(y_lag, delta) + if res is None: + return None + b, a = res + n = len(y_lag) + mx = sum(y_lag) / n + den = sum((x - mx) ** 2 for x in y_lag) + if den == 0: + return None + fitted = [a + b * y_lag[i] for i in range(n)] + resid = [delta[i] - fitted[i] for i in range(n)] + rss = sum(r * r for r in resid) + if n - 2 <= 0: + return None + sigma2 = rss / (n - 2) + se_b = math.sqrt(sigma2 / den) + if se_b == 0: + return None + return b / se_b + + +def cointegration_test( + series_a: list[float], + series_b: list[float], + significance_t: float = -2.86, +) -> dict[str, float | bool | None]: + """Engle-Granger cointegration: + 1. OLS: y_t = alpha + beta * x_t + eps + 2. ADF su residui: se t-stat < critical (-2.86 @ 5%) → cointegrate. + """ + if len(series_a) != len(series_b) or len(series_a) < 50: + return { + "cointegrated": None, + "beta": None, + "alpha": None, + "adf_t_stat": None, + "spread_mean": None, + "spread_std": None, + } + res = _ols_slope_intercept(series_b, series_a) + if res is None: + return { + "cointegrated": None, + "beta": None, + "alpha": None, + "adf_t_stat": None, + "spread_mean": None, + "spread_std": None, + } + beta, alpha = res + spread = [series_a[i] - alpha - beta * series_b[i] for i in range(len(series_a))] + t_stat = _adf_t_stat(spread) + cointegrated = (t_stat is not None and t_stat < significance_t) + n = len(spread) + mean = sum(spread) / n + var = sum((s - mean) ** 2 for s in spread) / (n - 1) if n > 1 else 0.0 + return { + "cointegrated": cointegrated, + "beta": beta, + "alpha": alpha, + "adf_t_stat": t_stat, + "spread_mean": mean, + "spread_std": math.sqrt(var), + }