Files
OpenFin/backend/services/var_service.py
2026-06-20 09:08:54 +02:00

576 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}