feat: Phase 3 — Portfolio Risk Engine (Exposition, Clusters, Kelly, Risk Dashboard)

Sprint 3.1 — Vue Portefeuille Consolidée
- database.py: get_portfolio_exposure() — exposition par classe d'actif + facteur de risque
- database.py: get_pnl_timeline() — courbe P&L cumulé pour equity curve
- Alertes concentration automatiques (>40% par classe, >50% par facteur)
- _RISK_FACTOR_MAP: classification géopolitique/inflation/récession/liquidité/dollar

Sprint 3.2 — Risk Cluster Engine
- database.py: get_risk_clusters() — saturation par facteur + risk_prompt_context
- database.py: get_pattern_correlations() — matrice Pearson sur trades matures
- auto_cycle.py: injection du contexte risque dans le prompt de scoring (Step 3.5)
- ai_analyzer.py: paramètre risk_context dans score_patterns_with_context()
- Pénalisation automatique des patterns sur facteurs saturés dans le scoring GPT

Sprint 3.3 — Position Sizing Kelly Fractionnel
- database.py: compute_kelly_sizing() — f* = (p×G - (1-p))/G, Kelly ×33% par défaut
- Ajustement cluster: sizing ÷2 si facteur saturé
- Ajustement fiabilité: sizing ÷2 si win_rate historique <40% (≥5 trades)
- JournalDeBord.tsx: colonne "Kelly" avec KellyCell (% + €, ajustements signalés)
- routers/risk.py: GET /api/risk/kelly/{pattern_id}

Sprint 3.4 — Tableau de Bord Risque Global
- database.py: get_risk_dashboard() — HHI, score diversification, drawdown attendu, recommandation
- database.py: _build_risk_recommendation() — alerte Risk Committee automatique
- RiskDashboard.tsx: nouvelle page — jauges concentration, courbe P&L, corrélations, recommandation
- Dashboard.tsx: banner d'alerte concentration sur le Cockpit avec lien vers /risk
- routers/risk.py: GET /api/risk/exposure|timeline|clusters|correlations|dashboard
- App.tsx + Sidebar.tsx: route /risk + entrée menu Risk Dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-17 17:18:36 +02:00
parent f09c5b8ee7
commit e44c8799b9
11 changed files with 962 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router
from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router, risk as risk_router
from services.database import init_db, get_config, cleanup_stale_running_cycles
import os
import uvicorn
@@ -73,6 +73,7 @@ app.include_router(reasoning_router.router)
app.include_router(knowledge_router.router)
app.include_router(options_vol_router.router)
app.include_router(analytics_router.router)
app.include_router(risk_router.router)
@app.get("/")

51
backend/routers/risk.py Normal file
View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Query
from services.database import (
get_portfolio_exposure,
get_pnl_timeline,
get_risk_clusters,
get_pattern_correlations,
compute_kelly_sizing,
get_risk_dashboard,
)
router = APIRouter(prefix="/api/risk", tags=["risk"])
@router.get("/exposure")
def portfolio_exposure():
"""Exposure by asset class + risk factor for open positions."""
return get_portfolio_exposure()
@router.get("/timeline")
def pnl_timeline(days: int = Query(default=90, ge=7, le=365)):
"""Daily aggregated P&L timeline for equity curve."""
return {"timeline": get_pnl_timeline(days=days)}
@router.get("/clusters")
def risk_clusters():
"""Risk factor clustering + saturation detection + prompt context."""
return get_risk_clusters()
@router.get("/correlations")
def pattern_correlations():
"""Pearson correlation matrix between patterns (mature trades only)."""
return get_pattern_correlations()
@router.get("/kelly/{pattern_id}")
def kelly_sizing(
pattern_id: str,
capital: float = Query(default=10000.0, ge=100, le=10_000_000),
fractional: float = Query(default=0.33, ge=0.1, le=1.0),
):
"""Fractional Kelly position sizing for a pattern."""
return compute_kelly_sizing(pattern_id=pattern_id, capital_available=capital, fractional=fractional)
@router.get("/dashboard")
def risk_dashboard():
"""Full portfolio risk snapshot: concentration, diversification, expected drawdown, recommendation."""
return get_risk_dashboard()

View File

@@ -327,8 +327,9 @@ def score_patterns_with_context(
macro_regime: Optional[Dict] = None,
portfolio_lessons: Optional[Dict] = None,
iv_context: str = "",
risk_context: str = "",
) -> List[Dict[str, Any]]:
"""Score all patterns with rich context (news, prices, IV) using GPT-4o."""
"""Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o."""
if not get_client():
return []
@@ -609,6 +610,7 @@ Leçons : {' | '.join(str(l)[:80] for l in lessons[:3])}
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
- Top risques: {geo_score.get('top_risks', [])}
{macro_section}{lessons_header}{iv_context}
{risk_context}
TEMPLATE DE NOTATION:
{scoring_template}

View File

@@ -280,7 +280,22 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
summary["patterns_added"] = added_count
# ── Step 3.5: Collect IV context ─────────────────────────────────────
# ── Step 3.5: Collect risk cluster context ───────────────────────────
risk_cluster_context = ""
try:
from services.database import get_risk_clusters as _grc
_clusters = _grc()
risk_cluster_context = _clusters.get("risk_prompt_context", "")
if risk_cluster_context:
logger.info(
f"[Cycle {run_id[:16]}] Risk clusters: "
f"{len(_clusters.get('clusters', []))} facteurs, "
f"saturés={_clusters.get('saturated_factors', [])}"
)
except Exception as _re:
logger.warning(f"[Cycle] Risk cluster context failed (non-blocking): {_re}")
# ── Step 3.6: Collect IV context ─────────────────────────────────────
iv_context = ""
try:
from services.iv_engine import get_iv_context_for_prompt, IV_WATCHLIST
@@ -320,6 +335,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
macro_regime=macro_regime,
portfolio_lessons=portfolio_lessons,
iv_context=iv_context,
risk_context=risk_cluster_context,
)
scored_with_id = [s for s in scored if s.get("pattern_id")]
scored_without_id = [s for s in scored if not s.get("pattern_id")]

View File

@@ -1795,3 +1795,504 @@ def get_calibration_data(days: int = 365) -> Dict:
),
}
# ╔══════════════════════════════════════════════════════════════════════════════╗
# ║ PHASE 3 — Portfolio Risk Engine ║
# ╚══════════════════════════════════════════════════════════════════════════════╝
# Risk factor → (asset_classes, trigger_keywords)
_RISK_FACTOR_MAP = {
"géopolitique": {
"asset_classes": {"energy", "metals", "agriculture"},
"triggers": {"military", "sanctions", "trade_war", "political_speech"},
},
"inflation": {
"asset_classes": {"energy", "metals", "agriculture", "forex"},
"triggers": {"energy", "resource_scarcity", "trade_war"},
},
"récession": {
"asset_classes": {"indices", "equities", "rates"},
"triggers": {"financial_crisis", "elections"},
},
"liquidité": {
"asset_classes": {"indices", "equities", "rates"},
"triggers": {"financial_crisis", "health_crisis"},
},
"dollar": {
"asset_classes": {"forex", "metals"},
"triggers": {"sanctions", "financial_crisis", "trade_war"},
},
}
def _classify_risk_factors(asset_class: str, triggers: List[str]) -> List[str]:
"""Return list of risk factors for a trade given its asset_class and pattern triggers."""
ac = (asset_class or "").lower()
trg_set = {t.lower() for t in (triggers or [])}
factors = []
for factor, cfg in _RISK_FACTOR_MAP.items():
if ac in cfg["asset_classes"] or trg_set & cfg["triggers"]:
factors.append(factor)
return factors or ["autre"]
# ── Sprint 3.1 — Portfolio Exposure ──────────────────────────────────────────
def get_portfolio_exposure() -> Dict:
"""
Returns exposure by asset class and by risk factor for open positions,
plus P&L timeline and concentration alerts.
"""
conn = get_conn()
trades = conn.execute(
"SELECT * FROM portfolio WHERE status='open'"
).fetchall()
conn.close()
by_class: Dict[str, Dict] = {}
by_factor: Dict[str, Dict] = {}
total_capital = 0.0
for row in trades:
t = dict(row)
ac = (t.get("asset_class") or "autre").lower()
cap = float(t.get("capital_invested") or 0)
total_capital += cap
if ac not in by_class:
by_class[ac] = {"capital": 0.0, "trade_count": 0, "trades": []}
by_class[ac]["capital"] += cap
by_class[ac]["trade_count"] += 1
by_class[ac]["trades"].append(t.get("id"))
# Pattern triggers for risk factor classification
triggers: List[str] = []
try:
conn2 = get_conn()
pat_row = conn2.execute(
"SELECT triggers FROM custom_patterns WHERE id=?",
(t.get("geo_trigger") or "",)
).fetchone()
conn2.close()
if pat_row and pat_row["triggers"]:
triggers = json.loads(pat_row["triggers"] or "[]")
except Exception:
pass
factors = _classify_risk_factors(ac, triggers)
for f in factors:
if f not in by_factor:
by_factor[f] = {"capital": 0.0, "trade_count": 0, "trades": []}
by_factor[f]["capital"] += cap
by_factor[f]["trade_count"] += 1
by_factor[f]["trades"].append(t.get("id"))
# Compute percentages + concentration alerts
alerts = []
for ac, info in by_class.items():
pct = round(info["capital"] / total_capital * 100, 1) if total_capital else 0
info["pct_of_portfolio"] = pct
if pct > 40:
alerts.append({
"type": "concentration_class",
"level": "high" if pct > 60 else "warning",
"message": f"Concentration élevée sur {ac.upper()}: {pct}% du capital",
"asset_class": ac,
"pct": pct,
})
for factor, info in by_factor.items():
pct = round(info["capital"] / total_capital * 100, 1) if total_capital else 0
info["pct_of_portfolio"] = pct
if pct > 50:
alerts.append({
"type": "concentration_factor",
"level": "high" if pct > 70 else "warning",
"message": f"Risque concentré sur facteur '{factor}': {pct}% du capital",
"factor": factor,
"pct": pct,
})
return {
"by_class": by_class,
"by_factor": by_factor,
"total_capital": total_capital,
"open_trade_count": len(trades),
"concentration_alerts": alerts,
}
def get_pnl_timeline(days: int = 90) -> List[Dict]:
"""
Returns daily aggregated P&L from trade_entry_prices (closed/mature trades).
Used for portfolio equity curve.
"""
conn = get_conn()
rows = conn.execute("""
SELECT entry_date, SUM(pnl_pct * capital_invested / 100) as daily_pnl_abs,
AVG(pnl_pct) as avg_pnl_pct, COUNT(*) as trade_count
FROM trade_entry_prices
WHERE pnl_pct IS NOT NULL AND entry_date >= date('now', ?)
GROUP BY entry_date
ORDER BY entry_date ASC
""", (f"-{days} days",)).fetchall()
conn.close()
cumulative = 0.0
result = []
for row in rows:
r = dict(row)
cumulative += r.get("daily_pnl_abs") or 0
r["cumulative_pnl_abs"] = round(cumulative, 2)
result.append(r)
return result
# ── Sprint 3.2 — Risk Cluster Engine ────────────────────────────────────────
def get_risk_clusters() -> Dict:
"""
Classify all open trades by risk factor, compute exposure per factor,
detect saturation (>50%), and return cluster data for the scoring prompt.
"""
exposure = get_portfolio_exposure()
by_factor = exposure["by_factor"]
total = exposure["total_capital"]
clusters = []
saturated_factors = []
for factor, info in by_factor.items():
pct = info.get("pct_of_portfolio", 0)
saturated = pct > 50
if saturated:
saturated_factors.append(factor)
clusters.append({
"factor": factor,
"capital": round(info["capital"], 2),
"pct_of_portfolio": pct,
"trade_count": info["trade_count"],
"saturated": saturated,
})
clusters.sort(key=lambda x: -x["pct_of_portfolio"])
return {
"clusters": clusters,
"saturated_factors": saturated_factors,
"total_capital": total,
"risk_prompt_context": _build_risk_cluster_prompt(clusters, saturated_factors),
}
def _build_risk_cluster_prompt(clusters: List[Dict], saturated: List[str]) -> str:
if not clusters:
return ""
lines = ["## ⚠ ÉTAT DU PORTEFEUILLE — Concentration des risques"]
for c in clusters[:5]:
sat_mark = " 🔴 SATURÉ" if c["saturated"] else ""
lines.append(f" - Facteur '{c['factor']}': {c['pct_of_portfolio']}% du capital ({c['trade_count']} trades){sat_mark}")
if saturated:
lines.append(
f"\n⚠ CONSIGNE SCORING: Les facteurs [{', '.join(saturated)}] sont SATURÉS. "
"Pénaliser de 15 points les patterns dépendants de ces facteurs. "
"Favoriser les patterns sur d'autres facteurs pour diversifier."
)
return "\n".join(lines)
def get_pattern_correlations() -> Dict:
"""
Compute Pearson correlation of P&L between pattern pairs with ≥3 mature trades each.
Returns correlation matrix and sorted pair list.
"""
import math
from datetime import date as _d
conn = get_conn()
rows = conn.execute("""
SELECT tep.pattern_id, tep.pnl_pct, tep.entry_date, tep.horizon_days,
cp.name as pattern_name
FROM trade_entry_prices tep
LEFT JOIN custom_patterns cp ON cp.id = tep.pattern_id
WHERE tep.pnl_pct IS NOT NULL
""").fetchall()
conn.close()
today = _d.today()
by_pattern: Dict[str, list] = {}
names: Dict[str, str] = {}
for row in rows:
r = dict(row)
pid = r["pattern_id"]
try:
entry = _d.fromisoformat(r["entry_date"])
days_held = (today - entry).days
except Exception:
days_held = 0
horizon = r.get("horizon_days") or 30
if days_held / horizon < 0.35:
continue # mature only
by_pattern.setdefault(pid, []).append(float(r["pnl_pct"]))
names[pid] = r.get("pattern_name") or pid
# Keep patterns with ≥3 trades
patterns = {pid: pnls for pid, pnls in by_pattern.items() if len(pnls) >= 3}
def pearson(a: list, b: list) -> Optional[float]:
n = min(len(a), len(b))
if n < 2:
return None
xa, xb = a[:n], b[:n]
ma, mb = sum(xa) / n, sum(xb) / n
num = sum((xa[i] - ma) * (xb[i] - mb) for i in range(n))
da = math.sqrt(sum((x - ma) ** 2 for x in xa))
db = math.sqrt(sum((x - mb) ** 2 for x in xb))
if da * db == 0:
return None
return round(num / (da * db), 3)
pids = list(patterns.keys())
pairs = []
matrix: Dict[str, Dict[str, Optional[float]]] = {}
for i, pa in enumerate(pids):
matrix[pa] = {}
for pb in pids:
if pa == pb:
matrix[pa][pb] = 1.0
else:
corr = pearson(patterns[pa], patterns[pb])
matrix[pa][pb] = corr
for pb in pids[i + 1:]:
corr = matrix[pa].get(pb)
if corr is not None:
pairs.append({
"pattern_a": pa,
"name_a": names.get(pa, pa),
"pattern_b": pb,
"name_b": names.get(pb, pb),
"correlation": corr,
"interpretation": (
"Très corrélés — risque concentré" if abs(corr) > 0.7
else "Modérément corrélés" if abs(corr) > 0.4
else "Faiblement corrélés"
),
})
pairs.sort(key=lambda x: -abs(x["correlation"]))
return {
"matrix": matrix,
"pairs": pairs[:20],
"pattern_names": names,
"pattern_count": len(pids),
}
# ── Sprint 3.3 — Kelly Fractional Sizing ────────────────────────────────────
def compute_kelly_sizing(
pattern_id: str,
capital_available: float = 10000.0,
fractional: float = 0.33,
) -> Dict:
"""
Compute fractional Kelly position sizing for a pattern.
f* = (p × G - (1-p)) / G where G = expected gain as multiplier.
Fractional Kelly = fractional × f* (default 33% = between 25-50% institutional norm).
Adjusted for risk cluster saturation.
"""
conn = get_conn()
pat = conn.execute("SELECT * FROM custom_patterns WHERE id=?", (pattern_id,)).fetchone()
conn.close()
if not pat:
return {"error": "Pattern not found"}
p = dict(pat)
prob = float(p.get("probability") or 0.5)
expected_move = float(p.get("expected_move_pct") or 50) / 100 # as decimal
# G = gain multiplier (if trade wins, return expected_move; if loses, -1)
G = max(expected_move, 0.01)
kelly_full = (prob * G - (1 - prob)) / G
kelly_full = max(0.0, kelly_full) # never negative
kelly_frac = kelly_full * fractional
# Risk cluster adjustment: halve if saturated
ac = (p.get("asset_class") or "").lower()
triggers_raw = p.get("triggers") or "[]"
try:
triggers_list = json.loads(triggers_raw) if isinstance(triggers_raw, str) else triggers_raw
except Exception:
triggers_list = []
factors = _classify_risk_factors(ac, triggers_list)
clusters = get_risk_clusters()
saturated = set(clusters.get("saturated_factors", []))
cluster_adjusted = kelly_frac
cluster_adjustment_reason = None
if factors and saturated & set(factors):
cluster_adjusted = kelly_frac / 2
cluster_adjustment_reason = f"Facteur {'|'.join(saturated & set(factors))} saturé → sizing ÷ 2"
suggested_capital = round(capital_available * cluster_adjusted, 2)
suggested_capital_display = min(suggested_capital, capital_available * 0.25) # hard cap 25%
# Reliability adjustment (if available)
reliability = get_pattern_reliability(pattern_id=pattern_id)
reliability_adjustment = None
if reliability:
rel = reliability[0]
if rel["trade_count"] >= 5 and rel["win_rate"] < 0.4:
suggested_capital_display *= 0.5
reliability_adjustment = f"Win rate historique faible ({rel['win_rate_pct']}%) → sizing ÷ 2"
return {
"pattern_id": pattern_id,
"pattern_name": p.get("name"),
"probability": prob,
"expected_move_pct": round(expected_move * 100, 1),
"kelly_full": round(kelly_full, 4),
"kelly_fractional": round(kelly_frac, 4),
"fractional_pct": round(fractional * 100),
"cluster_adjusted_kelly": round(cluster_adjusted, 4),
"risk_factors": factors,
"saturated_factors": list(saturated & set(factors)),
"cluster_adjustment_reason": cluster_adjustment_reason,
"reliability_adjustment": reliability_adjustment,
"suggested_capital_pct": round(cluster_adjusted * 100, 1),
"suggested_capital_eur": round(suggested_capital_display, 0),
"capital_available": capital_available,
"explanation": (
f"Kelly complet = {kelly_full*100:.1f}% → Kelly fractionnel ({fractional*100:.0f}%) "
f"= {kelly_frac*100:.1f}%"
+ (f" → Ajusté cluster = {cluster_adjusted*100:.1f}%" if cluster_adjustment_reason else "")
),
}
# ── Sprint 3.4 — Risk Dashboard ──────────────────────────────────────────────
def get_risk_dashboard() -> Dict:
"""
Full portfolio risk snapshot: concentration, diversification score,
expected drawdown estimate, and auto-recommendation.
"""
import math
exposure = get_portfolio_exposure()
clusters = get_risk_clusters()
corr_data = get_pattern_correlations()
by_class = exposure["by_class"]
by_factor = exposure["by_factor"]
total = exposure["total_capital"]
alerts = exposure["concentration_alerts"]
# Herfindahl-Hirschman Index (HHI) as concentration measure
# HHI = sum of (share_i)^2. HHI=1 fully concentrated, HHI=1/N fully diversified
shares = [info["pct_of_portfolio"] / 100 for info in by_class.values() if info.get("pct_of_portfolio")]
hhi = sum(s ** 2 for s in shares) if shares else 0
n_classes = len(shares)
hhi_min = 1 / n_classes if n_classes > 0 else 1
# Effective N = 1/HHI (Herfindahl diversity = N effective independent positions)
effective_n = round(1 / hhi, 2) if hhi > 0 else n_classes
max_possible_n = n_classes if n_classes > 0 else 1
diversification_score = round(min(effective_n / max(max_possible_n, 1), 1.0) * 100, 1)
# Expected drawdown estimate: weighted by concentration
# Simple model: if one factor is at X% and has 40% historical loss → max drawdown = X% × 40%
FACTOR_DRAWDOWN = {
"géopolitique": 0.40, # high volatility
"inflation": 0.30,
"récession": 0.45,
"liquidité": 0.35,
"dollar": 0.25,
"autre": 0.30,
}
expected_drawdown_pct = 0.0
for factor, info in by_factor.items():
w = info.get("pct_of_portfolio", 0) / 100
dd = FACTOR_DRAWDOWN.get(factor, 0.30)
expected_drawdown_pct += w * dd * 100
# High-correlation pairs count
high_corr_pairs = [p for p in corr_data.get("pairs", []) if abs(p["correlation"]) > 0.7]
# Auto-recommendation
recommendation = _build_risk_recommendation(
clusters.get("saturated_factors", []),
diversification_score,
round(expected_drawdown_pct, 1),
alerts,
high_corr_pairs,
)
return {
"exposure_by_class": {k: {**v, "pct_of_portfolio": v.get("pct_of_portfolio", 0)} for k, v in by_class.items()},
"exposure_by_factor": {k: {**v, "pct_of_portfolio": v.get("pct_of_portfolio", 0)} for k, v in by_factor.items()},
"total_capital": total,
"open_trades": exposure["open_trade_count"],
"hhi": round(hhi, 4),
"diversification_score": diversification_score,
"effective_n_positions": effective_n,
"expected_drawdown_pct": round(expected_drawdown_pct, 1),
"concentration_alerts": alerts,
"high_correlation_pairs": high_corr_pairs[:5],
"saturated_factors": clusters.get("saturated_factors", []),
"risk_clusters": clusters.get("clusters", []),
"recommendation": recommendation,
}
def _build_risk_recommendation(
saturated: List[str],
div_score: float,
exp_dd: float,
alerts: List[Dict],
high_corr: List[Dict],
) -> Dict:
messages = []
level = "ok"
if saturated:
level = "danger"
messages.append(
f"Portefeuille sur-concentré sur les facteurs {', '.join(saturated)}. "
"Les 2 prochains trades devraient cibler d'autres facteurs de risque."
)
if div_score < 40:
level = max(level, "warning") if level == "ok" else level
messages.append(
f"Score de diversification faible ({div_score}%). "
"Envisager des positions sur des classes d'actifs non corrélées."
)
if exp_dd > 25:
level = "danger"
messages.append(
f"Drawdown attendu estimé à {exp_dd}%. "
"Réduire l'exposition aux facteurs à haute volatilité."
)
if high_corr:
pair = high_corr[0]
level = max(level, "warning") if level == "ok" else level
messages.append(
f"Attention : '{pair['name_a']}' et '{pair['name_b']}' sont fortement corrélés "
f"({pair['correlation']:+.2f}) — ils ne représentent pas deux opportunités indépendantes."
)
if not messages:
messages.append(
"Portefeuille bien diversifié. "
"Continuer à varier les facteurs de risque à chaque nouveau trade."
)
return {
"level": level,
"messages": messages,
"summary": messages[0] if messages else "",
}