#!/usr/bin/env python3 """ ================================================================ Auteur : Simon-Pierre Boucher Contact : contact@spboucher.ai Projet : Prévision de volatilité réalisée multi-actifs (HAR-RV vs GARCH vs Machine Learning) Fichier : 06_economic_value.py Description : Étape 06 — Valeur économique : volatility timing (Sharpe net, sensibilité aux coûts), gains d'utilité mean-variance vs HAR, backtests VaR (Kupiec, Christoffersen). Usage : python scripts/06_economic_value.py ================================================================ """ from __future__ import annotations import logging import sys from pathlib import Path import numpy as np import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from wp12 import backtest as bt, config # noqa: E402 logger = logging.getLogger("06_economic_value") OUT = config.path("reproduced") CFG = config.load_config()["backtest"] # models carried into the economic-value exercise (h=1 forecasts) EV_MODELS = [ "HAR", "LogHAR", "HARQ", "SHAR", "HAR-CJ", "GARCH", "GJR", "EGARCH", "RealGARCH", "Ridge-X", "LASSO-X", "RF-X", "XGBoost-X", "LightGBM-X", "LSTM", "Transformer", "Pooled-LGBM", "Comb-Mean", "Comb-InvMSE", ] def _load() -> tuple[pd.DataFrame, dict[str, pd.Series], dict[str, str]]: """Merged h=1 forecasts + daily returns per ticker + class map.""" merged = pd.read_parquet(config.path("processed") / "merged_forecasts.parquet") merged["date"] = pd.to_datetime(merged["date"]) merged = merged[(merged["h"] == 1) & merged["model"].isin(EV_MODELS)] panel = pd.read_parquet(config.path("processed") / "panel.parquet") rets = {tk: d.sort_index()["ret_cc"] for tk, d in panel.groupby("ticker")} cls = panel.groupby("ticker")["cls"].first().to_dict() return merged, rets, cls def timing_tables(merged: pd.DataFrame, rets: dict, cls: dict) -> None: """Volatility-timing Sharpe (net) per model, plus cost sensitivity.""" rows = [] for (tk, m), sub in merged.groupby(["ticker", "model"], observed=True): pv = sub.set_index("date")["forecast"] ret = rets[tk] for tc in CFG["tc_grid_bps"]: led = bt.volatility_timing( ret, pv, vol_target_ann=CFG["vol_target_ann"], leverage_cap=CFG["leverage_cap"], tc_bps=float(tc)) st = bt.perf_stats(led["net"]) rows.append((tk, cls[tk], m, tc, st["ann_ret"], st["ann_vol"], st["sharpe"], st["max_dd"], float(led["weight"].diff().abs().mean()))) tab = pd.DataFrame(rows, columns=[ "ticker", "cls", "model", "tc_bps", "ann_ret", "ann_vol", "sharpe", "max_dd", "turnover"]) tab.to_csv(OUT / "timing_by_ticker.csv", index=False) base = tab[tab["tc_bps"] == CFG["tc_bps"]] summary = base.groupby(["cls", "model"], observed=True)["sharpe"].mean().reset_index() overall = base.groupby("model", observed=True)[["sharpe", "ann_ret", "ann_vol", "turnover"]].mean().reset_index() overall["cls"] = "all" summary.to_csv(OUT / "timing_by_class.csv", index=False) overall.to_csv(OUT / "timing_overall.csv", index=False) logger.info("timing tables written") def utility_tables(merged: pd.DataFrame, rets: dict, cls: dict) -> None: """Annualized utility gain (bps) of each model over HAR.""" gamma = CFG["risk_aversion"] rows = [] for tk, sub in merged.groupby("ticker", observed=True): ret = rets[tk] nets = {} for m, s in sub.groupby("model", observed=True): pv = s.set_index("date")["forecast"] led = bt.volatility_timing( ret, pv, vol_target_ann=CFG["vol_target_ann"], leverage_cap=CFG["leverage_cap"], tc_bps=float(CFG["tc_bps"])) nets[m] = led["net"] if "HAR" not in nets: continue for m, net in nets.items(): if m == "HAR": continue gain = bt.utility_gain(net, nets["HAR"], risk_aversion=gamma) rows.append((tk, cls[tk], m, gain * 1e4)) tab = pd.DataFrame(rows, columns=["ticker", "cls", "model", "utility_gain_bps"]) tab.to_csv(OUT / "utility_by_ticker.csv", index=False) tab.groupby("model", observed=True)["utility_gain_bps"].agg( ["mean", "median"]).reset_index().to_csv(OUT / "utility_summary.csv", index=False) logger.info("utility tables written") def var_tables(merged: pd.DataFrame, rets: dict, cls: dict) -> None: """VaR 1% / 5% backtests; RV forecasts scaled to return variance with a trailing 250-day factor (no look-ahead).""" rows = [] for (tk, m), sub in merged.groupby(["ticker", "model"], observed=True): pv = sub.set_index("date")["forecast"] ret = rets[tk] # trailing conversion factor: return variance per unit of RV rv_al = pv.reindex(ret.index) c = ((ret**2).rolling(250).mean() / rv_al.rolling(250).mean()).clip(0.2, 5.0) pv_ret = (rv_al * c).dropna() for level in CFG["var_levels"]: res = bt.var_backtest(ret, pv_ret, float(level)) rows.append((tk, cls[tk], m, level, res["n"], res["viol_rate"], res["kupiec_p"], res["christoffersen_p"])) tab = pd.DataFrame(rows, columns=[ "ticker", "cls", "model", "level", "n", "viol_rate", "kupiec_p", "christoffersen_p"]) tab.to_csv(OUT / "var_by_ticker.csv", index=False) summary = tab.groupby(["model", "level"], observed=True).apply( lambda d: pd.Series({ "mean_viol_rate": d["viol_rate"].mean(), "pct_pass_kupiec": float((d["kupiec_p"] > 0.05).mean()), "pct_pass_cc": float((d["christoffersen_p"] > 0.05).mean()), }), include_groups=False).reset_index() summary.to_csv(OUT / "var_summary.csv", index=False) logger.info("VaR tables written") def main() -> None: """Run all economic-value blocks.""" config.setup_logging() merged, rets, cls = _load() timing_tables(merged, rets, cls) utility_tables(merged, rets, cls) var_tables(merged, rets, cls) logger.info("economic value complete") if __name__ == "__main__": main()