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