feat: VaR/PnL schedulers + snapshots DB + page sur bouton
Backend: - Tables var_snapshots + pnl_snapshots dans SQLite (contexte macro + prix tickers) - var_service.py : save_var_snapshot, save_pnl_snapshot + fonctions get_* - var_scheduler.py : threads APScheduler pour VaR (défaut 6h) et PnL (défaut 1h) - router var.py : /run-now (POST compute+save), /latest, /snapshots, /pnl/run-now, /pnl/latest, /scheduler/status, /scheduler/config - main.py : démarrage des deux schedulers au startup Frontend: - VaRAnalysis.tsx : plus d'auto-fetch ; charge le dernier snapshot DB au mount ; bouton "Calculer" → POST /run-now ; erreur backend = message clair ; historique de snapshots sélectionnables - Config.tsx : section "Schedulers VaR & PnL" dans l'onglet cycle avec toggle enable/disable, intervalle, et boutons "Snapshot maintenant" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -75,12 +75,19 @@ def startup():
|
||||
# Start auto-cycle scheduler if enabled
|
||||
from services.auto_cycle import start_scheduler
|
||||
start_scheduler()
|
||||
# Start VaR + PnL snapshot schedulers
|
||||
from services.var_scheduler import start_var_scheduler, start_pnl_scheduler
|
||||
start_var_scheduler()
|
||||
start_pnl_scheduler()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown():
|
||||
from services.auto_cycle import stop_scheduler
|
||||
stop_scheduler()
|
||||
from services.var_scheduler import stop_var_scheduler, stop_pnl_scheduler
|
||||
stop_var_scheduler()
|
||||
stop_pnl_scheduler()
|
||||
|
||||
|
||||
app.include_router(market_data.router)
|
||||
|
||||
@@ -1,20 +1,107 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from services.var_service import compute_var
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.var_service import (
|
||||
compute_var, save_var_snapshot,
|
||||
get_var_snapshots, get_var_snapshot, get_latest_var_snapshot,
|
||||
get_pnl_snapshots, get_latest_pnl_snapshot, save_pnl_snapshot,
|
||||
)
|
||||
from services.var_scheduler import (
|
||||
get_scheduler_status, restart_var_scheduler, restart_pnl_scheduler,
|
||||
)
|
||||
from services.database import set_config
|
||||
|
||||
router = APIRouter(prefix="/api/var", tags=["var"])
|
||||
|
||||
|
||||
@router.get("/compute")
|
||||
def var_compute(
|
||||
@router.get("/latest")
|
||||
def var_latest():
|
||||
"""Get most recent saved VaR snapshot (no recompute)."""
|
||||
snap = get_latest_var_snapshot()
|
||||
return {"snapshot": snap}
|
||||
|
||||
|
||||
@router.get("/snapshots")
|
||||
def var_snapshots(limit: int = Query(default=20, ge=1, le=100)):
|
||||
"""List recent VaR snapshot summaries."""
|
||||
return {"snapshots": get_var_snapshots(limit)}
|
||||
|
||||
|
||||
@router.get("/snapshots/{snapshot_id}")
|
||||
def var_snapshot_detail(snapshot_id: int):
|
||||
snap = get_var_snapshot(snapshot_id)
|
||||
if not snap:
|
||||
raise HTTPException(404, "Snapshot not found")
|
||||
return snap
|
||||
|
||||
|
||||
@router.post("/run-now")
|
||||
def var_run_now(
|
||||
confidence: float = Query(default=0.95, ge=0.90, le=0.99),
|
||||
horizon_days: int = Query(default=1, ge=1, le=30),
|
||||
lookback_days: int = Query(default=252, ge=60, le=504),
|
||||
default_iv: float = Query(default=0.20, ge=0.05, le=0.80),
|
||||
):
|
||||
"""Compute portfolio VaR using Black-Scholes delta approach."""
|
||||
return compute_var(
|
||||
"""Compute VaR now and save to DB. Returns the full result."""
|
||||
result = compute_var(
|
||||
confidence=confidence,
|
||||
horizon_days=horizon_days,
|
||||
lookback_days=lookback_days,
|
||||
default_iv=default_iv,
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(400, result["error"])
|
||||
snapshot_id = save_var_snapshot(
|
||||
result, confidence, horizon_days, lookback_days, default_iv
|
||||
)
|
||||
return {**result, "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
# ─── PnL endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/pnl/latest")
|
||||
def pnl_latest():
|
||||
return {"snapshot": get_latest_pnl_snapshot()}
|
||||
|
||||
|
||||
@router.get("/pnl/snapshots")
|
||||
def pnl_snapshots(limit: int = Query(default=48, ge=1, le=200)):
|
||||
return {"snapshots": get_pnl_snapshots(limit)}
|
||||
|
||||
|
||||
@router.post("/pnl/run-now")
|
||||
def pnl_run_now():
|
||||
try:
|
||||
row_id = save_pnl_snapshot()
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
return {"snapshot_id": row_id, "snapshot": get_latest_pnl_snapshot()}
|
||||
|
||||
|
||||
# ─── Scheduler status / config ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/scheduler/status")
|
||||
def scheduler_status():
|
||||
return get_scheduler_status()
|
||||
|
||||
|
||||
class SchedulerConfig(BaseModel):
|
||||
var_enabled: bool | None = None
|
||||
var_hours: float | None = None
|
||||
pnl_enabled: bool | None = None
|
||||
pnl_hours: float | None = None
|
||||
|
||||
|
||||
@router.post("/scheduler/config")
|
||||
def scheduler_config(cfg: SchedulerConfig):
|
||||
if cfg.var_enabled is not None:
|
||||
set_config("var_scheduler_enabled", "true" if cfg.var_enabled else "false")
|
||||
if cfg.var_hours is not None:
|
||||
set_config("var_scheduler_hours", str(cfg.var_hours))
|
||||
if cfg.pnl_enabled is not None:
|
||||
set_config("pnl_scheduler_enabled", "true" if cfg.pnl_enabled else "false")
|
||||
if cfg.pnl_hours is not None:
|
||||
set_config("pnl_scheduler_hours", str(cfg.pnl_hours))
|
||||
# Restart scheduler threads to pick up new config
|
||||
restart_var_scheduler()
|
||||
restart_pnl_scheduler()
|
||||
return {"ok": True, "status": get_scheduler_status()}
|
||||
|
||||
@@ -398,6 +398,51 @@ def init_db():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS var_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
computed_at TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.95,
|
||||
horizon_days INTEGER NOT NULL DEFAULT 1,
|
||||
lookback_days INTEGER NOT NULL DEFAULT 252,
|
||||
default_iv REAL NOT NULL DEFAULT 0.20,
|
||||
hist_var_1d_pct REAL,
|
||||
hist_cvar_pct REAL,
|
||||
hist_var_1d_eur REAL,
|
||||
param_var_1d_pct REAL,
|
||||
param_cvar_pct REAL,
|
||||
mc_var_1d_pct REAL,
|
||||
mc_cvar_pct REAL,
|
||||
n_positions INTEGER,
|
||||
total_notional_eur REAL,
|
||||
data_source TEXT,
|
||||
breach_rate_pct REAL,
|
||||
kupiec_ok INTEGER,
|
||||
macro_regime TEXT,
|
||||
ticker_prices TEXT,
|
||||
full_result TEXT
|
||||
)""")
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_var_snap_ts ON var_snapshots(computed_at DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS pnl_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapped_at TEXT NOT NULL,
|
||||
n_open INTEGER,
|
||||
n_closed INTEGER,
|
||||
total_capital_eur REAL,
|
||||
total_pnl_pct REAL,
|
||||
total_pnl_eur REAL,
|
||||
ticker_prices TEXT,
|
||||
macro_regime TEXT,
|
||||
trades_snapshot TEXT
|
||||
)""")
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_pnl_snap_ts ON pnl_snapshots(snapped_at DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)")
|
||||
|
||||
124
backend/services/var_scheduler.py
Normal file
124
backend/services/var_scheduler.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Periodic scheduler for VaR and PnL snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_var_thread: threading.Thread | None = None
|
||||
_pnl_thread: threading.Thread | None = None
|
||||
_var_stop = threading.Event()
|
||||
_pnl_stop = threading.Event()
|
||||
|
||||
|
||||
def _var_loop(stop: threading.Event):
|
||||
while not stop.wait(0):
|
||||
from .database import get_config
|
||||
try:
|
||||
enabled = (get_config("var_scheduler_enabled") or "false").lower() == "true"
|
||||
hours = float(get_config("var_scheduler_hours") or "6")
|
||||
except Exception:
|
||||
enabled, hours = False, 6.0
|
||||
|
||||
if not enabled:
|
||||
stop.wait(timeout=300)
|
||||
continue
|
||||
|
||||
# Run snapshot
|
||||
try:
|
||||
from .var_service import compute_var, save_var_snapshot
|
||||
result = compute_var()
|
||||
if "error" not in result:
|
||||
save_var_snapshot(result, confidence=0.95, horizon_days=1,
|
||||
lookback_days=252, default_iv=0.20)
|
||||
logger.info("[VaR Scheduler] Snapshot saved")
|
||||
else:
|
||||
logger.warning(f"[VaR Scheduler] Compute failed: {result['error']}")
|
||||
except Exception as e:
|
||||
logger.error(f"[VaR Scheduler] Exception: {e}")
|
||||
|
||||
stop.wait(timeout=hours * 3600)
|
||||
|
||||
|
||||
def _pnl_loop(stop: threading.Event):
|
||||
while not stop.wait(0):
|
||||
from .database import get_config
|
||||
try:
|
||||
enabled = (get_config("pnl_scheduler_enabled") or "false").lower() == "true"
|
||||
hours = float(get_config("pnl_scheduler_hours") or "1")
|
||||
except Exception:
|
||||
enabled, hours = False, 1.0
|
||||
|
||||
if not enabled:
|
||||
stop.wait(timeout=120)
|
||||
continue
|
||||
|
||||
try:
|
||||
from .var_service import save_pnl_snapshot
|
||||
save_pnl_snapshot()
|
||||
logger.info("[PnL Scheduler] Snapshot saved")
|
||||
except Exception as e:
|
||||
logger.error(f"[PnL Scheduler] Exception: {e}")
|
||||
|
||||
stop.wait(timeout=hours * 3600)
|
||||
|
||||
|
||||
def start_var_scheduler():
|
||||
global _var_thread, _var_stop
|
||||
_var_stop.clear()
|
||||
if _var_thread and _var_thread.is_alive():
|
||||
return
|
||||
_var_thread = threading.Thread(target=_var_loop, args=(_var_stop,),
|
||||
name="var-scheduler", daemon=True)
|
||||
_var_thread.start()
|
||||
logger.info("[VaR Scheduler] Started")
|
||||
|
||||
|
||||
def start_pnl_scheduler():
|
||||
global _pnl_thread, _pnl_stop
|
||||
_pnl_stop.clear()
|
||||
if _pnl_thread and _pnl_thread.is_alive():
|
||||
return
|
||||
_pnl_thread = threading.Thread(target=_pnl_loop, args=(_pnl_stop,),
|
||||
name="pnl-scheduler", daemon=True)
|
||||
_pnl_thread.start()
|
||||
logger.info("[PnL Scheduler] Started")
|
||||
|
||||
|
||||
def stop_var_scheduler():
|
||||
_var_stop.set()
|
||||
|
||||
|
||||
def stop_pnl_scheduler():
|
||||
_pnl_stop.set()
|
||||
|
||||
|
||||
def restart_var_scheduler():
|
||||
stop_var_scheduler()
|
||||
import time; time.sleep(0.2)
|
||||
start_var_scheduler()
|
||||
|
||||
|
||||
def restart_pnl_scheduler():
|
||||
stop_pnl_scheduler()
|
||||
import time; time.sleep(0.2)
|
||||
start_pnl_scheduler()
|
||||
|
||||
|
||||
def get_scheduler_status() -> dict:
|
||||
from .database import get_config
|
||||
return {
|
||||
"var": {
|
||||
"enabled": (get_config("var_scheduler_enabled") or "false").lower() == "true",
|
||||
"hours": float(get_config("var_scheduler_hours") or "6"),
|
||||
"alive": bool(_var_thread and _var_thread.is_alive()),
|
||||
},
|
||||
"pnl": {
|
||||
"enabled": (get_config("pnl_scheduler_enabled") or "false").lower() == "true",
|
||||
"hours": float(get_config("pnl_scheduler_hours") or "1"),
|
||||
"alive": bool(_pnl_thread and _pnl_thread.is_alive()),
|
||||
},
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from .database import get_conn
|
||||
|
||||
@@ -253,3 +254,211 @@ def compute_var(
|
||||
"kupiec_ok": breach_rate <= alpha * 100 * 2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def save_var_snapshot(result: Dict, confidence: float, horizon_days: int,
|
||||
lookback_days: int, default_iv: float,
|
||||
macro_regime: Optional[str] = None,
|
||||
ticker_prices: Optional[str] = None) -> int:
|
||||
"""Persist a VaR result dict to var_snapshots. Returns new row id."""
|
||||
if "error" in result:
|
||||
raise ValueError(result["error"])
|
||||
|
||||
v = result["var"]
|
||||
p = result["portfolio"]
|
||||
bt = result.get("backtest", {})
|
||||
computed_at = datetime.utcnow().isoformat(timespec="seconds")
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO var_snapshots
|
||||
(computed_at, confidence, horizon_days, lookback_days, default_iv,
|
||||
hist_var_1d_pct, hist_cvar_pct, hist_var_1d_eur,
|
||||
param_var_1d_pct, param_cvar_pct,
|
||||
mc_var_1d_pct, mc_cvar_pct,
|
||||
n_positions, total_notional_eur, data_source,
|
||||
breach_rate_pct, kupiec_ok,
|
||||
macro_regime, ticker_prices, full_result)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
computed_at, confidence, horizon_days, lookback_days, default_iv,
|
||||
v["historical"]["var_1d_pct"], v["historical"]["cvar_pct"], v["historical"]["var_1d_eur"],
|
||||
v["parametric"]["var_1d_pct"], v["parametric"]["cvar_pct"],
|
||||
v["monte_carlo"]["var_1d_pct"], v["monte_carlo"]["cvar_pct"],
|
||||
p["n_positions"], p["total_notional_eur"], p["data_source"],
|
||||
bt.get("breach_rate_pct"), 1 if bt.get("kupiec_ok") else 0,
|
||||
macro_regime, ticker_prices,
|
||||
json.dumps(result, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
row_id = cur.lastrowid
|
||||
conn.close()
|
||||
return row_id
|
||||
|
||||
|
||||
def get_var_snapshots(limit: int = 20) -> List[Dict]:
|
||||
"""Return most recent VaR snapshots (summary, no full_result)."""
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT id, computed_at, confidence, horizon_days, lookback_days,
|
||||
hist_var_1d_pct, hist_cvar_pct, hist_var_1d_eur,
|
||||
param_var_1d_pct, mc_var_1d_pct,
|
||||
n_positions, total_notional_eur, data_source,
|
||||
breach_rate_pct, kupiec_ok
|
||||
FROM var_snapshots ORDER BY computed_at DESC LIMIT ?""",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_var_snapshot(snapshot_id: int) -> Optional[Dict]:
|
||||
"""Return a single snapshot with full_result parsed."""
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM var_snapshots WHERE id=?", (snapshot_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
if d.get("full_result"):
|
||||
try:
|
||||
d["full_result"] = json.loads(d["full_result"])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def get_latest_var_snapshot() -> Optional[Dict]:
|
||||
"""Return the most recent snapshot with full result."""
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM var_snapshots ORDER BY computed_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
if d.get("full_result"):
|
||||
try:
|
||||
d["full_result"] = json.loads(d["full_result"])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
# ─── PnL snapshot ────────────────────────────────────────────────────────────
|
||||
|
||||
def save_pnl_snapshot() -> int:
|
||||
"""Compute live PnL and persist to pnl_snapshots. Returns new row id."""
|
||||
from .database import _fetch_live_prices
|
||||
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trade_entry_prices WHERE status = 'open'"
|
||||
).fetchall()
|
||||
trades = [dict(r) for r in rows]
|
||||
|
||||
closed_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM trade_entry_prices WHERE status = 'closed'"
|
||||
).fetchone()[0]
|
||||
|
||||
# Fetch live prices
|
||||
tickers = list({t["underlying"] for t in trades if t.get("underlying") and ":" not in (t["underlying"] or "")})
|
||||
prices: Dict = {}
|
||||
if tickers:
|
||||
try:
|
||||
prices = _fetch_live_prices(tickers, timeout=15) or {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
BEARISH = {"long put", "bear put spread", "short call", "put"}
|
||||
|
||||
def is_bearish(strategy: str) -> bool:
|
||||
return any(k in strategy.lower() for k in BEARISH)
|
||||
|
||||
enriched = []
|
||||
total_capital = 0.0
|
||||
total_pnl_eur = 0.0
|
||||
|
||||
for t in trades:
|
||||
entry = t.get("entry_price") or 0.0
|
||||
capital = t.get("capital_invested") or entry
|
||||
current = prices.get(t.get("underlying") or "")
|
||||
pnl_pct = None
|
||||
if entry and current and entry > 0:
|
||||
raw = (current - entry) / entry * 100
|
||||
pnl_pct = round(-raw if is_bearish(t.get("strategy") or "") else raw, 2)
|
||||
pnl_eur = round(capital * (pnl_pct / 100), 2) if pnl_pct is not None and capital else None
|
||||
total_capital += capital
|
||||
if pnl_eur is not None:
|
||||
total_pnl_eur += pnl_eur
|
||||
enriched.append({
|
||||
"id": t["id"],
|
||||
"ticker": t.get("underlying"),
|
||||
"strategy": t.get("strategy"),
|
||||
"entry_price": entry,
|
||||
"current_price": current,
|
||||
"pnl_pct": pnl_pct,
|
||||
"pnl_eur": pnl_eur,
|
||||
"capital_invested": capital,
|
||||
})
|
||||
|
||||
total_pnl_pct = round(total_pnl_eur / total_capital * 100, 3) if total_capital > 0 else 0.0
|
||||
|
||||
# Macro regime snapshot
|
||||
macro_row = conn.execute(
|
||||
"SELECT dominant, scores_json FROM macro_regime_history ORDER BY timestamp DESC LIMIT 1"
|
||||
).fetchone()
|
||||
macro_context = json.dumps(dict(macro_row)) if macro_row else None
|
||||
|
||||
snapped_at = datetime.utcnow().isoformat(timespec="seconds")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pnl_snapshots
|
||||
(snapped_at, n_open, n_closed, total_capital_eur, total_pnl_pct, total_pnl_eur,
|
||||
ticker_prices, macro_regime, trades_snapshot)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snapped_at, len(trades), closed_count,
|
||||
round(total_capital, 2), total_pnl_pct, round(total_pnl_eur, 2),
|
||||
json.dumps(prices, ensure_ascii=False),
|
||||
macro_context,
|
||||
json.dumps(enriched, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
row_id = cur.lastrowid
|
||||
conn.close()
|
||||
return row_id
|
||||
|
||||
|
||||
def get_pnl_snapshots(limit: int = 48) -> List[Dict]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT id, snapped_at, n_open, n_closed,
|
||||
total_capital_eur, total_pnl_pct, total_pnl_eur
|
||||
FROM pnl_snapshots ORDER BY snapped_at DESC LIMIT ?""",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_latest_pnl_snapshot() -> Optional[Dict]:
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pnl_snapshots ORDER BY snapped_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
for key in ("ticker_prices", "macro_regime", "trades_snapshot"):
|
||||
if d.get(key):
|
||||
try:
|
||||
d[key] = json.loads(d[key])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults } from '../hooks/useApi'
|
||||
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock } from 'lucide-react'
|
||||
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const API = 'http://localhost:8000'
|
||||
|
||||
const SOURCE_CATEGORIES = {
|
||||
'Flux RSS actifs': ['reuters_world', 'reuters_business', 'reuters_energy', 'ap_top', 'aljazeera', 'ft', 'bloomberg'],
|
||||
'Données économiques': ['newsapi', 'gdelt', 'eia', 'fred', 'usda'],
|
||||
@@ -370,6 +373,41 @@ export default function Config() {
|
||||
}
|
||||
}, [exitDefaultsData])
|
||||
|
||||
// VaR + PnL scheduler state
|
||||
const qc = useQueryClient()
|
||||
const { data: schedStatus, refetch: refetchSched } = useQuery({
|
||||
queryKey: ['sched-status'],
|
||||
queryFn: () => fetch(`${API}/api/var/scheduler/status`).then(r => r.json()),
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
const [varSchedEnabled, setVarSchedEnabled] = useState(false)
|
||||
const [varSchedHours, setVarSchedHours] = useState(6)
|
||||
const [pnlSchedEnabled, setPnlSchedEnabled] = useState(false)
|
||||
const [pnlSchedHours, setPnlSchedHours] = useState(1)
|
||||
useEffect(() => {
|
||||
if (schedStatus) {
|
||||
setVarSchedEnabled(schedStatus.var?.enabled ?? false)
|
||||
setVarSchedHours(schedStatus.var?.hours ?? 6)
|
||||
setPnlSchedEnabled(schedStatus.pnl?.enabled ?? false)
|
||||
setPnlSchedHours(schedStatus.pnl?.hours ?? 1)
|
||||
}
|
||||
}, [schedStatus])
|
||||
const { mutate: saveSchedConfig, isPending: savingSched } = useMutation({
|
||||
mutationFn: (cfg: any) => fetch(`${API}/api/var/scheduler/config`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg),
|
||||
}).then(r => r.json()),
|
||||
onSuccess: () => { refetchSched(); setSavedMsg('Schedulers sauvegardés'); setTimeout(() => setSavedMsg(''), 2000) },
|
||||
})
|
||||
const { mutate: triggerVarNow, isPending: triggeringVar } = useMutation({
|
||||
mutationFn: () => fetch(`${API}/api/var/run-now`, { method: 'POST' }).then(r => r.json()),
|
||||
onSuccess: () => { setSavedMsg('Snapshot VaR sauvegardé'); setTimeout(() => setSavedMsg(''), 2000) },
|
||||
})
|
||||
const { mutate: triggerPnlNow, isPending: triggeringPnl } = useMutation({
|
||||
mutationFn: () => fetch(`${API}/api/var/pnl/run-now`, { method: 'POST' }).then(r => r.json()),
|
||||
onSuccess: () => { setSavedMsg('Snapshot PnL sauvegardé'); setTimeout(() => setSavedMsg(''), 2000) },
|
||||
})
|
||||
|
||||
// Auto-cycle local state
|
||||
const cs = cycleStatus as any
|
||||
const [cycleEnabled, setCycleEnabled] = useState(false)
|
||||
@@ -793,6 +831,112 @@ export default function Config() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── VaR & PnL Schedulers ── */}
|
||||
<div className="card">
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">
|
||||
<Gauge className="w-4 h-4 text-red-400" /> Schedulers VaR & PnL
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Calcule et sauvegarde automatiquement les snapshots VaR (Black-Scholes delta) et PnL à intervalles définis.
|
||||
Les données sont accessibles depuis la page VaR Analyse avec un historique complet.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
{/* VaR scheduler */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-sm font-semibold text-slate-300">Scheduler VaR</span>
|
||||
{schedStatus?.var?.alive
|
||||
? <span className="text-[10px] bg-emerald-900/30 text-emerald-400 border border-emerald-700/30 px-1.5 py-0.5 rounded ml-auto">● Actif</span>
|
||||
: <span className="text-[10px] bg-dark-700 text-slate-600 border border-slate-700/30 px-1.5 py-0.5 rounded ml-auto">○ Inactif</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Activer</label>
|
||||
<button onClick={() => setVarSchedEnabled(!varSchedEnabled)}
|
||||
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
|
||||
'bg-red-700 border-red-600 text-white': varSchedEnabled,
|
||||
'bg-dark-700 border-slate-700 text-slate-400': !varSchedEnabled,
|
||||
})}>
|
||||
{varSchedEnabled ? '✓ Activé' : '○ Désactivé'}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Intervalle</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 3, 6, 12, 24].map(h => (
|
||||
<button key={h} onClick={() => setVarSchedHours(h)}
|
||||
className={clsx('flex-1 py-1.5 rounded text-xs transition-colors', {
|
||||
'bg-red-700 text-white': varSchedHours === h,
|
||||
'bg-dark-700 text-slate-400 hover:text-slate-200': varSchedHours !== h,
|
||||
})}>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PnL scheduler */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="text-sm font-semibold text-slate-300">Scheduler PnL</span>
|
||||
{schedStatus?.pnl?.alive
|
||||
? <span className="text-[10px] bg-emerald-900/30 text-emerald-400 border border-emerald-700/30 px-1.5 py-0.5 rounded ml-auto">● Actif</span>
|
||||
: <span className="text-[10px] bg-dark-700 text-slate-600 border border-slate-700/30 px-1.5 py-0.5 rounded ml-auto">○ Inactif</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Activer</label>
|
||||
<button onClick={() => setPnlSchedEnabled(!pnlSchedEnabled)}
|
||||
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
|
||||
'bg-emerald-700 border-emerald-600 text-white': pnlSchedEnabled,
|
||||
'bg-dark-700 border-slate-700 text-slate-400': !pnlSchedEnabled,
|
||||
})}>
|
||||
{pnlSchedEnabled ? '✓ Activé' : '○ Désactivé'}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Intervalle</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 4, 8, 24].map(h => (
|
||||
<button key={h} onClick={() => setPnlSchedHours(h)}
|
||||
className={clsx('flex-1 py-1.5 rounded text-xs transition-colors', {
|
||||
'bg-emerald-700 text-white': pnlSchedHours === h,
|
||||
'bg-dark-700 text-slate-400 hover:text-slate-200': pnlSchedHours !== h,
|
||||
})}>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => saveSchedConfig({ var_enabled: varSchedEnabled, var_hours: varSchedHours, pnl_enabled: pnlSchedEnabled, pnl_hours: pnlSchedHours })}
|
||||
disabled={savingSched}
|
||||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||||
<Save className="w-4 h-4" />
|
||||
{savingSched ? 'Sauvegarde…' : 'Appliquer'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerVarNow()}
|
||||
disabled={triggeringVar}
|
||||
className="flex items-center gap-1.5 border border-red-500/50 text-red-400 hover:bg-red-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
|
||||
<Gauge className={clsx('w-4 h-4', triggeringVar && 'animate-spin')} />
|
||||
Snapshot VaR maintenant
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerPnlNow()}
|
||||
disabled={triggeringPnl}
|
||||
className="flex items-center gap-1.5 border border-emerald-500/50 text-emerald-400 hover:bg-emerald-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
|
||||
<DollarSign className={clsx('w-4 h-4', triggeringPnl && 'animate-spin')} />
|
||||
Snapshot PnL maintenant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,194 +1,87 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine,
|
||||
LineChart, Line, ResponsiveContainer, Cell,
|
||||
} from 'recharts'
|
||||
import { ShieldAlert, TrendingDown, Activity, AlertTriangle, Info, RefreshCw } from 'lucide-react'
|
||||
import { ShieldAlert, TrendingDown, Activity, AlertTriangle, Info, Play, Clock, Database } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────────────────────────
|
||||
const API = 'http://localhost:8000'
|
||||
|
||||
async function fetchVar(params: Record<string, string | number>) {
|
||||
// ─── API calls ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchLatest() {
|
||||
const r = await fetch(`${API}/api/var/latest`)
|
||||
if (!r.ok) throw new Error(`Backend inaccessible (${r.status})`)
|
||||
return r.json()
|
||||
}
|
||||
|
||||
async function runNow(params: { confidence: number; horizon_days: number; lookback_days: number; default_iv: number }) {
|
||||
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString()
|
||||
const r = await fetch(`http://localhost:8000/api/var/compute?${qs}`)
|
||||
if (!r.ok) throw new Error(await r.text())
|
||||
const r = await fetch(`${API}/api/var/run-now?${qs}`, { method: 'POST' })
|
||||
if (!r.ok) {
|
||||
const txt = await r.text()
|
||||
throw new Error(txt)
|
||||
}
|
||||
return r.json()
|
||||
}
|
||||
|
||||
async function fetchSnapshots() {
|
||||
const r = await fetch(`${API}/api/var/snapshots?limit=10`)
|
||||
if (!r.ok) return { snapshots: [] }
|
||||
return r.json()
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function MetricCard({
|
||||
label, method, var1d, varNd, cvar, horizon, stressed = false, color,
|
||||
label, method, var1d, varNd, cvar, varEur, cvarEur, horizon, stressed = false, colorClass,
|
||||
}: {
|
||||
label: string
|
||||
method: string
|
||||
var1d: number
|
||||
varNd: number
|
||||
cvar: number
|
||||
horizon: number
|
||||
stressed?: boolean
|
||||
color: string
|
||||
label: string; method: string; var1d: number; varNd: number; cvar: number
|
||||
varEur: number; cvarEur: number; horizon: number; stressed?: boolean; colorClass: string
|
||||
}) {
|
||||
const pct = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(3)}%`
|
||||
const isLoss = (v: number) => v < 0
|
||||
const pct = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(3)}%`
|
||||
const eur = (v: number) => `${v < 0 ? '' : '+'}${v.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €`
|
||||
const displayed = horizon === 1 ? var1d : varNd
|
||||
const displayedEur = horizon === 1 ? varEur : varEur * Math.sqrt(horizon)
|
||||
return (
|
||||
<div className={clsx('bg-dark-800 rounded-xl border p-4', `border-${color}-700/40`)}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={clsx('text-xs font-semibold uppercase tracking-wider', `text-${color}-400`)}>{label}</span>
|
||||
{stressed && (
|
||||
<span className="text-xs bg-orange-900/30 text-orange-400 border border-orange-700/40 px-2 py-0.5 rounded">
|
||||
Stressed ×1.5
|
||||
</span>
|
||||
)}
|
||||
<div className={clsx('bg-dark-800 rounded-xl border p-4', colorClass)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-300">{label}</span>
|
||||
{stressed && <span className="text-[10px] bg-orange-900/30 text-orange-400 border border-orange-700/40 px-1.5 py-0.5 rounded">Stressed ×1.5</span>}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-1">{method}</div>
|
||||
<div className="text-[11px] text-slate-500 mb-3">{method}</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-slate-400 text-xs">VaR 1J ({horizon === 1 ? '1j' : `${horizon}j`})</span>
|
||||
<span className={clsx('text-lg font-bold', isLoss(var1d) ? 'text-red-400' : 'text-emerald-400')}>
|
||||
{pct(horizon === 1 ? var1d : varNd)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 mb-0.5">VaR {horizon}j</div>
|
||||
<div className={clsx('text-2xl font-bold', displayed < 0 ? 'text-red-400' : 'text-emerald-400')}>
|
||||
{pct(displayed)}
|
||||
</div>
|
||||
<div className={clsx('text-xs', displayedEur < 0 ? 'text-red-400/70' : 'text-emerald-400/70')}>
|
||||
{eur(displayedEur)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-slate-400 text-xs">CVaR (ES)</span>
|
||||
<span className={clsx('font-semibold text-sm', isLoss(cvar) ? 'text-orange-400' : 'text-slate-300')}>
|
||||
<div className="border-t border-slate-700/40 pt-2">
|
||||
<div className="text-[10px] text-slate-500 mb-0.5">CVaR (Expected Shortfall)</div>
|
||||
<div className={clsx('text-base font-semibold', cvar < 0 ? 'text-orange-400' : 'text-slate-300')}>
|
||||
{pct(cvar)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx('text-xs', cvarEur < 0 ? 'text-orange-400/70' : 'text-slate-500')}>
|
||||
{eur(cvarEur)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlBar({
|
||||
confidence, setConfidence,
|
||||
horizon, setHorizon,
|
||||
lookback, setLookback,
|
||||
iv, setIv,
|
||||
loading, onRefresh,
|
||||
}: {
|
||||
confidence: number
|
||||
setConfidence: (v: number) => void
|
||||
horizon: number
|
||||
setHorizon: (v: number) => void
|
||||
lookback: number
|
||||
setLookback: (v: number) => void
|
||||
iv: number
|
||||
setIv: (v: number) => void
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
{/* Confidence */}
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-1">Confiance</label>
|
||||
<div className="flex gap-1">
|
||||
{[0.90, 0.95, 0.99].map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setConfidence(c)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
|
||||
confidence === c
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-dark-700 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{(c * 100).toFixed(0)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizon */}
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-1">Horizon</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 5, 10, 21].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setHorizon(h)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
|
||||
horizon === h
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'bg-dark-700 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{h}j
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lookback */}
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-1">Historique</label>
|
||||
<div className="flex gap-1">
|
||||
{[63, 126, 252].map(l => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLookback(l)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
|
||||
lookback === l
|
||||
? 'bg-emerald-700 text-white'
|
||||
: 'bg-dark-700 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{l === 63 ? '3M' : l === 126 ? '6M' : '1A'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IV */}
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-1">IV par défaut</label>
|
||||
<div className="flex gap-1">
|
||||
{[0.15, 0.20, 0.30, 0.40].map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setIv(v)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
|
||||
iv === v
|
||||
? 'bg-amber-600 text-white'
|
||||
: 'bg-dark-700 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{(v * 100).toFixed(0)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold transition-colors',
|
||||
'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', loading && 'animate-spin')} />
|
||||
Recalcul
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Custom tooltip for histogram ─────────────────────────────────────────────
|
||||
|
||||
function HistoTooltip({ active, payload }: any) {
|
||||
if (!active || !payload?.length) return null
|
||||
const { x, count } = payload[0].payload
|
||||
return (
|
||||
<div className="bg-dark-800 border border-slate-700 rounded px-3 py-2 text-xs">
|
||||
<div className="text-slate-300">Ret: <span className="font-bold">{x.toFixed(3)}%</span></div>
|
||||
<div className="text-slate-400">Obs: {count}</div>
|
||||
<div className="bg-dark-800 border border-slate-700 rounded px-2.5 py-1.5 text-xs">
|
||||
<div className="text-slate-300">Ret: <span className="font-bold">{payload[0].payload.x.toFixed(3)}%</span></div>
|
||||
<div className="text-slate-400">Obs: {payload[0].payload.count}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,48 +89,95 @@ function HistoTooltip({ active, payload }: any) {
|
||||
function RollingTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="bg-dark-800 border border-slate-700 rounded px-3 py-2 text-xs">
|
||||
<div className="text-slate-500 mb-1">{label}</div>
|
||||
<div className="bg-dark-800 border border-slate-700 rounded px-2.5 py-1.5 text-xs">
|
||||
<div className="text-slate-500 mb-0.5">{label}</div>
|
||||
<div className="text-red-400 font-semibold">VaR: {payload[0]?.value?.toFixed(3)}%</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
function IdleState({ onCompute, computing }: { onCompute: () => void; computing: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-6">
|
||||
<ShieldAlert className="w-16 h-16 text-slate-700" />
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-semibold text-slate-400 mb-2">Aucun snapshot VaR disponible</h2>
|
||||
<p className="text-slate-600 text-sm max-w-md">
|
||||
Le calcul VaR ne se lance pas automatiquement pour éviter des appels réseau inutiles.
|
||||
Cliquez sur "Calculer maintenant" ou configurez le scheduler dans la Configuration.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCompute}
|
||||
disabled={computing}
|
||||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-6 py-3 rounded-lg font-semibold text-sm"
|
||||
>
|
||||
<Play className={clsx('w-4 h-4', computing && 'animate-pulse')} />
|
||||
{computing ? 'Calcul en cours…' : 'Calculer maintenant'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function VaRAnalysis() {
|
||||
const qc = useQueryClient()
|
||||
const [confidence, setConfidence] = useState(0.95)
|
||||
const [horizon, setHorizon] = useState(1)
|
||||
const [lookback, setLookback] = useState(252)
|
||||
const [iv, setIv] = useState(0.20)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [activeSnap, setActiveSnap] = useState<any>(null) // override from history
|
||||
|
||||
const params = useMemo(() => ({
|
||||
confidence,
|
||||
horizon_days: horizon,
|
||||
lookback_days: lookback,
|
||||
default_iv: iv,
|
||||
}), [confidence, horizon, lookback, iv, refreshKey]) // eslint-disable-line
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['var-compute', params],
|
||||
queryFn: () => fetchVar(params),
|
||||
staleTime: 60_000,
|
||||
// Load last snapshot from DB on mount (no heavy compute)
|
||||
const { data: latestData, isLoading: loadingLatest, error: latestError } = useQuery({
|
||||
queryKey: ['var-latest'],
|
||||
queryFn: fetchLatest,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const varData = data?.var
|
||||
const portfolio = data?.portfolio
|
||||
const histogram: { x: number; count: number }[] = data?.histogram ?? []
|
||||
const rolling: { date: string; var_95: number }[] = data?.rolling_var ?? []
|
||||
const positions: any[] = data?.positions ?? []
|
||||
const backtest = data?.backtest
|
||||
const { data: snapshotsData } = useQuery({
|
||||
queryKey: ['var-snapshots'],
|
||||
queryFn: fetchSnapshots,
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
// Color histogram bars: red if x < VaR threshold
|
||||
// Button-triggered compute + save
|
||||
const { mutate: compute, isPending: computing, error: computeError } = useMutation({
|
||||
mutationFn: () => runNow({ confidence, horizon_days: horizon, lookback_days: lookback, default_iv: iv }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['var-latest'] })
|
||||
qc.invalidateQueries({ queryKey: ['var-snapshots'] })
|
||||
setActiveSnap(null)
|
||||
},
|
||||
})
|
||||
|
||||
// The displayed result: manual history pick OR latest from DB
|
||||
const displayedResult = activeSnap ?? latestData?.snapshot?.full_result
|
||||
const displayedMeta = activeSnap
|
||||
? null
|
||||
: latestData?.snapshot
|
||||
|
||||
const varData = displayedResult?.var
|
||||
const portfolio = displayedResult?.portfolio
|
||||
const histogram: any[] = displayedResult?.histogram ?? []
|
||||
const rolling: any[] = displayedResult?.rolling_var ?? []
|
||||
const positions: any[] = displayedResult?.positions ?? []
|
||||
const backtest = displayedResult?.backtest
|
||||
const histVarThreshold = varData?.historical?.var_1d_pct ?? 0
|
||||
|
||||
const snapshots: any[] = snapshotsData?.snapshots ?? []
|
||||
|
||||
const backendDown = latestError && String(latestError).includes('Backend')
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-screen-xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="p-6 space-y-5 max-w-screen-xl mx-auto">
|
||||
|
||||
{/* Header bar */}
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
@@ -245,231 +185,295 @@ export default function VaRAnalysis() {
|
||||
<h1 className="text-xl font-bold text-white">Analyse VaR — Value at Risk</h1>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Approche delta Black-Scholes · {portfolio ? `${portfolio.n_positions} positions · Notionnel ${portfolio.total_notional_eur.toLocaleString('fr-FR')} EUR` : '—'}
|
||||
{portfolio?.data_source === 'simulated' && (
|
||||
<span className="ml-2 text-amber-400 text-xs">⚠ Données simulées (marché indisponible)</span>
|
||||
Approche delta Black-Scholes
|
||||
{displayedMeta && (
|
||||
<span className="ml-2 text-slate-500">
|
||||
· snapshot du {displayedMeta.computed_at?.slice(0, 16).replace('T', ' ')} UTC
|
||||
{displayedMeta.data_source === 'simulated' && (
|
||||
<span className="ml-2 text-amber-400">⚠ données simulées</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ControlBar
|
||||
confidence={confidence} setConfidence={setConfidence}
|
||||
horizon={horizon} setHorizon={setHorizon}
|
||||
lookback={lookback} setLookback={setLookback}
|
||||
iv={iv} setIv={setIv}
|
||||
loading={isLoading}
|
||||
onRefresh={() => setRefreshKey(k => k + 1)}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
{/* Confidence */}
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">Confiance</div>
|
||||
<div className="flex gap-1">
|
||||
{[0.90, 0.95, 0.99].map(c => (
|
||||
<button key={c} onClick={() => setConfidence(c)}
|
||||
className={clsx('px-2.5 py-1.5 rounded text-xs font-semibold',
|
||||
confidence === c ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
|
||||
{(c * 100).toFixed(0)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Horizon */}
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">Horizon</div>
|
||||
<div className="flex gap-1">
|
||||
{[1, 5, 10, 21].map(h => (
|
||||
<button key={h} onClick={() => setHorizon(h)}
|
||||
className={clsx('px-2.5 py-1.5 rounded text-xs font-semibold',
|
||||
horizon === h ? 'bg-violet-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
|
||||
{h}j
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Lookback */}
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">Historique</div>
|
||||
<div className="flex gap-1">
|
||||
{[63, 126, 252].map(l => (
|
||||
<button key={l} onClick={() => setLookback(l)}
|
||||
className={clsx('px-2.5 py-1.5 rounded text-xs font-semibold',
|
||||
lookback === l ? 'bg-emerald-700 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
|
||||
{l === 63 ? '3M' : l === 126 ? '6M' : '1A'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* IV */}
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">IV défaut</div>
|
||||
<div className="flex gap-1">
|
||||
{[0.15, 0.20, 0.30, 0.40].map(v => (
|
||||
<button key={v} onClick={() => setIv(v)}
|
||||
className={clsx('px-2.5 py-1.5 rounded text-xs font-semibold',
|
||||
iv === v ? 'bg-amber-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
|
||||
{(v * 100).toFixed(0)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Compute button */}
|
||||
<button
|
||||
onClick={() => compute()}
|
||||
disabled={computing}
|
||||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-4 py-2 rounded text-sm font-semibold"
|
||||
>
|
||||
<Play className={clsx('w-3.5 h-3.5', computing && 'animate-pulse')} />
|
||||
{computing ? 'Calcul…' : 'Calculer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-700/40 rounded-xl p-4 text-red-400 flex items-center gap-2">
|
||||
{/* Backend down notice */}
|
||||
{backendDown && (
|
||||
<div className="bg-red-900/20 border border-red-700/40 rounded-xl p-4 text-red-400 flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{String(error)}</span>
|
||||
Backend inaccessible — vérifiez que le serveur FastAPI est démarré sur le port 8000.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.error && (
|
||||
<div className="bg-amber-900/20 border border-amber-700/40 rounded-xl p-4 text-amber-400 flex items-center gap-2">
|
||||
<Info className="w-4 h-4 shrink-0" />
|
||||
<span>{data.error}</span>
|
||||
{/* Compute error */}
|
||||
{computeError && (
|
||||
<div className="bg-red-900/20 border border-red-700/40 rounded-xl p-3 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{String(computeError)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VaR metric cards */}
|
||||
{varData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<MetricCard
|
||||
label="Historique"
|
||||
method="Percentile empirique"
|
||||
var1d={varData.historical.var_1d_pct}
|
||||
varNd={varData.historical.var_nd_pct}
|
||||
cvar={varData.historical.cvar_pct}
|
||||
horizon={horizon}
|
||||
color="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Paramétrique"
|
||||
method="Distribution normale"
|
||||
var1d={varData.parametric.var_1d_pct}
|
||||
varNd={varData.parametric.var_nd_pct}
|
||||
cvar={varData.parametric.cvar_pct}
|
||||
horizon={horizon}
|
||||
color="violet"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Monte Carlo"
|
||||
method={`${(confidence * 100).toFixed(0)}% · vol stressée ×1.5`}
|
||||
var1d={varData.monte_carlo.var_1d_pct}
|
||||
varNd={varData.monte_carlo.var_nd_pct}
|
||||
cvar={varData.monte_carlo.cvar_pct}
|
||||
horizon={horizon}
|
||||
stressed
|
||||
color="orange"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EUR amounts */}
|
||||
{varData && portfolio && (
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ label: 'VaR Hist. 1J', val: varData.historical.var_1d_eur, color: 'blue' },
|
||||
{ label: 'CVaR Hist.', val: varData.historical.cvar_eur, color: 'blue' },
|
||||
{ label: 'VaR Param. 1J', val: varData.parametric.var_1d_eur, color: 'violet' },
|
||||
{ label: 'CVaR Param.', val: varData.parametric.cvar_eur, color: 'violet' },
|
||||
{ label: 'VaR MC Stressed', val: varData.monte_carlo.var_1d_eur, color: 'orange' },
|
||||
{ label: 'CVaR MC', val: varData.monte_carlo.cvar_eur, color: 'orange' },
|
||||
].map(({ label, val, color }) => (
|
||||
<div key={label} className={clsx('bg-dark-800 rounded-lg border p-3', `border-${color}-700/30`)}>
|
||||
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
||||
<div className={clsx('font-bold text-sm', val < 0 ? 'text-red-400' : 'text-emerald-400')}>
|
||||
{val < 0 ? '' : '+'}{val.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €
|
||||
</div>
|
||||
</div>
|
||||
{/* History picker */}
|
||||
{snapshots.length > 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Database className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="text-xs text-slate-500">Historique :</span>
|
||||
<button
|
||||
onClick={() => setActiveSnap(null)}
|
||||
className={clsx('text-xs px-2 py-1 rounded', !activeSnap ? 'bg-blue-900/40 text-blue-400 border border-blue-700/40' : 'bg-dark-700 text-slate-500 hover:text-slate-300')}
|
||||
>
|
||||
Dernier
|
||||
</button>
|
||||
{snapshots.slice(1).map((s: any) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
fetch(`${API}/api/var/snapshots/${s.id}`).then(r => r.json()).then(d => setActiveSnap(d.full_result))
|
||||
}}
|
||||
className={clsx('text-xs px-2 py-1 rounded',
|
||||
activeSnap?.snapshot_id === s.id
|
||||
? 'bg-violet-900/40 text-violet-400 border border-violet-700/40'
|
||||
: 'bg-dark-700 text-slate-500 hover:text-slate-300')}
|
||||
>
|
||||
{s.computed_at?.slice(5, 16).replace('T', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Returns distribution histogram */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="w-4 h-4 text-slate-400" />
|
||||
<h3 className="text-sm font-semibold text-white">Distribution des Retours</h3>
|
||||
<span className="text-xs text-slate-500 ml-auto">— trait rouge = VaR hist.</span>
|
||||
</div>
|
||||
{histogram.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={histogram} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="x" tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||
<Tooltip content={<HistoTooltip />} />
|
||||
<ReferenceLine x={histVarThreshold} stroke="#f87171" strokeDasharray="4 2" strokeWidth={2} />
|
||||
<Bar dataKey="count" radius={[2, 2, 0, 0]}>
|
||||
{histogram.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.x < histVarThreshold ? '#ef4444' : '#3b82f6'} fillOpacity={0.75} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[220px] flex items-center justify-center text-slate-600 text-sm">
|
||||
{isLoading ? 'Calcul en cours…' : 'Aucune donnée'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Empty / loading state */}
|
||||
{loadingLatest && !displayedResult && (
|
||||
<div className="text-center py-16 text-slate-600">Chargement du dernier snapshot…</div>
|
||||
)}
|
||||
|
||||
{/* Rolling VaR */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingDown className="w-4 h-4 text-red-400" />
|
||||
<h3 className="text-sm font-semibold text-white">VaR 95% Glissante (fenêtre 30J)</h3>
|
||||
</div>
|
||||
{rolling.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={rolling} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||
tickFormatter={d => d.slice(5)} // MM-DD
|
||||
interval={Math.floor(rolling.length / 6)}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 10, fill: '#94a3b8' }} unit="%" />
|
||||
<Tooltip content={<RollingTooltip />} />
|
||||
<ReferenceLine y={0} stroke="#475569" />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="var_95"
|
||||
stroke="#f87171"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[220px] flex items-center justify-center text-slate-600 text-sm">
|
||||
{isLoading ? 'Calcul en cours…' : 'Historique insuffisant'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!loadingLatest && !displayedResult && !backendDown && (
|
||||
<IdleState onCompute={() => compute()} computing={computing} />
|
||||
)}
|
||||
|
||||
{/* Bottom row: positions + backtest */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Positions deltas */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Positions & Deltas</h3>
|
||||
{positions.length === 0 ? (
|
||||
<p className="text-slate-600 text-sm">{isLoading ? 'Chargement…' : 'Aucune position'}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{positions.map((p, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs py-1.5 border-b border-slate-700/30 last:border-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-white font-semibold">{p.ticker}</span>
|
||||
<span className="text-slate-400 ml-2">{p.strategy}</span>
|
||||
{p.pattern && <div className="text-slate-600 truncate">{p.pattern}</div>}
|
||||
{/* ── Results ── */}
|
||||
{varData && portfolio && (
|
||||
<>
|
||||
{/* Portfolio summary strip */}
|
||||
<div className="flex gap-4 flex-wrap text-xs">
|
||||
{[
|
||||
{ label: 'Positions', val: String(portfolio.n_positions) },
|
||||
{ label: 'Notionnel', val: `${portfolio.total_notional_eur.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €` },
|
||||
{ label: 'Confiance', val: `${portfolio.confidence_pct}%` },
|
||||
{ label: 'Lookback', val: `${portfolio.lookback_days}j` },
|
||||
{ label: 'Source', val: portfolio.data_source === 'simulated' ? '⚠ Simulée' : '✓ Live' },
|
||||
].map(({ label, val }) => (
|
||||
<div key={label} className="bg-dark-800 border border-slate-700/30 rounded-lg px-3 py-2">
|
||||
<div className="text-slate-500">{label}</div>
|
||||
<div className="text-white font-semibold">{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 3 VaR method cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<MetricCard
|
||||
label="Historique" method="Percentile empirique"
|
||||
var1d={varData.historical.var_1d_pct} varNd={varData.historical.var_nd_pct}
|
||||
cvar={varData.historical.cvar_pct}
|
||||
varEur={varData.historical.var_1d_eur} cvarEur={varData.historical.cvar_eur}
|
||||
horizon={horizon} colorClass="border-blue-700/40"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Paramétrique" method="Distribution normale"
|
||||
var1d={varData.parametric.var_1d_pct} varNd={varData.parametric.var_nd_pct}
|
||||
cvar={varData.parametric.cvar_pct}
|
||||
varEur={varData.parametric.var_1d_eur} cvarEur={varData.parametric.cvar_eur}
|
||||
horizon={horizon} colorClass="border-violet-700/40"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Monte Carlo" method="Vol stressée ×1.5"
|
||||
var1d={varData.monte_carlo.var_1d_pct} varNd={varData.monte_carlo.var_nd_pct}
|
||||
cvar={varData.monte_carlo.cvar_pct}
|
||||
varEur={varData.monte_carlo.var_1d_eur} cvarEur={varData.monte_carlo.cvar_eur}
|
||||
horizon={horizon} colorClass="border-orange-700/40" stressed
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Histogram */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="w-4 h-4 text-slate-400" />
|
||||
<h3 className="text-sm font-semibold text-white">Distribution des Retours</h3>
|
||||
<span className="text-[10px] text-slate-600 ml-auto">trait rouge = VaR hist.</span>
|
||||
</div>
|
||||
{histogram.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={histogram} margin={{ top: 2, right: 4, left: -22, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="x" tick={{ fontSize: 9, fill: '#64748b' }} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} />
|
||||
<Tooltip content={<HistoTooltip />} />
|
||||
<ReferenceLine x={histVarThreshold} stroke="#f87171" strokeDasharray="4 2" strokeWidth={1.5} />
|
||||
<Bar dataKey="count" radius={[2, 2, 0, 0]}>
|
||||
{histogram.map((e: any, i: number) => (
|
||||
<Cell key={i} fill={e.x < histVarThreshold ? '#ef4444' : '#3b82f6'} fillOpacity={0.7} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[210px] flex items-center justify-center text-slate-600 text-sm">Aucune donnée</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rolling VaR */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingDown className="w-4 h-4 text-red-400" />
|
||||
<h3 className="text-sm font-semibold text-white">VaR 95% Glissante (fenêtre 30J)</h3>
|
||||
</div>
|
||||
{rolling.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<LineChart data={rolling} margin={{ top: 2, right: 4, left: -22, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 8, fill: '#64748b' }}
|
||||
tickFormatter={d => d.slice(5)} interval={Math.floor(rolling.length / 5)} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} unit="%" />
|
||||
<Tooltip content={<RollingTooltip />} />
|
||||
<ReferenceLine y={0} stroke="#475569" />
|
||||
<Line type="monotone" dataKey="var_95" stroke="#f87171" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[210px] flex items-center justify-center text-slate-600 text-sm">Historique insuffisant</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Positions + Kupiec */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Positions */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Positions & Deltas</h3>
|
||||
<div className="space-y-1.5">
|
||||
{positions.map((p: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs py-1 border-b border-slate-700/20 last:border-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-white font-semibold">{p.ticker}</span>
|
||||
<span className="text-slate-400 ml-2">{p.strategy}</span>
|
||||
{p.pattern && <div className="text-slate-600 truncate text-[11px]">{p.pattern}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4 shrink-0">
|
||||
<span className={clsx('font-mono font-semibold',
|
||||
Math.abs(p.delta) < 0.05 ? 'text-slate-500'
|
||||
: p.delta > 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
Δ {p.delta > 0 ? '+' : ''}{p.delta.toFixed(3)}
|
||||
</span>
|
||||
<span className="text-slate-500 font-mono">
|
||||
{p.notional.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 shrink-0 ml-4">
|
||||
<span className={clsx(
|
||||
'font-mono font-semibold',
|
||||
Math.abs(p.delta) < 0.05 ? 'text-slate-500'
|
||||
: p.delta > 0 ? 'text-emerald-400' : 'text-red-400'
|
||||
)}>
|
||||
Δ {p.delta > 0 ? '+' : ''}{p.delta.toFixed(3)}
|
||||
</span>
|
||||
<span className="text-slate-500 font-mono">{p.notional.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kupiec backtest */}
|
||||
{backtest && (
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Backtest — Test de Kupiec</h3>
|
||||
<div className="space-y-2.5">
|
||||
{[
|
||||
{ label: 'Observations', val: String(backtest.n_observations) },
|
||||
{ label: 'Violations VaR', val: String(backtest.n_breaches) },
|
||||
{ label: 'Taux réel', val: `${backtest.breach_rate_pct}%`, color: backtest.kupiec_ok ? 'text-emerald-400' : 'text-red-400' },
|
||||
{ label: 'Taux attendu', val: `${backtest.expected_breach_rate_pct}%`, color: 'text-slate-300' },
|
||||
].map(({ label, val, color }) => (
|
||||
<div key={label} className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className={clsx('font-semibold', color ?? 'text-white')}>{val}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={clsx('flex items-center gap-2 p-2.5 rounded-lg text-xs mt-2',
|
||||
backtest.kupiec_ok
|
||||
? 'bg-emerald-900/20 border border-emerald-700/40 text-emerald-400'
|
||||
: 'bg-red-900/20 border border-red-700/40 text-red-400')}>
|
||||
{backtest.kupiec_ok
|
||||
? '✓ Modèle validé — violations dans la tolérance'
|
||||
: '✗ Excès de violations — modèle sous-estime le risque'}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-600">
|
||||
Règle Kupiec : taux réel ≤ {(backtest.expected_breach_rate_pct * 2).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backtest Kupiec */}
|
||||
{backtest && (
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Backtest — Test de Kupiec</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-400 text-sm">Observations</span>
|
||||
<span className="text-white font-semibold">{backtest.n_observations}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-400 text-sm">Violations VaR</span>
|
||||
<span className="text-white font-semibold">{backtest.n_breaches}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-400 text-sm">Taux réel</span>
|
||||
<span className={clsx('font-semibold', backtest.kupiec_ok ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{backtest.breach_rate_pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-400 text-sm">Taux attendu</span>
|
||||
<span className="text-slate-300">{backtest.expected_breach_rate_pct}%</span>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2 p-3 rounded-lg text-sm',
|
||||
backtest.kupiec_ok
|
||||
? 'bg-emerald-900/20 border border-emerald-700/40 text-emerald-400'
|
||||
: 'bg-red-900/20 border border-red-700/40 text-red-400'
|
||||
)}>
|
||||
{backtest.kupiec_ok
|
||||
? '✓ Modèle validé — violations dans la tolérance'
|
||||
: '✗ Excès de violations — modèle sous-estime le risque'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 mt-1">
|
||||
Règle : taux réel ≤ 2× taux attendu ({backtest.expected_breach_rate_pct * 2}%)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user