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() {
{msg}
+ ))} ++ Concentration · Clusters · Corrélations · Position Sizing +
+