"""VaR service — Black-Scholes delta approach with numpy/scipy (no numba dependency).""" from __future__ import annotations import numpy as np import pandas as pd from scipy.stats import norm from datetime import datetime, timedelta from typing import List, Dict from .database import get_conn # ─── Option strategy → (type, directional multiplier) ─────────────────────── def _parse_strategy(strategy: str) -> tuple[str, float]: """Return (option_type, direction_sign) from strategy name.""" s = strategy.lower() if "straddle" in s or "strangle" in s: return "straddle", (1.0 if "long" in s else -1.0) if "iron condor" in s or "butterfly" in s or "neutral" in s: return "neutral", 0.0 if "bull" in s: return "call", 0.5 if "bear" in s: return "put", -0.5 if "call" in s: return "call", (1.0 if "long" in s else -1.0) if "put" in s: return "put", (1.0 if "long" in s else -1.0) return "call", 0.5 # default def _bs_delta(S: float, K: float, T_days: float, sigma: float, opt_type: str, direction: float) -> float: """Black-Scholes delta, direction-adjusted.""" r = 0.05 T = max(T_days, 1) / 252.0 d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) if opt_type == "call": raw = float(norm.cdf(d1)) elif opt_type == "put": raw = float(norm.cdf(d1) - 1.0) elif opt_type == "straddle": # Long straddle: net delta ≈ 0 ATM; represent as small residual raw = float(norm.cdf(d1) + (norm.cdf(d1) - 1.0)) # ≈ 0 else: raw = 0.0 return raw * direction # ─── Market data ───────────────────────────────────────────────────────────── def _fetch_returns(tickers: List[str], lookback: int) -> pd.DataFrame: """Download historical daily returns via yfinance. Returns {} on failure.""" valid = [t for t in tickers if ":" not in t] if not valid: return pd.DataFrame() try: import yfinance as yf end = datetime.now() start = end - timedelta(days=lookback + 60) raw = yf.download(valid, start=start, end=end, progress=False, auto_adjust=True) if raw.empty: return pd.DataFrame() close = raw["Close"] if len(valid) > 1 else raw[["Close"]].rename(columns={"Close": valid[0]}) return close.pct_change().dropna().tail(lookback) except Exception: return pd.DataFrame() def _synthetic_returns(tickers: List[str], lookback: int, seed: int = 42) -> pd.DataFrame: """Fallback: simulate realistic returns when market data unavailable.""" rng = np.random.default_rng(seed) idx = pd.date_range(end=datetime.now(), periods=lookback, freq="B") data = {t: rng.normal(0.0002, 0.018, lookback) for t in tickers} return pd.DataFrame(data, index=idx) # ─── Core VaR computation ──────────────────────────────────────────────────── def compute_var( confidence: float = 0.95, horizon_days: int = 1, lookback_days: int = 252, default_iv: float = 0.20, ) -> Dict: conn = get_conn() rows = conn.execute( "SELECT underlying, strategy, entry_price, capital_invested, " "strike_guidance, expiry_days_at_entry, pattern_name " "FROM trade_entry_prices WHERE status = 'open'" ).fetchall() if not rows: return {"error": "Aucune position ouverte"} positions = [dict(r) for r in rows] # Filter positions usable for delta calc valid = [ p for p in positions if p.get("underlying") and ":" not in (p["underlying"] or "") and p.get("entry_price") and p["entry_price"] > 0 ] if not valid: return {"error": "Aucune position avec données de marché disponibles"} tickers = list({p["underlying"] for p in valid}) # Fetch or synthesize returns returns_df = _fetch_returns(tickers, lookback_days) data_source = "live" if returns_df.empty: returns_df = _synthetic_returns(tickers, lookback_days) data_source = "simulated" # Align to available history n = len(returns_df) # Build delta-weighted portfolio PnL series weighted_pnl = pd.Series(0.0, index=returns_df.index) total_notional = 0.0 pos_details = [] for p in valid: ticker = p["underlying"] if ticker not in returns_df.columns: continue S = float(p["entry_price"]) T = float(p.get("expiry_days_at_entry") or 60) capital = float(p.get("capital_invested") or S) strategy = p.get("strategy") or "Long Call" opt_type, direction = _parse_strategy(strategy) # Strike: ATM unless guidance specifies otherwise K = S delta = _bs_delta(S, K, T, default_iv, opt_type, direction) weighted_pnl += returns_df[ticker] * delta * capital total_notional += capital pos_details.append({ "ticker": ticker, "pattern": p.get("pattern_name") or "", "strategy": strategy, "delta": round(delta, 4), "notional": round(capital, 2), }) if total_notional == 0: return {"error": "Notionnel total nul"} portfolio_pnl = (weighted_pnl / total_notional).dropna() pnl = portfolio_pnl.values.astype(float) alpha = 1.0 - confidence # ── Historical VaR ── hist_var_1d = float(np.percentile(pnl, alpha * 100)) hist_var_nd = hist_var_1d * np.sqrt(horizon_days) tail = pnl[pnl <= hist_var_1d] hist_cvar = float(np.mean(tail)) if len(tail) > 0 else hist_var_1d # ── Parametric VaR (Gaussian) ── mu = float(np.mean(pnl)) sigma = float(np.std(pnl)) z = float(norm.ppf(alpha)) param_var_1d = mu + z * sigma param_var_nd = param_var_1d * np.sqrt(horizon_days) # ES closed-form: μ − σ·φ(z)/α param_cvar = mu - sigma * norm.pdf(-z) / alpha # ── Monte Carlo (stressed: vol × 1.5) ── rng = np.random.default_rng(42) stressed_sigma = sigma * 1.5 mc_draws = rng.normal(mu, stressed_sigma, 10_000) mc_var_1d = float(np.percentile(mc_draws, alpha * 100)) mc_var_nd = mc_var_1d * np.sqrt(horizon_days) mc_tail = mc_draws[mc_draws <= mc_var_1d] mc_cvar = float(np.mean(mc_tail)) if len(mc_tail) > 0 else mc_var_1d # ── Rolling 30-day Historical VaR ── rolling_var = [] for i in range(30, n): w = pnl[i - 30:i] rolling_var.append({ "date": portfolio_pnl.index[i].strftime("%Y-%m-%d"), "var_95": round(float(np.percentile(w, 5)) * 100, 4), }) rolling_var = rolling_var[-90:] # last 90 data points max # ── Returns histogram ── counts, edges = np.histogram(pnl * 100, bins=30) histogram = [ {"x": round(float((edges[i] + edges[i + 1]) / 2), 4), "count": int(counts[i])} for i in range(len(counts)) ] # ── Backtest (Kupiec test) ── n_breaches = int(np.sum(pnl < hist_var_1d)) breach_rate = round(n_breaches / n * 100, 2) if n > 0 else 0.0 # ── VaR in EUR (based on total notional) ── def pct_to_eur(pct_val: float) -> float: return round(pct_val / 100 * total_notional, 2) return { "var": { "historical": { "var_1d_pct": round(hist_var_1d * 100, 3), "var_nd_pct": round(hist_var_nd * 100, 3), "cvar_pct": round(hist_cvar * 100, 3), "var_1d_eur": pct_to_eur(hist_var_1d * 100), "var_nd_eur": pct_to_eur(hist_var_nd * 100), "cvar_eur": pct_to_eur(hist_cvar * 100), }, "parametric": { "var_1d_pct": round(param_var_1d * 100, 3), "var_nd_pct": round(param_var_nd * 100, 3), "cvar_pct": round(param_cvar * 100, 3), "var_1d_eur": pct_to_eur(param_var_1d * 100), "var_nd_eur": pct_to_eur(param_var_nd * 100), "cvar_eur": pct_to_eur(param_cvar * 100), }, "monte_carlo": { "var_1d_pct": round(mc_var_1d * 100, 3), "var_nd_pct": round(mc_var_nd * 100, 3), "cvar_pct": round(mc_cvar * 100, 3), "var_1d_eur": pct_to_eur(mc_var_1d * 100), "var_nd_eur": pct_to_eur(mc_var_nd * 100), "cvar_eur": pct_to_eur(mc_cvar * 100), "stressed": True, }, }, "portfolio": { "total_notional_eur": round(total_notional, 2), "n_positions": len(pos_details), "horizon_days": horizon_days, "confidence_pct": round(confidence * 100, 1), "lookback_days": n, "data_source": data_source, }, "positions": pos_details, "rolling_var": rolling_var, "histogram": histogram, "backtest": { "n_observations": n, "n_breaches": n_breaches, "breach_rate_pct": breach_rate, "expected_breach_rate_pct": round(alpha * 100, 1), "kupiec_ok": breach_rate <= alpha * 100 * 2, }, }