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
|
||||
|
||||
Reference in New Issue
Block a user