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:
@@ -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
51
backend/routers/risk.py
Normal 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()
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/super-contexte" element={<SuperContexte />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/risk" element={<RiskDashboard />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk concentration banner */}
|
||||
{(riskDashboard as any)?.concentration_alerts?.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{((riskDashboard as any).concentration_alerts as any[]).slice(0, 2).map((alert: any, i: number) => (
|
||||
<div key={i} className={clsx('flex items-center gap-2 text-xs px-3 py-2 rounded border', {
|
||||
'bg-red-900/20 border-red-700/30 text-red-300': alert.level === 'high',
|
||||
'bg-amber-900/20 border-amber-700/30 text-amber-300': alert.level === 'warning',
|
||||
})}>
|
||||
<ShieldAlert className="w-3 h-3 shrink-0" />
|
||||
{alert.message}
|
||||
<a href="/risk" className="ml-auto text-slate-500 hover:text-slate-300 underline">Risk Dashboard →</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top row */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* Geo Risk */}
|
||||
|
||||
@@ -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<string, { label: string; color: string; emoji: string }> = {
|
||||
@@ -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 <span className="text-slate-700 text-[10px]">…</span>
|
||||
if (!data || data.error) return <span className="text-slate-700 text-[10px]">—</span>
|
||||
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 (
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span className={clsx('text-[10px] font-bold font-mono', color)}>{pct?.toFixed(1)}%</span>
|
||||
<span className="text-[8px] text-slate-600">{eur != null ? `${eur}€` : ''}</span>
|
||||
{adjusted && <span className="text-[8px] text-orange-400" title={adjusted}>⚠ ajusté</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
<th className="text-right px-3 py-2 font-medium">Prix actuel</th>
|
||||
<th className="text-right px-3 py-2 font-medium">Maturité</th>
|
||||
<th className="text-right px-3 py-2 font-medium">IV Rank</th>
|
||||
<th className="text-right px-3 py-2 font-medium">Kelly</th>
|
||||
<th className="text-right px-3 py-2 font-medium">P&L th.</th>
|
||||
<th className="px-3 py-2 font-medium w-8"></th>
|
||||
</tr>
|
||||
@@ -508,6 +526,9 @@ function TradeMtmSection({ days }: { days: number }) {
|
||||
<td className="px-3 py-2 text-right">
|
||||
<IvRankCell underlying={t.underlying} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<KellyCell patternId={t.pattern_id} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<PnlBadge pnl={t.pnl_pct} />
|
||||
</td>
|
||||
|
||||
297
frontend/src/pages/RiskDashboard.tsx
Normal file
297
frontend/src/pages/RiskDashboard.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-400 capitalize">{label}</span>
|
||||
<span className={clsx('font-mono font-bold', textColor)}>{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
|
||||
<div className={clsx('h-full rounded-full transition-all', color)} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||
</div>
|
||||
{pct > threshold && (
|
||||
<div className="text-[10px] text-red-400 mt-0.5">⚠ Saturé (>{threshold}%)</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Equity curve SVG ─────────────────────────────────────────────────────────
|
||||
function EquityCurve({ timeline }: { timeline: any[] }) {
|
||||
if (!timeline || timeline.length < 2) {
|
||||
return (
|
||||
<div className="h-24 flex items-center justify-center text-slate-600 text-xs">
|
||||
Pas encore de données de P&L (nécessite des trades matures)
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-20">
|
||||
<defs>
|
||||
<linearGradient id="curve-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={lineColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={lineColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Zero line */}
|
||||
{min < 0 && max > 0 && (
|
||||
<line
|
||||
x1={pad} x2={W - pad}
|
||||
y1={H - pad - ((0 - min) / range) * (H - pad * 2)}
|
||||
y2={H - pad - ((0 - min) / range) * (H - pad * 2)}
|
||||
stroke="#475569" strokeWidth="0.5" strokeDasharray="3,3"
|
||||
/>
|
||||
)}
|
||||
<polyline points={points} fill="none" stroke={lineColor} strokeWidth="1.5" />
|
||||
</svg>
|
||||
<div className="flex justify-between text-[10px] text-slate-600 mt-0.5">
|
||||
<span>{timeline[0]?.entry_date?.slice(0, 7)}</span>
|
||||
<span className={clsx('font-mono font-bold', finalVal >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{finalVal >= 0 ? '+' : ''}{finalVal.toFixed(0)}€
|
||||
</span>
|
||||
<span>{timeline[timeline.length - 1]?.entry_date?.slice(0, 7)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className={clsx('flex items-center gap-3 px-3 py-2 rounded border text-xs', bg)}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-slate-300 truncate">{pair.name_a}</span>
|
||||
<span className="text-slate-600 mx-1">↔</span>
|
||||
<span className="text-slate-300 truncate">{pair.name_b}</span>
|
||||
</div>
|
||||
<div className={clsx('font-mono font-bold shrink-0', color)}>
|
||||
{c > 0 ? '+' : ''}{c.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-slate-600 text-[10px] shrink-0">{pair.interpretation}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Recommendation card ──────────────────────────────────────────────────────
|
||||
function RecommendationCard({ rec }: { rec: any }) {
|
||||
if (!rec) return null
|
||||
const isOk = rec.level === 'ok'
|
||||
const isDanger = rec.level === 'danger'
|
||||
return (
|
||||
<div className={clsx('card', {
|
||||
'border-red-700/40 bg-red-900/10': isDanger,
|
||||
'border-amber-700/40 bg-amber-900/10': rec.level === 'warning',
|
||||
'border-emerald-700/40 bg-emerald-900/10': isOk,
|
||||
})}>
|
||||
<div className={clsx('flex items-center gap-2 mb-2 text-sm font-semibold', {
|
||||
'text-red-400': isDanger,
|
||||
'text-amber-400': rec.level === 'warning',
|
||||
'text-emerald-400': isOk,
|
||||
})}>
|
||||
{isOk ? <CheckCircle className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
|
||||
Recommandation Risk Committee
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{(rec.messages ?? []).map((msg: string, i: number) => (
|
||||
<p key={i} className="text-xs text-slate-300 leading-relaxed">{msg}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<ShieldAlert className="w-5 h-5 text-red-400" /> Risk Dashboard
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Concentration · Clusters · Corrélations · Position Sizing
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[1, 2, 3, 4].map(i => <div key={i} className="card h-20 animate-pulse bg-dark-700" />)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="card text-center">
|
||||
<div className="text-2xl font-bold text-white font-mono">{d.open_trades ?? 0}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Positions ouvertes</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className={clsx('text-2xl font-bold font-mono',
|
||||
d.diversification_score >= 60 ? 'text-emerald-400'
|
||||
: d.diversification_score >= 35 ? 'text-amber-400' : 'text-red-400'
|
||||
)}>{d.diversification_score ?? '—'}%</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Score diversification</div>
|
||||
<div className="text-[10px] text-slate-600">N effectif = {d.effective_n_positions ?? '—'}</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className={clsx('text-2xl font-bold font-mono',
|
||||
(d.expected_drawdown_pct ?? 0) < 15 ? 'text-emerald-400'
|
||||
: (d.expected_drawdown_pct ?? 0) < 25 ? 'text-amber-400' : 'text-red-400'
|
||||
)}>{d.expected_drawdown_pct ?? '—'}%</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Drawdown attendu</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className={clsx('text-2xl font-bold font-mono',
|
||||
!d.saturated_factors?.length ? 'text-emerald-400' : 'text-red-400'
|
||||
)}>
|
||||
{d.saturated_factors?.length ?? 0}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Facteurs saturés</div>
|
||||
{d.saturated_factors?.length > 0 && (
|
||||
<div className="text-[10px] text-red-400">{d.saturated_factors.join(', ')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendation */}
|
||||
<RecommendationCard rec={d.recommendation} />
|
||||
|
||||
{/* Alerts */}
|
||||
{(d.concentration_alerts ?? []).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{d.concentration_alerts.map((alert: any, i: number) => (
|
||||
<div key={i} className={clsx('flex items-start gap-2 text-xs px-3 py-2 rounded border', {
|
||||
'bg-red-900/20 border-red-700/30 text-red-300': alert.level === 'high',
|
||||
'bg-amber-900/20 border-amber-700/30 text-amber-300': alert.level === 'warning',
|
||||
})}>
|
||||
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
{alert.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
{/* Exposure by class */}
|
||||
<div className="card">
|
||||
<div className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-blue-400" /> Exposition par classe d'actif
|
||||
</div>
|
||||
{Object.keys(d.exposure_by_class ?? {}).length === 0 ? (
|
||||
<div className="text-xs text-slate-600 text-center py-4">Aucune position ouverte</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(d.exposure_by_class ?? {})
|
||||
.sort(([, a]: any, [, b]: any) => b.pct_of_portfolio - a.pct_of_portfolio)
|
||||
.map(([cls, info]: any) => (
|
||||
<ConcentrationGauge key={cls} label={cls} pct={info.pct_of_portfolio} threshold={40} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk factors */}
|
||||
<div className="card">
|
||||
<div className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-orange-400" /> Exposition par facteur de risque
|
||||
</div>
|
||||
{(d.risk_clusters ?? []).length === 0 ? (
|
||||
<div className="text-xs text-slate-600 text-center py-4">Aucune position ouverte</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(d.risk_clusters ?? []).map((c: any) => (
|
||||
<ConcentrationGauge key={c.factor} label={c.factor} pct={c.pct_of_portfolio} threshold={50} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equity curve */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-blue-400" /> Courbe P&L cumulé (trades matures)
|
||||
</div>
|
||||
<select value={tlDays} onChange={e => setTlDays(Number(e.target.value))}
|
||||
className="bg-dark-700 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300">
|
||||
<option value={30}>30j</option>
|
||||
<option value={90}>90j</option>
|
||||
<option value={180}>180j</option>
|
||||
<option value={365}>1 an</option>
|
||||
</select>
|
||||
</div>
|
||||
<EquityCurve timeline={tlData?.timeline ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Correlation pairs */}
|
||||
<div className="card">
|
||||
<div className="text-sm font-semibold text-white mb-3">
|
||||
Corrélations entre patterns (P&L historique)
|
||||
</div>
|
||||
{pairs.length === 0 ? (
|
||||
<div className="text-xs text-slate-600 text-center py-4">
|
||||
Nécessite ≥3 trades matures par pattern pour calculer les corrélations
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{pairs.slice(0, 10).map((pair: any, i: number) => (
|
||||
<CorrelationPairRow key={i} pair={pair} />
|
||||
))}
|
||||
{pairs.length > 10 && (
|
||||
<div className="text-xs text-slate-600 text-center pt-1">{pairs.length - 10} autres paires…</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pairs.length > 0 && (
|
||||
<div className="text-[10px] text-slate-600 mt-2">
|
||||
Corrélation > 0.7 = risque concentré — ces patterns partagent le même facteur sous-jacent
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user