From e44c8799b98e35360d8ab176fa986b67cde12b62 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Wed, 17 Jun 2026 17:18:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20Portfolio=20Ris?= =?UTF-8?q?k=20Engine=20(Exposition,=20Clusters,=20Kelly,=20Risk=20Dashboa?= =?UTF-8?q?rd)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/main.py | 3 +- backend/routers/risk.py | 51 +++ backend/services/ai_analyzer.py | 4 +- backend/services/auto_cycle.py | 18 +- backend/services/database.py | 501 +++++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/hooks/useApi.ts | 46 ++ frontend/src/pages/Dashboard.tsx | 21 +- frontend/src/pages/JournalDeBord.tsx | 23 +- frontend/src/pages/RiskDashboard.tsx | 297 ++++++++++++ 11 files changed, 962 insertions(+), 7 deletions(-) create mode 100644 backend/routers/risk.py create mode 100644 frontend/src/pages/RiskDashboard.tsx diff --git a/backend/main.py b/backend/main.py index 03ffa81..cbd5c1b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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("/") diff --git a/backend/routers/risk.py b/backend/routers/risk.py new file mode 100644 index 0000000..e36139c --- /dev/null +++ b/backend/routers/risk.py @@ -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() diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index c6eef9b..1b7d5a3 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -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} diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index de0e3fc..ef24b67 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -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")] diff --git a/backend/services/database.py b/backend/services/database.py index 00a2b8a..a62e445 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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 "", + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a34f074..6148d6b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import RapportIA from './pages/RapportIA' import SuperContexte from './pages/SuperContexte' import Config from './pages/Config' import Analytics from './pages/Analytics' +import RiskDashboard from './pages/RiskDashboard' import { useCycleWatcher } from './hooks/useApi' function GlobalWatcher() { @@ -43,6 +44,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ec44369..ddb8526 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Globe, BarChart2, FlaskConical, - History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -18,6 +18,7 @@ const nav = [ { to: '/rapport', icon: FileBarChart, label: 'Rapport IA' }, { to: '/super-contexte', icon: Brain, label: 'Super Contexte' }, { to: '/analytics', icon: FlaskConical, label: 'Analytics' }, + { to: '/risk', icon: ShieldAlert, label: 'Risk Dashboard' }, { to: '/backtest', icon: History, label: 'Backtest' }, { to: '/calendar', icon: Calendar, label: 'Calendrier' }, { to: '/config', icon: Settings, label: 'Configuration' }, diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 506c784..36aca38 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -629,3 +629,49 @@ export const useCalibration = (days = 365) => queryFn: () => api.get('/analytics/calibration', { params: { days } }).then(r => r.data), staleTime: 10 * 60_000, }) + +// ── Risk Engine (Phase 3) ───────────────────────────────────────────────────── + +export const useRiskExposure = () => + useQuery({ + queryKey: ['risk-exposure'], + queryFn: () => api.get('/risk/exposure').then(r => r.data), + staleTime: 2 * 60_000, + }) + +export const usePnlTimeline = (days = 90) => + useQuery({ + queryKey: ['pnl-timeline', days], + queryFn: () => api.get('/risk/timeline', { params: { days } }).then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useRiskClusters = () => + useQuery({ + queryKey: ['risk-clusters'], + queryFn: () => api.get('/risk/clusters').then(r => r.data), + staleTime: 2 * 60_000, + }) + +export const usePatternCorrelations = () => + useQuery({ + queryKey: ['pattern-correlations'], + queryFn: () => api.get('/risk/correlations').then(r => r.data), + staleTime: 10 * 60_000, + }) + +export const useKellySizing = (patternId: string, capital = 10000) => + useQuery({ + queryKey: ['kelly', patternId, capital], + queryFn: () => api.get(`/risk/kelly/${encodeURIComponent(patternId)}`, { params: { capital } }).then(r => r.data), + enabled: !!patternId, + staleTime: 5 * 60_000, + }) + +export const useRiskDashboard = () => + useQuery({ + queryKey: ['risk-dashboard'], + queryFn: () => api.get('/risk/dashboard').then(r => r.data), + staleTime: 2 * 60_000, + refetchInterval: 5 * 60_000, + }) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f831c70..6776d85 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -3,9 +3,9 @@ import { useGeoRiskScore, useAllQuotes, useCalendar, useAiStatus, usePortfolioSummary, useAddPosition, useScorePatterns, useLastScores, useAllPatterns, useMacroRegime, - usePortfolioPositions, useTradeMtm, useRiskProfiles, + usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard, } from '../hooks/useApi' -import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2 } from 'lucide-react' +import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, ShieldAlert } from 'lucide-react' import clsx from 'clsx' import type { Quote } from '../types' import { format } from 'date-fns' @@ -416,6 +416,7 @@ export default function Dashboard() { const { data: positions, refetch: refetchPositions } = usePortfolioPositions('open') const { data: tradeMtmData } = useTradeMtm(30) const { data: riskProfilesData } = useRiskProfiles() + const { data: riskDashboard } = useRiskDashboard() const { mutate: scorePatterns, isPending: scoring } = useScorePatterns() const { mutate: addPos } = useAddPosition() @@ -602,6 +603,22 @@ export default function Dashboard() { + {/* Risk concentration banner */} + {(riskDashboard as any)?.concentration_alerts?.length > 0 && ( +
+ {((riskDashboard as any).concentration_alerts as any[]).slice(0, 2).map((alert: any, i: number) => ( +
+ + {alert.message} + Risk Dashboard → +
+ ))} +
+ )} + {/* Top row */}
{/* Geo Risk */} diff --git a/frontend/src/pages/JournalDeBord.tsx b/frontend/src/pages/JournalDeBord.tsx index 949d2aa..917a56a 100644 --- a/frontend/src/pages/JournalDeBord.tsx +++ b/frontend/src/pages/JournalDeBord.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp } from 'lucide-react' import clsx from 'clsx' -import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, api } from '../hooks/useApi' +import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi' import { useQueryClient } from '@tanstack/react-query' const SCENARIO_META: Record = { @@ -82,6 +82,23 @@ function IvRankCell({ underlying }: { underlying: string }) { ) } +function KellyCell({ patternId, capital = 10000 }: { patternId: string; capital?: number }) { + const { data, isLoading } = useKellySizing(patternId, capital) + if (!patternId || isLoading) return + if (!data || data.error) return + const pct = data.suggested_capital_pct + const eur = data.suggested_capital_eur + const color = pct <= 0 ? 'text-slate-600' : pct < 5 ? 'text-amber-400' : 'text-emerald-400' + const adjusted = data.cluster_adjustment_reason || data.reliability_adjustment + return ( +
+ {pct?.toFixed(1)}% + {eur != null ? `${eur}€` : ''} + {adjusted && ⚠ ajusté} +
+ ) +} + function MacroHistorySection({ days }: { days: number }) { const { data, isLoading, refetch, isFetching } = useMacroHistory(days) const history: any[] = (data as any)?.history ?? [] @@ -417,6 +434,7 @@ function TradeMtmSection({ days }: { days: number }) { Prix actuel Maturité IV Rank + Kelly P&L th. @@ -508,6 +526,9 @@ function TradeMtmSection({ days }: { days: number }) { + + + diff --git a/frontend/src/pages/RiskDashboard.tsx b/frontend/src/pages/RiskDashboard.tsx new file mode 100644 index 0000000..50a9435 --- /dev/null +++ b/frontend/src/pages/RiskDashboard.tsx @@ -0,0 +1,297 @@ +import { useState } from 'react' +import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure } from '../hooks/useApi' +import clsx from 'clsx' +import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity } from 'lucide-react' + +// ── Gauge component ────────────────────────────────────────────────────────── +function ConcentrationGauge({ label, pct, threshold = 50 }: { label: string; pct: number; threshold?: number }) { + const color = pct > threshold ? 'bg-red-500' : pct > threshold * 0.7 ? 'bg-amber-500' : 'bg-emerald-500' + const textColor = pct > threshold ? 'text-red-400' : pct > threshold * 0.7 ? 'text-amber-400' : 'text-emerald-400' + return ( +
+
+ {label} + {pct.toFixed(1)}% +
+
+
+
+ {pct > threshold && ( +
⚠ Saturé (>{threshold}%)
+ )} +
+ ) +} + +// ── Equity curve SVG ───────────────────────────────────────────────────────── +function EquityCurve({ timeline }: { timeline: any[] }) { + if (!timeline || timeline.length < 2) { + return ( +
+ Pas encore de données de P&L (nécessite des trades matures) +
+ ) + } + + const values = timeline.map(t => t.cumulative_pnl_abs ?? 0) + const min = Math.min(...values) + const max = Math.max(...values) + const range = max - min || 1 + const W = 400 + const H = 80 + const pad = 4 + + const points = values.map((v, i) => { + const x = pad + (i / (values.length - 1)) * (W - pad * 2) + const y = H - pad - ((v - min) / range) * (H - pad * 2) + return `${x},${y}` + }).join(' ') + + const finalVal = values[values.length - 1] + const lineColor = finalVal >= 0 ? '#34d399' : '#f87171' + + return ( +
+ + + + + + + + {/* Zero line */} + {min < 0 && max > 0 && ( + + )} + + +
+ {timeline[0]?.entry_date?.slice(0, 7)} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {finalVal >= 0 ? '+' : ''}{finalVal.toFixed(0)}€ + + {timeline[timeline.length - 1]?.entry_date?.slice(0, 7)} +
+
+ ) +} + +// ── Correlation heatmap row ────────────────────────────────────────────────── +function CorrelationPairRow({ pair }: { pair: any }) { + const c = pair.correlation + const abs = Math.abs(c) + const color = abs > 0.7 ? 'text-red-400' : abs > 0.4 ? 'text-amber-400' : 'text-emerald-400' + const bg = abs > 0.7 ? 'bg-red-900/20 border-red-700/30' : abs > 0.4 ? 'bg-amber-900/20 border-amber-700/30' : 'bg-dark-700/30 border-slate-700/20' + return ( +
+
+ {pair.name_a} + + {pair.name_b} +
+
+ {c > 0 ? '+' : ''}{c.toFixed(2)} +
+
{pair.interpretation}
+
+ ) +} + +// ── Recommendation card ────────────────────────────────────────────────────── +function RecommendationCard({ rec }: { rec: any }) { + if (!rec) return null + const isOk = rec.level === 'ok' + const isDanger = rec.level === 'danger' + return ( +
+
+ {isOk ? : } + Recommandation Risk Committee +
+
+ {(rec.messages ?? []).map((msg: string, i: number) => ( +

{msg}

+ ))} +
+
+ ) +} + +// ── Main page ──────────────────────────────────────────────────────────────── +export default function RiskDashboard() { + const { data: dashboard, isLoading } = useRiskDashboard() + const { data: corrData } = usePatternCorrelations() + const [tlDays, setTlDays] = useState(90) + const { data: tlData } = usePnlTimeline(tlDays) + + const d: any = dashboard ?? {} + const pairs: any[] = corrData?.pairs ?? [] + + return ( +
+ {/* Header */} +
+
+

+ Risk Dashboard +

+

+ Concentration · Clusters · Corrélations · Position Sizing +

+
+
+ + {isLoading ? ( +
+ {[1, 2, 3, 4].map(i =>
)} +
+ ) : ( + <> + {/* KPI row */} +
+
+
{d.open_trades ?? 0}
+
Positions ouvertes
+
+
+
= 60 ? 'text-emerald-400' + : d.diversification_score >= 35 ? 'text-amber-400' : 'text-red-400' + )}>{d.diversification_score ?? '—'}%
+
Score diversification
+
N effectif = {d.effective_n_positions ?? '—'}
+
+
+
{d.expected_drawdown_pct ?? '—'}%
+
Drawdown attendu
+
+
+
+ {d.saturated_factors?.length ?? 0} +
+
Facteurs saturés
+ {d.saturated_factors?.length > 0 && ( +
{d.saturated_factors.join(', ')}
+ )} +
+
+ + {/* Recommendation */} + + + {/* Alerts */} + {(d.concentration_alerts ?? []).length > 0 && ( +
+ {d.concentration_alerts.map((alert: any, i: number) => ( +
+ + {alert.message} +
+ ))} +
+ )} + +
+ {/* Exposure by class */} +
+
+ Exposition par classe d'actif +
+ {Object.keys(d.exposure_by_class ?? {}).length === 0 ? ( +
Aucune position ouverte
+ ) : ( +
+ {Object.entries(d.exposure_by_class ?? {}) + .sort(([, a]: any, [, b]: any) => b.pct_of_portfolio - a.pct_of_portfolio) + .map(([cls, info]: any) => ( + + ))} +
+ )} +
+ + {/* Risk factors */} +
+
+ Exposition par facteur de risque +
+ {(d.risk_clusters ?? []).length === 0 ? ( +
Aucune position ouverte
+ ) : ( +
+ {(d.risk_clusters ?? []).map((c: any) => ( + + ))} +
+ )} +
+
+ + {/* Equity curve */} +
+
+
+ Courbe P&L cumulé (trades matures) +
+ +
+ +
+ + {/* Correlation pairs */} +
+
+ Corrélations entre patterns (P&L historique) +
+ {pairs.length === 0 ? ( +
+ Nécessite ≥3 trades matures par pattern pour calculer les corrélations +
+ ) : ( +
+ {pairs.slice(0, 10).map((pair: any, i: number) => ( + + ))} + {pairs.length > 10 && ( +
{pairs.length - 10} autres paires…
+ )} +
+ )} + {pairs.length > 0 && ( +
+ Corrélation > 0.7 = risque concentré — ces patterns partagent le même facteur sous-jacent +
+ )} +
+ + )} +
+ ) +}