"""VaR service — Black-Scholes delta approach with numpy/scipy (no numba dependency).""" from __future__ import annotations import json import numpy as np import pandas as pd from scipy.stats import norm from datetime import datetime, timedelta from typing import List, Dict, Optional 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.""" from .database import _normalize_ticker valid = [t for t in tickers if ":" not in t] if not valid: return pd.DataFrame() # Map raw → yfinance ticker; keep reverse map for column rename yf_map = {t: _normalize_ticker(t) for t in valid} yf_tickers = list(yf_map.values()) reverse = {v: k for k, v in yf_map.items()} try: import yfinance as yf end = datetime.now() start = end - timedelta(days=lookback + 60) raw = yf.download(yf_tickers, start=start, end=end, progress=False, auto_adjust=True) if raw.empty: return pd.DataFrame() close = raw["Close"] if len(yf_tickers) > 1 else raw[["Close"]].rename(columns={"Close": yf_tickers[0]}) # Rename yfinance tickers back to original close = close.rename(columns=reverse) 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, }, } def save_var_snapshot(result: Dict, confidence: float, horizon_days: int, lookback_days: int, default_iv: float, macro_regime: Optional[str] = None, ticker_prices: Optional[str] = None) -> int: """Persist a VaR result dict to var_snapshots. Returns new row id.""" if "error" in result: raise ValueError(result["error"]) v = result["var"] p = result["portfolio"] bt = result.get("backtest", {}) computed_at = datetime.utcnow().isoformat(timespec="seconds") conn = get_conn() cur = conn.execute( """INSERT INTO var_snapshots (computed_at, confidence, horizon_days, lookback_days, default_iv, hist_var_1d_pct, hist_cvar_pct, hist_var_1d_eur, param_var_1d_pct, param_cvar_pct, mc_var_1d_pct, mc_cvar_pct, n_positions, total_notional_eur, data_source, breach_rate_pct, kupiec_ok, macro_regime, ticker_prices, full_result) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( computed_at, confidence, horizon_days, lookback_days, default_iv, v["historical"]["var_1d_pct"], v["historical"]["cvar_pct"], v["historical"]["var_1d_eur"], v["parametric"]["var_1d_pct"], v["parametric"]["cvar_pct"], v["monte_carlo"]["var_1d_pct"], v["monte_carlo"]["cvar_pct"], p["n_positions"], p["total_notional_eur"], p["data_source"], bt.get("breach_rate_pct"), 1 if bt.get("kupiec_ok") else 0, macro_regime, ticker_prices, json.dumps(result, ensure_ascii=False), ) ) conn.commit() row_id = cur.lastrowid conn.close() return row_id def get_var_snapshots(limit: int = 20) -> List[Dict]: """Return most recent VaR snapshots (summary, no full_result).""" conn = get_conn() rows = conn.execute( """SELECT id, computed_at, confidence, horizon_days, lookback_days, hist_var_1d_pct, hist_cvar_pct, hist_var_1d_eur, param_var_1d_pct, mc_var_1d_pct, n_positions, total_notional_eur, data_source, breach_rate_pct, kupiec_ok FROM var_snapshots ORDER BY computed_at DESC LIMIT ?""", (limit,) ).fetchall() conn.close() return [dict(r) for r in rows] def get_var_snapshot(snapshot_id: int) -> Optional[Dict]: """Return a single snapshot with full_result parsed.""" conn = get_conn() row = conn.execute( "SELECT * FROM var_snapshots WHERE id=?", (snapshot_id,) ).fetchone() conn.close() if not row: return None d = dict(row) if d.get("full_result"): try: d["full_result"] = json.loads(d["full_result"]) except Exception: pass return d def get_latest_var_snapshot() -> Optional[Dict]: """Return the most recent snapshot with full result.""" conn = get_conn() row = conn.execute( "SELECT * FROM var_snapshots ORDER BY computed_at DESC LIMIT 1" ).fetchone() conn.close() if not row: return None d = dict(row) if d.get("full_result"): try: d["full_result"] = json.loads(d["full_result"]) except Exception: pass return d # ─── PnL snapshot ──────────────────────────────────────────────────────────── def save_pnl_snapshot() -> int: """Compute live PnL and persist to pnl_snapshots. Returns new row id.""" from .database import _fetch_live_prices conn = get_conn() rows = conn.execute( "SELECT * FROM trade_entry_prices WHERE status = 'open'" ).fetchall() trades = [dict(r) for r in rows] closed_count = conn.execute( "SELECT COUNT(*) FROM trade_entry_prices WHERE status = 'closed'" ).fetchone()[0] # Fetch live prices tickers = list({t["underlying"] for t in trades if t.get("underlying") and ":" not in (t["underlying"] or "")}) prices: Dict = {} if tickers: try: prices = _fetch_live_prices(tickers, timeout=15) or {} except Exception: pass BEARISH = {"long put", "bear put spread", "short call", "put"} def is_bearish(strategy: str) -> bool: return any(k in strategy.lower() for k in BEARISH) enriched = [] total_capital = 0.0 total_pnl_eur = 0.0 for t in trades: entry = t.get("entry_price") or 0.0 capital = t.get("capital_invested") or entry current = prices.get(t.get("underlying") or "") pnl_pct = None if entry and current and entry > 0: raw = (current - entry) / entry * 100 pnl_pct = round(-raw if is_bearish(t.get("strategy") or "") else raw, 2) pnl_eur = round(capital * (pnl_pct / 100), 2) if pnl_pct is not None and capital else None total_capital += capital if pnl_eur is not None: total_pnl_eur += pnl_eur enriched.append({ "id": t["id"], "ticker": t.get("underlying"), "strategy": t.get("strategy"), "entry_price": entry, "current_price": current, "pnl_pct": pnl_pct, "pnl_eur": pnl_eur, "capital_invested": capital, }) total_pnl_pct = round(total_pnl_eur / total_capital * 100, 3) if total_capital > 0 else 0.0 # Macro regime snapshot macro_row = conn.execute( "SELECT dominant, scores_json FROM macro_regime_history ORDER BY timestamp DESC LIMIT 1" ).fetchone() macro_context = json.dumps(dict(macro_row)) if macro_row else None snapped_at = datetime.utcnow().isoformat(timespec="seconds") cur = conn.execute( """INSERT INTO pnl_snapshots (snapped_at, n_open, n_closed, total_capital_eur, total_pnl_pct, total_pnl_eur, ticker_prices, macro_regime, trades_snapshot) VALUES (?,?,?,?,?,?,?,?,?)""", ( snapped_at, len(trades), closed_count, round(total_capital, 2), total_pnl_pct, round(total_pnl_eur, 2), json.dumps(prices, ensure_ascii=False), macro_context, json.dumps(enriched, ensure_ascii=False), ) ) conn.commit() row_id = cur.lastrowid conn.close() return row_id def get_pnl_snapshots(limit: int = 48) -> List[Dict]: conn = get_conn() rows = conn.execute( """SELECT id, snapped_at, n_open, n_closed, total_capital_eur, total_pnl_pct, total_pnl_eur FROM pnl_snapshots ORDER BY snapped_at DESC LIMIT ?""", (limit,) ).fetchall() conn.close() return [dict(r) for r in rows] def get_latest_pnl_snapshot() -> Optional[Dict]: conn = get_conn() row = conn.execute( "SELECT * FROM pnl_snapshots ORDER BY snapped_at DESC LIMIT 1" ).fetchone() conn.close() if not row: return None d = dict(row) for key in ("ticker_prices", "macro_regime", "trades_snapshot"): if d.get(key): try: d[key] = json.loads(d[key]) except Exception: pass return d def get_pnl_snapshot(snapshot_id: int) -> Optional[Dict]: conn = get_conn() row = conn.execute("SELECT * FROM pnl_snapshots WHERE id=?", (snapshot_id,)).fetchone() conn.close() if not row: return None d = dict(row) for key in ("ticker_prices", "macro_regime", "trades_snapshot"): if d.get(key): try: d[key] = json.loads(d[key]) except Exception: pass return d def diff_pnl_snapshots(id_a: int, id_b: int) -> Dict: """Compute position-level diff between two PnL snapshots (a=earlier, b=later).""" snap_a = get_pnl_snapshot(id_a) snap_b = get_pnl_snapshot(id_b) if not snap_a: raise ValueError(f"Snapshot {id_a} introuvable") if not snap_b: raise ValueError(f"Snapshot {id_b} introuvable") trades_a: Dict[int, Dict] = {t["id"]: t for t in (snap_a.get("trades_snapshot") or [])} trades_b: Dict[int, Dict] = {t["id"]: t for t in (snap_b.get("trades_snapshot") or [])} ids_a = set(trades_a) ids_b = set(trades_b) new_positions = [] # in B not A closed_positions = [] # in A not B changed_positions = [] # in both — with delta for tid in ids_b - ids_a: t = trades_b[tid] new_positions.append({**t, "change_type": "new"}) for tid in ids_a - ids_b: t = trades_a[tid] closed_positions.append({**t, "change_type": "closed"}) for tid in ids_a & ids_b: ta, tb = trades_a[tid], trades_b[tid] pnl_a = ta.get("pnl_pct") or 0.0 pnl_b = tb.get("pnl_pct") or 0.0 delta_pct = round(pnl_b - pnl_a, 3) pnl_eur_a = ta.get("pnl_eur") or 0.0 pnl_eur_b = tb.get("pnl_eur") or 0.0 delta_eur = round(pnl_eur_b - pnl_eur_a, 2) changed_positions.append({ **tb, "pnl_pct_a": pnl_a, "pnl_pct_b": pnl_b, "delta_pct": delta_pct, "pnl_eur_a": pnl_eur_a, "pnl_eur_b": pnl_eur_b, "delta_eur": delta_eur, "change_type": "changed", }) # Sort changed by abs delta descending changed_positions.sort(key=lambda x: abs(x["delta_pct"]), reverse=True) # Portfolio-level delta cap_a = snap_a.get("total_capital_eur") or 0.0 cap_b = snap_b.get("total_capital_eur") or 0.0 pnl_pct_a = snap_a.get("total_pnl_pct") or 0.0 pnl_pct_b = snap_b.get("total_pnl_pct") or 0.0 pnl_eur_a = snap_a.get("total_pnl_eur") or 0.0 pnl_eur_b = snap_b.get("total_pnl_eur") or 0.0 # Macro regime diff regime_a = (snap_a.get("macro_regime") or {}).get("dominant") if isinstance(snap_a.get("macro_regime"), dict) else None regime_b = (snap_b.get("macro_regime") or {}).get("dominant") if isinstance(snap_b.get("macro_regime"), dict) else None return { "snapshot_a": { "id": snap_a["id"], "snapped_at": snap_a["snapped_at"], "n_open": snap_a.get("n_open"), "total_capital_eur": cap_a, "total_pnl_pct": pnl_pct_a, "total_pnl_eur": pnl_eur_a, "macro_regime": regime_a, }, "snapshot_b": { "id": snap_b["id"], "snapped_at": snap_b["snapped_at"], "n_open": snap_b.get("n_open"), "total_capital_eur": cap_b, "total_pnl_pct": pnl_pct_b, "total_pnl_eur": pnl_eur_b, "macro_regime": regime_b, }, "portfolio_delta": { "capital_delta_eur": round(cap_b - cap_a, 2), "pnl_pct_delta": round(pnl_pct_b - pnl_pct_a, 3), "pnl_eur_delta": round(pnl_eur_b - pnl_eur_a, 2), "positions_opened": len(new_positions), "positions_closed": len(closed_positions), "positions_unchanged": sum(1 for p in changed_positions if p["delta_pct"] == 0), }, "new_positions": new_positions, "closed_positions": closed_positions, "changed_positions": changed_positions, }