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:
OpenSquared
2026-06-20 06:21:20 +02:00
parent d64d1029bf
commit b4f3089c58
7 changed files with 1014 additions and 394 deletions

View File

@@ -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)

View File

@@ -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()}

View File

@@ -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)")

View 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()),
},
}

View File

@@ -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