Core pipeline: API client, RV measures, econ/ML/eval/robustness/backtest modules, tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 changed files +3,014 −1
modified
config.yaml
+1 −1
@@ -14,7 +14,7 @@ api: | ||
| 14 | 14 | max_retries: 5 |
| 15 | 15 | backoff_base_sec: 1.0 # exponential backoff: base * 2**attempt |
| 16 | 16 | timeout_sec: 60 |
| 17 | − page_limit: 50000 # max rows per request page | |
| 17 | + page_limit: 100000 # max rows per request page (validated live) | |
| 18 | 18 | cache_dir: "data/raw" # parquet cache, one file per (asset, ticker, timeframe) |
| 19 | 19 | |
| 20 | 20 | # ---------------------------------------------------------------- universe |
added
scripts/01_download_data.py
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +================================================================ | |
| 4 | +Auteur : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 7 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 8 | +Fichier : 01_download_data.py | |
| 9 | +Description : Étape 01 — Téléchargement des barres 1-min et daily | |
| 10 | + pour tout l'univers (cache parquet data/raw). | |
| 11 | +Usage : python scripts/01_download_data.py [--refresh] | |
| 12 | +================================================================ | |
| 13 | +""" | |
| 14 | + | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import argparse | |
| 18 | +import logging | |
| 19 | +import sys | |
| 20 | +from pathlib import Path | |
| 21 | + | |
| 22 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 23 | + | |
| 24 | +from wp12 import config, data_pipeline # noqa: E402 | |
| 25 | +from wp12.api_client import HFMarketDataClient # noqa: E402 | |
| 26 | + | |
| 27 | +logger = logging.getLogger("01_download") | |
| 28 | + | |
| 29 | + | |
| 30 | +def main() -> None: | |
| 31 | + """Download 1-min and daily bars for every instrument in the universe.""" | |
| 32 | + ap = argparse.ArgumentParser() | |
| 33 | + ap.add_argument("--refresh", action="store_true", help="force re-download") | |
| 34 | + args = ap.parse_args() | |
| 35 | + | |
| 36 | + config.setup_logging() | |
| 37 | + logging.getLogger("urllib3").setLevel(logging.WARNING) | |
| 38 | + client = HFMarketDataClient() | |
| 39 | + todo = config.universe(include_predictors=True) | |
| 40 | + for i, inst in enumerate(todo, 1): | |
| 41 | + logger.info("[%d/%d] %s (%s)", i, len(todo), inst["ticker"], inst["asset"]) | |
| 42 | + bars = data_pipeline.download_instrument(client, inst, refresh=args.refresh) | |
| 43 | + for tf, df in bars.items(): | |
| 44 | + logger.info(" %s: %d rows", tf, len(df)) | |
| 45 | + | |
| 46 | + | |
| 47 | +if __name__ == "__main__": | |
| 48 | + main() | |
added
scripts/02_build_rv.py
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +================================================================ | |
| 4 | +Auteur : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 7 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 8 | +Fichier : 02_build_rv.py | |
| 9 | +Description : Étape 02 — Construction des mesures de volatilité | |
| 10 | + réalisée journalières et du panel multi-actifs. | |
| 11 | +Usage : python scripts/02_build_rv.py [--refresh] | |
| 12 | +================================================================ | |
| 13 | +""" | |
| 14 | + | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import argparse | |
| 18 | +import logging | |
| 19 | +import sys | |
| 20 | +from pathlib import Path | |
| 21 | + | |
| 22 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 23 | + | |
| 24 | +from wp12 import config, data_pipeline # noqa: E402 | |
| 25 | + | |
| 26 | +logger = logging.getLogger("02_build_rv") | |
| 27 | + | |
| 28 | + | |
| 29 | +def main() -> None: | |
| 30 | + """Build daily realized measures for the universe and save the panel.""" | |
| 31 | + ap = argparse.ArgumentParser() | |
| 32 | + ap.add_argument("--refresh", action="store_true", help="recompute RV files") | |
| 33 | + args = ap.parse_args() | |
| 34 | + | |
| 35 | + config.setup_logging() | |
| 36 | + logging.getLogger("urllib3").setLevel(logging.WARNING) | |
| 37 | + panel = data_pipeline.build_panel(refresh=args.refresh) | |
| 38 | + logger.info("panel summary:\n%s", | |
| 39 | + panel.groupby("ticker")["rv5ss"].agg(["count", "mean"])) | |
| 40 | + | |
| 41 | + | |
| 42 | +if __name__ == "__main__": | |
| 43 | + main() | |
added
scripts/03_run_models.py
+153 −0
@@ -0,0 +1,153 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +================================================================ | |
| 4 | +Auteur : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 7 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 8 | +Fichier : 03_run_models.py | |
| 9 | +Description : Étape 03 — Prévisions out-of-sample roulantes pour | |
| 10 | + toutes les familles de modèles (HAR, GARCH, ML, | |
| 11 | + deep, pooled), parallélisées par actif. | |
| 12 | +Usage : python scripts/03_run_models.py --family econ|ml|deep|pooled|all | |
| 13 | + [--tickers SPY,BTC] [--workers 8] | |
| 14 | +================================================================ | |
| 15 | +""" | |
| 16 | + | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import argparse | |
| 20 | +import logging | |
| 21 | +import sys | |
| 22 | +from concurrent.futures import ProcessPoolExecutor, as_completed | |
| 23 | +from pathlib import Path | |
| 24 | + | |
| 25 | +import pandas as pd | |
| 26 | + | |
| 27 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 28 | + | |
| 29 | +from wp12 import config # noqa: E402 | |
| 30 | + | |
| 31 | +logger = logging.getLogger("03_run_models") | |
| 32 | + | |
| 33 | +FC_DIR = config.path("processed") / "forecasts" | |
| 34 | + | |
| 35 | +HORIZONS = tuple(config.load_config()["evaluation"]["horizons"]) | |
| 36 | +WINDOW = int(config.load_config()["evaluation"]["estimation_window_days"]) | |
| 37 | +REFIT = int(config.load_config()["evaluation"]["reestimation_freq_days"]) | |
| 38 | + | |
| 39 | + | |
| 40 | +def _load_rv(ticker: str) -> pd.DataFrame: | |
| 41 | + return pd.read_parquet(config.path("processed") / f"rv_{ticker}.parquet") | |
| 42 | + | |
| 43 | + | |
| 44 | +def run_econ(ticker: str) -> str: | |
| 45 | + """All HAR-family and GARCH-family forecasts for one ticker.""" | |
| 46 | + from wp12 import models_econ as me | |
| 47 | + | |
| 48 | + df = _load_rv(ticker) | |
| 49 | + parts = [] | |
| 50 | + for spec in ["HAR", "HAR-J", "HAR-CJ", "SHAR", "HARQ", "LogHAR"]: | |
| 51 | + parts.append(me.rolling_har_forecasts( | |
| 52 | + df, spec, horizons=HORIZONS, window=WINDOW, refit_every=REFIT)) | |
| 53 | + for kind in ["GARCH", "GJR", "EGARCH"]: | |
| 54 | + parts.append(me.rolling_garch_forecasts( | |
| 55 | + df, kind, horizons=HORIZONS, window=WINDOW, refit_every=REFIT)) | |
| 56 | + parts.append(me.rolling_realgarch_forecasts( | |
| 57 | + df, horizons=HORIZONS, window=WINDOW, refit_every=REFIT)) | |
| 58 | + out = pd.concat(parts, ignore_index=True) | |
| 59 | + out["ticker"] = ticker | |
| 60 | + f = FC_DIR / f"econ_{ticker}.parquet" | |
| 61 | + out.to_parquet(f, index=False) | |
| 62 | + return f"{ticker}: {len(out)} econ forecasts" | |
| 63 | + | |
| 64 | + | |
| 65 | +def run_ml(ticker: str) -> str: | |
| 66 | + """Tabular ML forecasts (2 feature sets x 6 estimators) for one ticker.""" | |
| 67 | + from wp12 import models_ml as ml | |
| 68 | + | |
| 69 | + panel = pd.read_parquet(config.path("processed") / "panel.parquet") | |
| 70 | + frames = ml.ml_features(panel) | |
| 71 | + frame = frames[ticker] | |
| 72 | + parts = [] | |
| 73 | + for name in ["LASSO", "Ridge", "ElasticNet", "RF", "XGBoost", "LightGBM"]: | |
| 74 | + for fs in ["har", "extended"]: | |
| 75 | + parts.append(ml.rolling_ml_forecasts( | |
| 76 | + frame, name, feature_set=fs, horizons=HORIZONS, | |
| 77 | + window=WINDOW, refit_every=REFIT)) | |
| 78 | + out = pd.concat(parts, ignore_index=True) | |
| 79 | + out["ticker"] = ticker | |
| 80 | + f = FC_DIR / f"ml_{ticker}.parquet" | |
| 81 | + out.to_parquet(f, index=False) | |
| 82 | + return f"{ticker}: {len(out)} ML forecasts" | |
| 83 | + | |
| 84 | + | |
| 85 | +def run_deep(ticker: str) -> str: | |
| 86 | + """LSTM and Transformer forecasts for one ticker.""" | |
| 87 | + from wp12 import models_ml as ml | |
| 88 | + | |
| 89 | + panel = pd.read_parquet(config.path("processed") / "panel.parquet") | |
| 90 | + frames = ml.ml_features(panel) | |
| 91 | + frame = frames[ticker] | |
| 92 | + parts = [ | |
| 93 | + ml.rolling_deep_forecasts(frame, arch, horizons=HORIZONS, window=WINDOW) | |
| 94 | + for arch in ["LSTM", "Transformer"] | |
| 95 | + ] | |
| 96 | + out = pd.concat(parts, ignore_index=True) | |
| 97 | + out["ticker"] = ticker | |
| 98 | + f = FC_DIR / f"deep_{ticker}.parquet" | |
| 99 | + out.to_parquet(f, index=False) | |
| 100 | + return f"{ticker}: {len(out)} deep forecasts" | |
| 101 | + | |
| 102 | + | |
| 103 | +def run_pooled() -> str: | |
| 104 | + """Pooled multi-asset LightGBM (single job, all tickers jointly).""" | |
| 105 | + from wp12 import models_ml as ml | |
| 106 | + | |
| 107 | + panel = pd.read_parquet(config.path("processed") / "panel.parquet") | |
| 108 | + frames = ml.ml_features(panel) | |
| 109 | + out = ml.rolling_pooled_lgbm( | |
| 110 | + frames, horizons=HORIZONS, window=WINDOW, refit_every=REFIT) | |
| 111 | + f = FC_DIR / "pooled.parquet" | |
| 112 | + out.to_parquet(f, index=False) | |
| 113 | + return f"pooled: {len(out)} forecasts" | |
| 114 | + | |
| 115 | + | |
| 116 | +def main() -> None: | |
| 117 | + """Dispatch model runs across tickers with a process pool.""" | |
| 118 | + ap = argparse.ArgumentParser() | |
| 119 | + ap.add_argument("--family", default="all", | |
| 120 | + choices=["econ", "ml", "deep", "pooled", "all"]) | |
| 121 | + ap.add_argument("--tickers", default=None, help="comma-separated subset") | |
| 122 | + ap.add_argument("--workers", type=int, default=8) | |
| 123 | + args = ap.parse_args() | |
| 124 | + | |
| 125 | + config.setup_logging() | |
| 126 | + FC_DIR.mkdir(parents=True, exist_ok=True) | |
| 127 | + tickers = ( | |
| 128 | + args.tickers.split(",") if args.tickers | |
| 129 | + else [i["ticker"] for i in config.universe()] | |
| 130 | + ) | |
| 131 | + jobs: list[tuple] = [] | |
| 132 | + if args.family in ("econ", "all"): | |
| 133 | + jobs += [(run_econ, tk) for tk in tickers] | |
| 134 | + if args.family in ("ml", "all"): | |
| 135 | + jobs += [(run_ml, tk) for tk in tickers] | |
| 136 | + if args.family in ("deep", "all"): | |
| 137 | + jobs += [(run_deep, tk) for tk in tickers] | |
| 138 | + | |
| 139 | + if jobs: | |
| 140 | + with ProcessPoolExecutor(max_workers=args.workers) as pool: | |
| 141 | + futs = {pool.submit(fn, tk): (fn.__name__, tk) for fn, tk in jobs} | |
| 142 | + for fut in as_completed(futs): | |
| 143 | + name, tk = futs[fut] | |
| 144 | + try: | |
| 145 | + logger.info("done %s %s -> %s", name, tk, fut.result()) | |
| 146 | + except Exception as exc: # noqa: BLE001 | |
| 147 | + logger.error("FAILED %s %s: %s", name, tk, exc) | |
| 148 | + if args.family in ("pooled", "all"): | |
| 149 | + logger.info(run_pooled()) | |
| 150 | + | |
| 151 | + | |
| 152 | +if __name__ == "__main__": | |
| 153 | + main() | |
added
src/wp12/__init__.py
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : __init__.py | |
| 8 | +Description : Package WP12 — pipeline de prévision de volatilité | |
| 9 | + réalisée multi-actifs (UQO Working Paper No. 12). | |
| 10 | +================================================================ | |
| 11 | +""" | |
| 12 | + | |
| 13 | +__version__ = "0.1.0" | |
| 14 | +__author__ = "Simon-Pierre Boucher" | |
| 15 | +__email__ = "contact@spboucher.ai" | |
added
src/wp12/api_client.py
+256 −0
@@ -0,0 +1,256 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : api_client.py | |
| 8 | +Description : Client réutilisable pour l'API HF Market Data — | |
| 9 | + rate limiting, retry avec backoff exponentiel, | |
| 10 | + pagination et cache local parquet. | |
| 11 | +================================================================ | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import io | |
| 17 | +import logging | |
| 18 | +import time | |
| 19 | +from pathlib import Path | |
| 20 | +from typing import Any | |
| 21 | + | |
| 22 | +import pandas as pd | |
| 23 | +import requests | |
| 24 | + | |
| 25 | +from . import config | |
| 26 | + | |
| 27 | +logger = logging.getLogger(__name__) | |
| 28 | + | |
| 29 | +_RETRYABLE = {429, 500, 502, 503, 504} | |
| 30 | + | |
| 31 | + | |
| 32 | +class HFMarketDataClient: | |
| 33 | + """Client for https://www.hfmarketdata.io with caching and retries. | |
| 34 | + | |
| 35 | + Parameters | |
| 36 | + ---------- | |
| 37 | + base_url : str, optional | |
| 38 | + API root; defaults to the value in ``config.yaml``. | |
| 39 | + cache_dir : pathlib.Path, optional | |
| 40 | + Directory for the local parquet cache; defaults to ``data/raw``. | |
| 41 | + | |
| 42 | + Notes | |
| 43 | + ----- | |
| 44 | + Bars are fetched in ascending order and paginated by advancing the | |
| 45 | + ``start`` parameter one minute past the last received timestamp until | |
| 46 | + an empty page is returned — this is robust to any server-side row cap. | |
| 47 | + """ | |
| 48 | + | |
| 49 | + def __init__(self, base_url: str | None = None, cache_dir: Path | None = None) -> None: | |
| 50 | + cfg = config.load_config()["api"] | |
| 51 | + self.base_url = (base_url or cfg["base_url"]).rstrip("/") | |
| 52 | + self.cache_dir = Path(cache_dir) if cache_dir else config.path("raw") | |
| 53 | + self.min_interval = 1.0 / float(cfg.get("rate_limit_per_sec", 4)) | |
| 54 | + self.max_retries = int(cfg.get("max_retries", 5)) | |
| 55 | + self.backoff_base = float(cfg.get("backoff_base_sec", 1.0)) | |
| 56 | + self.timeout = float(cfg.get("timeout_sec", 60)) | |
| 57 | + self.page_limit = int(cfg.get("page_limit", 50_000)) | |
| 58 | + self._last_request_ts = 0.0 | |
| 59 | + self._session = requests.Session() | |
| 60 | + | |
| 61 | + # ------------------------------------------------------------- low level | |
| 62 | + def _throttle(self) -> None: | |
| 63 | + """Sleep as needed to respect the configured request rate.""" | |
| 64 | + wait = self._last_request_ts + self.min_interval - time.monotonic() | |
| 65 | + if wait > 0: | |
| 66 | + time.sleep(wait) | |
| 67 | + self._last_request_ts = time.monotonic() | |
| 68 | + | |
| 69 | + def _get(self, path: str, params: dict[str, Any] | None = None) -> requests.Response: | |
| 70 | + """Issue a GET with throttling and exponential-backoff retries. | |
| 71 | + | |
| 72 | + Parameters | |
| 73 | + ---------- | |
| 74 | + path : str | |
| 75 | + Path relative to the API root (e.g. ``/v1/status``). | |
| 76 | + params : dict, optional | |
| 77 | + Query-string parameters. | |
| 78 | + | |
| 79 | + Returns | |
| 80 | + ------- | |
| 81 | + requests.Response | |
| 82 | + The successful response (status code < 400). | |
| 83 | + | |
| 84 | + Raises | |
| 85 | + ------ | |
| 86 | + requests.HTTPError | |
| 87 | + If the request keeps failing after ``max_retries`` attempts. | |
| 88 | + """ | |
| 89 | + url = f"{self.base_url}{path}" | |
| 90 | + last_exc: Exception | None = None | |
| 91 | + for attempt in range(self.max_retries + 1): | |
| 92 | + self._throttle() | |
| 93 | + try: | |
| 94 | + resp = self._session.get(url, params=params, timeout=self.timeout) | |
| 95 | + if resp.status_code in _RETRYABLE: | |
| 96 | + raise requests.HTTPError(f"HTTP {resp.status_code}", response=resp) | |
| 97 | + resp.raise_for_status() | |
| 98 | + return resp | |
| 99 | + except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as exc: | |
| 100 | + resp_obj = getattr(exc, "response", None) | |
| 101 | + if resp_obj is not None and resp_obj.status_code not in _RETRYABLE: | |
| 102 | + raise | |
| 103 | + last_exc = exc | |
| 104 | + delay = self.backoff_base * (2**attempt) | |
| 105 | + logger.warning( | |
| 106 | + "GET %s failed (%s), retry %d/%d in %.1fs", | |
| 107 | + path, exc, attempt + 1, self.max_retries, delay, | |
| 108 | + ) | |
| 109 | + time.sleep(delay) | |
| 110 | + raise requests.HTTPError(f"GET {url} failed after {self.max_retries} retries") from last_exc | |
| 111 | + | |
| 112 | + def get_json(self, path: str, params: dict[str, Any] | None = None) -> Any: | |
| 113 | + """GET a JSON endpoint and return the decoded payload.""" | |
| 114 | + return self._get(path, params).json() | |
| 115 | + | |
| 116 | + # ------------------------------------------------------------- endpoints | |
| 117 | + def status(self) -> dict[str, Any]: | |
| 118 | + """Return the ``/v1/status`` dataset inventory.""" | |
| 119 | + return self.get_json("/v1/status") | |
| 120 | + | |
| 121 | + def tickers(self, asset: str, search: str | None = None, limit: int = 100) -> list[str]: | |
| 122 | + """List tickers available for an asset class. | |
| 123 | + | |
| 124 | + Parameters | |
| 125 | + ---------- | |
| 126 | + asset : str | |
| 127 | + API asset class (``stock``, ``etf``, ``futures``, ``crypto``, | |
| 128 | + ``index``, ``fx``). | |
| 129 | + search : str, optional | |
| 130 | + Substring filter applied server-side. | |
| 131 | + limit : int | |
| 132 | + Maximum number of tickers returned. | |
| 133 | + """ | |
| 134 | + params: dict[str, Any] = {"limit": limit} | |
| 135 | + if search: | |
| 136 | + params["search"] = search | |
| 137 | + payload = self.get_json(f"/v1/{asset}/tickers", params) | |
| 138 | + return payload.get("tickers", payload) | |
| 139 | + | |
| 140 | + def fetch_bars( | |
| 141 | + self, | |
| 142 | + asset: str, | |
| 143 | + ticker: str, | |
| 144 | + timeframe: str = "1min", | |
| 145 | + adjustment: str | None = None, | |
| 146 | + start: str | None = None, | |
| 147 | + end: str | None = None, | |
| 148 | + ) -> pd.DataFrame: | |
| 149 | + """Download OHLCV bars, transparently handling pagination. | |
| 150 | + | |
| 151 | + Parameters | |
| 152 | + ---------- | |
| 153 | + asset, ticker : str | |
| 154 | + Instrument identification. | |
| 155 | + timeframe : str | |
| 156 | + ``1min``, ``5min``, ``30min``, ``1hour`` or ``1day``. | |
| 157 | + adjustment : str, optional | |
| 158 | + Price-adjustment scheme; defaults to the class setting in | |
| 159 | + ``config.yaml``. | |
| 160 | + start, end : str, optional | |
| 161 | + Inclusive date bounds (``YYYY-MM-DD`` or full datetime). | |
| 162 | + | |
| 163 | + Returns | |
| 164 | + ------- | |
| 165 | + pandas.DataFrame | |
| 166 | + Columns ``datetime`` (naive exchange-local timestamps), | |
| 167 | + ``open``, ``high``, ``low``, ``close``, ``volume``; sorted, | |
| 168 | + duplicate timestamps dropped. | |
| 169 | + """ | |
| 170 | + adjustment = adjustment or config.adjustment_for(asset) | |
| 171 | + frames: list[pd.DataFrame] = [] | |
| 172 | + cursor = start | |
| 173 | + n_pages = 0 | |
| 174 | + while True: | |
| 175 | + params: dict[str, Any] = { | |
| 176 | + "timeframe": timeframe, | |
| 177 | + "adjustment": adjustment, | |
| 178 | + "order": "asc", | |
| 179 | + "limit": self.page_limit, | |
| 180 | + "format": "csv", | |
| 181 | + } | |
| 182 | + if cursor: | |
| 183 | + params["start"] = cursor | |
| 184 | + if end: | |
| 185 | + params["end"] = end | |
| 186 | + resp = self._get(f"/v1/bars/{asset}/{ticker}", params) | |
| 187 | + page = pd.read_csv(io.StringIO(resp.text)) if resp.text.strip() else pd.DataFrame() | |
| 188 | + if page.empty: | |
| 189 | + break | |
| 190 | + page["datetime"] = pd.to_datetime(page["datetime"]) | |
| 191 | + frames.append(page) | |
| 192 | + n_pages += 1 | |
| 193 | + last_dt = page["datetime"].iloc[-1] | |
| 194 | + logger.debug("%s/%s %s page %d: %d rows, up to %s", | |
| 195 | + asset, ticker, timeframe, n_pages, len(page), last_dt) | |
| 196 | + if len(page) < self.page_limit: | |
| 197 | + break | |
| 198 | + cursor = (last_dt + pd.Timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S") | |
| 199 | + if not frames: | |
| 200 | + logger.warning("%s/%s %s: no data returned", asset, ticker, timeframe) | |
| 201 | + return pd.DataFrame(columns=["datetime", "open", "high", "low", "close", "volume"]) | |
| 202 | + df = pd.concat(frames, ignore_index=True) | |
| 203 | + df = ( | |
| 204 | + df.drop(columns=["ticker"], errors="ignore") | |
| 205 | + .drop_duplicates(subset="datetime") | |
| 206 | + .sort_values("datetime") | |
| 207 | + .reset_index(drop=True) | |
| 208 | + ) | |
| 209 | + if "volume" not in df.columns: # some index series carry no volume | |
| 210 | + df["volume"] = float("nan") | |
| 211 | + logger.info("%s/%s %s: %d bars (%s -> %s)", asset, ticker, timeframe, | |
| 212 | + len(df), df["datetime"].iloc[0], df["datetime"].iloc[-1]) | |
| 213 | + return df | |
| 214 | + | |
| 215 | + # ------------------------------------------------------------- cache | |
| 216 | + def _cache_file(self, asset: str, ticker: str, timeframe: str) -> Path: | |
| 217 | + safe = ticker.replace("/", "-") | |
| 218 | + return self.cache_dir / f"{asset}_{safe}_{timeframe}.parquet" | |
| 219 | + | |
| 220 | + def get_bars( | |
| 221 | + self, | |
| 222 | + asset: str, | |
| 223 | + ticker: str, | |
| 224 | + timeframe: str = "1min", | |
| 225 | + start: str | None = None, | |
| 226 | + end: str | None = None, | |
| 227 | + refresh: bool = False, | |
| 228 | + ) -> pd.DataFrame: | |
| 229 | + """Return bars from the local parquet cache, downloading if absent. | |
| 230 | + | |
| 231 | + Parameters | |
| 232 | + ---------- | |
| 233 | + asset, ticker, timeframe : str | |
| 234 | + Instrument identification and bar frequency. | |
| 235 | + start, end : str, optional | |
| 236 | + Date bounds used when the series must be downloaded; when the | |
| 237 | + cache is hit the full cached range is returned (filter at the | |
| 238 | + call site if needed). | |
| 239 | + refresh : bool | |
| 240 | + Force a re-download even when a cache file exists. | |
| 241 | + | |
| 242 | + Returns | |
| 243 | + ------- | |
| 244 | + pandas.DataFrame | |
| 245 | + Same layout as :meth:`fetch_bars`. | |
| 246 | + """ | |
| 247 | + f = self._cache_file(asset, ticker, timeframe) | |
| 248 | + if f.exists() and not refresh: | |
| 249 | + logger.debug("cache hit: %s", f.name) | |
| 250 | + return pd.read_parquet(f) | |
| 251 | + df = self.fetch_bars(asset, ticker, timeframe, start=start, end=end) | |
| 252 | + if not df.empty: | |
| 253 | + f.parent.mkdir(parents=True, exist_ok=True) | |
| 254 | + df.to_parquet(f, index=False) | |
| 255 | + logger.info("cached %s (%.1f MB)", f.name, f.stat().st_size / 1e6) | |
| 256 | + return df | |
added
src/wp12/backtest.py
+210 −0
@@ -0,0 +1,210 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : backtest.py | |
| 8 | +Description : Valeur économique des prévisions — stratégie de | |
| 9 | + volatility timing (cible de vol, cap de levier, | |
| 10 | + coûts de transaction), gains d'utilité mean- | |
| 11 | + variance, et backtesting de la VaR (tests de | |
| 12 | + Kupiec et Christoffersen). | |
| 13 | +================================================================ | |
| 14 | +""" | |
| 15 | + | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import logging | |
| 19 | + | |
| 20 | +import numpy as np | |
| 21 | +import pandas as pd | |
| 22 | +from scipy import stats | |
| 23 | + | |
| 24 | +logger = logging.getLogger(__name__) | |
| 25 | + | |
| 26 | +ANN = 252 | |
| 27 | + | |
| 28 | + | |
| 29 | +def volatility_timing( | |
| 30 | + ret: pd.Series, | |
| 31 | + pred_var: pd.Series, | |
| 32 | + vol_target_ann: float = 0.10, | |
| 33 | + leverage_cap: float = 3.0, | |
| 34 | + tc_bps: float = 5.0, | |
| 35 | +) -> pd.DataFrame: | |
| 36 | + """Volatility-timing strategy driven by one-day-ahead variance forecasts. | |
| 37 | + | |
| 38 | + The weight on the risky asset is | |
| 39 | + ``w_t = min(cap, sigma_target / sigma_hat_{t+1})`` where | |
| 40 | + ``sigma_hat`` is the annualized forecast volatility; the position is | |
| 41 | + financed at zero rate and transaction costs are charged on turnover. | |
| 42 | + | |
| 43 | + Parameters | |
| 44 | + ---------- | |
| 45 | + ret : pandas.Series | |
| 46 | + Daily close-to-close log returns of the asset. | |
| 47 | + pred_var : pandas.Series | |
| 48 | + Daily variance forecasts indexed by forecast *origin* (the | |
| 49 | + position for day t+1 uses the forecast dated t). | |
| 50 | + vol_target_ann : float | |
| 51 | + Annualized volatility target. | |
| 52 | + leverage_cap : float | |
| 53 | + Maximum absolute weight. | |
| 54 | + tc_bps : float | |
| 55 | + One-way transaction cost in basis points of traded notional. | |
| 56 | + | |
| 57 | + Returns | |
| 58 | + ------- | |
| 59 | + pandas.DataFrame | |
| 60 | + Daily strategy ledger: ``weight``, ``gross``, ``net`` returns. | |
| 61 | + """ | |
| 62 | + sigma_ann = np.sqrt(pred_var.clip(lower=1e-12) * ANN) | |
| 63 | + w = (vol_target_ann / sigma_ann).clip(upper=leverage_cap) | |
| 64 | + # weight decided at origin t applies to return of t+1 | |
| 65 | + w_applied = w.shift(1) | |
| 66 | + aligned = pd.concat([ret.rename("ret"), w_applied.rename("w")], axis=1).dropna() | |
| 67 | + gross = aligned["w"] * aligned["ret"] | |
| 68 | + turnover = aligned["w"].diff().abs().fillna(aligned["w"].abs()) | |
| 69 | + net = gross - turnover * tc_bps / 1e4 | |
| 70 | + return pd.DataFrame({"weight": aligned["w"], "gross": gross, "net": net}) | |
| 71 | + | |
| 72 | + | |
| 73 | +def perf_stats(returns: pd.Series) -> dict[str, float]: | |
| 74 | + """Annualized performance statistics of a daily return series.""" | |
| 75 | + r = returns.dropna() | |
| 76 | + if len(r) < 60: | |
| 77 | + return {k: np.nan for k in ("ann_ret", "ann_vol", "sharpe", "max_dd")} | |
| 78 | + mu = r.mean() * ANN | |
| 79 | + sig = r.std() * np.sqrt(ANN) | |
| 80 | + cum = r.cumsum() | |
| 81 | + dd = float((cum - cum.cummax()).min()) | |
| 82 | + return { | |
| 83 | + "ann_ret": float(mu), | |
| 84 | + "ann_vol": float(sig), | |
| 85 | + "sharpe": float(mu / sig) if sig > 0 else np.nan, | |
| 86 | + "max_dd": dd, | |
| 87 | + } | |
| 88 | + | |
| 89 | + | |
| 90 | +def utility_gain( | |
| 91 | + net_a: pd.Series, net_b: pd.Series, risk_aversion: float = 5.0 | |
| 92 | +) -> float: | |
| 93 | + """Annualized management fee an investor would pay to switch B -> A. | |
| 94 | + | |
| 95 | + Mean-variance utility (Fleming, Kirby & Ostdiek 2001): | |
| 96 | + ``Delta`` solves ``U(r_A - Delta) = U(r_B)`` with | |
| 97 | + ``U(r) = E[r] - (gamma/2) Var[r]``. | |
| 98 | + | |
| 99 | + Parameters | |
| 100 | + ---------- | |
| 101 | + net_a, net_b : pandas.Series | |
| 102 | + Daily net strategy returns (A = candidate, B = benchmark). | |
| 103 | + risk_aversion : float | |
| 104 | + Relative risk-aversion coefficient gamma. | |
| 105 | + | |
| 106 | + Returns | |
| 107 | + ------- | |
| 108 | + float | |
| 109 | + Annualized utility gain (fee), in return units. | |
| 110 | + """ | |
| 111 | + a, b = net_a.dropna(), net_b.dropna() | |
| 112 | + common = a.index.intersection(b.index) | |
| 113 | + a, b = a[common], b[common] | |
| 114 | + ua = a.mean() - 0.5 * risk_aversion * a.var() | |
| 115 | + ub = b.mean() - 0.5 * risk_aversion * b.var() | |
| 116 | + return float((ua - ub) * ANN) | |
| 117 | + | |
| 118 | + | |
| 119 | +# ---------------------------------------------------------------- VaR backtest | |
| 120 | +def var_forecast( | |
| 121 | + pred_var: pd.Series, level: float = 0.01 | |
| 122 | +) -> pd.Series: | |
| 123 | + """Gaussian Value-at-Risk from a variance forecast (positive number).""" | |
| 124 | + z = stats.norm.ppf(level) | |
| 125 | + return -z * np.sqrt(pred_var.clip(lower=1e-12)) | |
| 126 | + | |
| 127 | + | |
| 128 | +def kupiec_test(violations: np.ndarray, level: float) -> tuple[float, float]: | |
| 129 | + """Kupiec (1995) proportion-of-failures LR test. | |
| 130 | + | |
| 131 | + Returns | |
| 132 | + ------- | |
| 133 | + tuple | |
| 134 | + ``(LR statistic, p-value)`` under chi2(1). | |
| 135 | + """ | |
| 136 | + n = len(violations) | |
| 137 | + x = int(violations.sum()) | |
| 138 | + if n == 0: | |
| 139 | + return np.nan, np.nan | |
| 140 | + pi_hat = x / n | |
| 141 | + if x in (0, n): | |
| 142 | + lr = -2 * n * (np.log(1 - level) if x == 0 else np.log(level)) | |
| 143 | + else: | |
| 144 | + ll0 = (n - x) * np.log(1 - level) + x * np.log(level) | |
| 145 | + ll1 = (n - x) * np.log(1 - pi_hat) + x * np.log(pi_hat) | |
| 146 | + lr = -2 * (ll0 - ll1) | |
| 147 | + return float(lr), float(stats.chi2.sf(lr, df=1)) | |
| 148 | + | |
| 149 | + | |
| 150 | +def christoffersen_test(violations: np.ndarray, level: float) -> tuple[float, float]: | |
| 151 | + """Christoffersen (1998) conditional-coverage LR test (chi2(2)). | |
| 152 | + | |
| 153 | + Combines unconditional coverage with first-order independence of the | |
| 154 | + violation indicator sequence. | |
| 155 | + """ | |
| 156 | + v = violations.astype(int) | |
| 157 | + n = len(v) | |
| 158 | + if n < 2: | |
| 159 | + return np.nan, np.nan | |
| 160 | + pairs = np.column_stack([v[:-1], v[1:]]) | |
| 161 | + n00 = int(((pairs[:, 0] == 0) & (pairs[:, 1] == 0)).sum()) | |
| 162 | + n01 = int(((pairs[:, 0] == 0) & (pairs[:, 1] == 1)).sum()) | |
| 163 | + n10 = int(((pairs[:, 0] == 1) & (pairs[:, 1] == 0)).sum()) | |
| 164 | + n11 = int(((pairs[:, 0] == 1) & (pairs[:, 1] == 1)).sum()) | |
| 165 | + pi01 = n01 / max(n00 + n01, 1) | |
| 166 | + pi11 = n11 / max(n10 + n11, 1) | |
| 167 | + pi = (n01 + n11) / max(n00 + n01 + n10 + n11, 1) | |
| 168 | + | |
| 169 | + def _safe_log(x: float) -> float: | |
| 170 | + return np.log(x) if x > 0 else 0.0 | |
| 171 | + | |
| 172 | + ll_ind = (n00 * _safe_log(1 - pi01) + n01 * _safe_log(pi01) | |
| 173 | + + n10 * _safe_log(1 - pi11) + n11 * _safe_log(pi11)) | |
| 174 | + ll_null = (n00 + n10) * _safe_log(1 - pi) + (n01 + n11) * _safe_log(pi) | |
| 175 | + lr_ind = -2 * (ll_null - ll_ind) | |
| 176 | + lr_uc, _ = kupiec_test(violations, level) | |
| 177 | + lr_cc = lr_uc + lr_ind | |
| 178 | + return float(lr_cc), float(stats.chi2.sf(lr_cc, df=2)) | |
| 179 | + | |
| 180 | + | |
| 181 | +def var_backtest( | |
| 182 | + ret: pd.Series, pred_var: pd.Series, level: float | |
| 183 | +) -> dict[str, float]: | |
| 184 | + """Full VaR backtest for one model / asset / level. | |
| 185 | + | |
| 186 | + Parameters | |
| 187 | + ---------- | |
| 188 | + ret : pandas.Series | |
| 189 | + Daily returns. | |
| 190 | + pred_var : pandas.Series | |
| 191 | + One-day variance forecasts indexed by origin (applied to t+1). | |
| 192 | + level : float | |
| 193 | + VaR tail level (0.01 or 0.05). | |
| 194 | + | |
| 195 | + Returns | |
| 196 | + ------- | |
| 197 | + dict | |
| 198 | + Violation rate and Kupiec / Christoffersen p-values. | |
| 199 | + """ | |
| 200 | + var = var_forecast(pred_var, level).shift(1) | |
| 201 | + aligned = pd.concat([ret.rename("ret"), var.rename("var")], axis=1).dropna() | |
| 202 | + viol = (aligned["ret"] < -aligned["var"]).to_numpy() | |
| 203 | + lr_uc, p_uc = kupiec_test(viol, level) | |
| 204 | + lr_cc, p_cc = christoffersen_test(viol, level) | |
| 205 | + return { | |
| 206 | + "n": float(len(viol)), | |
| 207 | + "viol_rate": float(viol.mean()) if len(viol) else np.nan, | |
| 208 | + "kupiec_p": p_uc, | |
| 209 | + "christoffersen_p": p_cc, | |
| 210 | + } | |
added
src/wp12/config.py
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : config.py | |
| 8 | +Description : Chargement de config.yaml, chemins du projet et | |
| 9 | + helpers d'accès à l'univers d'actifs. | |
| 10 | +================================================================ | |
| 11 | +""" | |
| 12 | + | |
| 13 | +from __future__ import annotations | |
| 14 | + | |
| 15 | +import logging | |
| 16 | +import os | |
| 17 | +from functools import lru_cache | |
| 18 | +from pathlib import Path | |
| 19 | +from typing import Any | |
| 20 | + | |
| 21 | +import yaml | |
| 22 | + | |
| 23 | +ROOT = Path(os.environ.get("WP12_ROOT", Path(__file__).resolve().parents[2])) | |
| 24 | +CONFIG_PATH = ROOT / "config.yaml" | |
| 25 | + | |
| 26 | + | |
| 27 | +def setup_logging(level: int = logging.INFO) -> None: | |
| 28 | + """Configure root logging once, with a compact console format. | |
| 29 | + | |
| 30 | + Parameters | |
| 31 | + ---------- | |
| 32 | + level : int | |
| 33 | + Logging level applied to the root logger. | |
| 34 | + """ | |
| 35 | + logging.basicConfig( | |
| 36 | + level=level, | |
| 37 | + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", | |
| 38 | + datefmt="%H:%M:%S", | |
| 39 | + force=False, | |
| 40 | + ) | |
| 41 | + | |
| 42 | + | |
| 43 | +@lru_cache(maxsize=1) | |
| 44 | +def load_config() -> dict[str, Any]: | |
| 45 | + """Load and cache the central YAML configuration. | |
| 46 | + | |
| 47 | + Returns | |
| 48 | + ------- | |
| 49 | + dict | |
| 50 | + Parsed contents of ``config.yaml`` at the repository root. | |
| 51 | + """ | |
| 52 | + with open(CONFIG_PATH, encoding="utf-8") as fh: | |
| 53 | + return yaml.safe_load(fh) | |
| 54 | + | |
| 55 | + | |
| 56 | +def path(key: str) -> Path: | |
| 57 | + """Resolve a path from the ``paths`` section of the config. | |
| 58 | + | |
| 59 | + Parameters | |
| 60 | + ---------- | |
| 61 | + key : str | |
| 62 | + Key inside the ``paths`` mapping (e.g. ``"raw"``, ``"figures"``). | |
| 63 | + | |
| 64 | + Returns | |
| 65 | + ------- | |
| 66 | + pathlib.Path | |
| 67 | + Absolute path anchored at the repository root. | |
| 68 | + """ | |
| 69 | + p = ROOT / load_config()["paths"][key] | |
| 70 | + p.mkdir(parents=True, exist_ok=True) | |
| 71 | + return p | |
| 72 | + | |
| 73 | + | |
| 74 | +def universe(include_predictors: bool = False) -> list[dict[str, str]]: | |
| 75 | + """Flatten the asset universe into a list of instrument records. | |
| 76 | + | |
| 77 | + Parameters | |
| 78 | + ---------- | |
| 79 | + include_predictors : bool | |
| 80 | + If True, append predictor-only instruments (e.g. VIX). | |
| 81 | + | |
| 82 | + Returns | |
| 83 | + ------- | |
| 84 | + list of dict | |
| 85 | + Records with keys ``ticker``, ``asset`` and ``cls`` (asset class | |
| 86 | + label used throughout the paper: equity, fx, crypto, futures, | |
| 87 | + index). | |
| 88 | + """ | |
| 89 | + cfg = load_config() | |
| 90 | + out: list[dict[str, str]] = [] | |
| 91 | + for cls, items in cfg["universe"].items(): | |
| 92 | + for it in items: | |
| 93 | + out.append({"ticker": it["ticker"], "asset": it["asset"], "cls": cls}) | |
| 94 | + if include_predictors: | |
| 95 | + for cls, items in cfg.get("predictors", {}).items(): | |
| 96 | + for it in items: | |
| 97 | + out.append({"ticker": it["ticker"], "asset": it["asset"], "cls": cls}) | |
| 98 | + return out | |
| 99 | + | |
| 100 | + | |
| 101 | +def adjustment_for(asset: str) -> str: | |
| 102 | + """Return the price-adjustment scheme configured for an asset class. | |
| 103 | + | |
| 104 | + Parameters | |
| 105 | + ---------- | |
| 106 | + asset : str | |
| 107 | + API asset class (``stock``, ``etf``, ``futures``, ``crypto``, | |
| 108 | + ``index``, ``fx``). | |
| 109 | + | |
| 110 | + Returns | |
| 111 | + ------- | |
| 112 | + str | |
| 113 | + Adjustment keyword understood by the HF Market Data API. | |
| 114 | + """ | |
| 115 | + return load_config()["adjustment"][asset] | |
added
src/wp12/data_pipeline.py
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : data_pipeline.py | |
| 8 | +Description : Orchestration données — téléchargement (via | |
| 9 | + api_client), construction des mesures RV | |
| 10 | + journalières par actif et assemblage du panel | |
| 11 | + multi-actifs (data/processed). | |
| 12 | +================================================================ | |
| 13 | +""" | |
| 14 | + | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import logging | |
| 18 | +from pathlib import Path | |
| 19 | + | |
| 20 | +import pandas as pd | |
| 21 | + | |
| 22 | +from . import config, realized_vol | |
| 23 | +from .api_client import HFMarketDataClient | |
| 24 | + | |
| 25 | +logger = logging.getLogger(__name__) | |
| 26 | + | |
| 27 | + | |
| 28 | +def download_instrument( | |
| 29 | + client: HFMarketDataClient, | |
| 30 | + inst: dict[str, str], | |
| 31 | + timeframes: tuple[str, ...] = ("1min", "1day"), | |
| 32 | + refresh: bool = False, | |
| 33 | +) -> dict[str, pd.DataFrame]: | |
| 34 | + """Download (or load from cache) all timeframes for one instrument. | |
| 35 | + | |
| 36 | + Parameters | |
| 37 | + ---------- | |
| 38 | + client : HFMarketDataClient | |
| 39 | + API client with parquet cache. | |
| 40 | + inst : dict | |
| 41 | + Instrument record with keys ``ticker``, ``asset``, ``cls``. | |
| 42 | + timeframes : tuple of str | |
| 43 | + Bar frequencies to materialize. | |
| 44 | + refresh : bool | |
| 45 | + Force re-download. | |
| 46 | + | |
| 47 | + Returns | |
| 48 | + ------- | |
| 49 | + dict | |
| 50 | + Mapping timeframe -> bars DataFrame. | |
| 51 | + """ | |
| 52 | + cfg = config.load_config()["sample"] | |
| 53 | + out = {} | |
| 54 | + for tf in timeframes: | |
| 55 | + out[tf] = client.get_bars( | |
| 56 | + inst["asset"], inst["ticker"], tf, | |
| 57 | + start=cfg["start"], end=cfg["end"], refresh=refresh, | |
| 58 | + ) | |
| 59 | + return out | |
| 60 | + | |
| 61 | + | |
| 62 | +def rv_file(ticker: str) -> Path: | |
| 63 | + """Path of the processed daily-RV parquet for one ticker.""" | |
| 64 | + return config.path("processed") / f"rv_{ticker}.parquet" | |
| 65 | + | |
| 66 | + | |
| 67 | +def build_instrument_rv( | |
| 68 | + client: HFMarketDataClient, | |
| 69 | + inst: dict[str, str], | |
| 70 | + refresh: bool = False, | |
| 71 | +) -> pd.DataFrame: | |
| 72 | + """Build (and cache) the daily realized-measure panel for one ticker. | |
| 73 | + | |
| 74 | + Parameters | |
| 75 | + ---------- | |
| 76 | + client : HFMarketDataClient | |
| 77 | + API client. | |
| 78 | + inst : dict | |
| 79 | + Instrument record (``ticker``, ``asset``, ``cls``). | |
| 80 | + refresh : bool | |
| 81 | + Recompute even if the processed file exists. | |
| 82 | + | |
| 83 | + Returns | |
| 84 | + ------- | |
| 85 | + pandas.DataFrame | |
| 86 | + Daily measures indexed by date, with ``ticker`` and ``cls`` | |
| 87 | + columns added. | |
| 88 | + """ | |
| 89 | + f = rv_file(inst["ticker"]) | |
| 90 | + if f.exists() and not refresh: | |
| 91 | + return pd.read_parquet(f) | |
| 92 | + cfg = config.load_config() | |
| 93 | + bars = client.get_bars( | |
| 94 | + inst["asset"], inst["ticker"], "1min", | |
| 95 | + start=cfg["sample"]["start"], end=cfg["sample"]["end"], | |
| 96 | + ) | |
| 97 | + daily = realized_vol.build_daily_rv( | |
| 98 | + bars, cls=inst["cls"], | |
| 99 | + jump_alpha=cfg["realized_vol"]["jump_test_alpha"], | |
| 100 | + ) | |
| 101 | + if daily.empty: | |
| 102 | + logger.warning("empty RV panel for %s", inst["ticker"]) | |
| 103 | + return daily | |
| 104 | + daily["ticker"] = inst["ticker"] | |
| 105 | + daily["cls"] = inst["cls"] | |
| 106 | + daily.to_parquet(f) | |
| 107 | + logger.info("%s: %d RV days -> %s", inst["ticker"], len(daily), f.name) | |
| 108 | + return daily | |
| 109 | + | |
| 110 | + | |
| 111 | +def build_panel(refresh: bool = False) -> pd.DataFrame: | |
| 112 | + """Assemble the multi-asset daily RV panel for the whole universe. | |
| 113 | + | |
| 114 | + Parameters | |
| 115 | + ---------- | |
| 116 | + refresh : bool | |
| 117 | + Recompute per-ticker RV files even when cached. | |
| 118 | + | |
| 119 | + Returns | |
| 120 | + ------- | |
| 121 | + pandas.DataFrame | |
| 122 | + Long panel (date x ticker) of daily realized measures, also | |
| 123 | + written to ``data/processed/panel.parquet``. | |
| 124 | + """ | |
| 125 | + client = HFMarketDataClient() | |
| 126 | + frames = [] | |
| 127 | + for inst in config.universe(include_predictors=True): | |
| 128 | + df = build_instrument_rv(client, inst, refresh=refresh) | |
| 129 | + if not df.empty: | |
| 130 | + frames.append(df) | |
| 131 | + panel = pd.concat(frames).sort_index() | |
| 132 | + out = config.path("processed") / "panel.parquet" | |
| 133 | + panel.to_parquet(out) | |
| 134 | + logger.info("panel: %d rows, %d tickers -> %s", | |
| 135 | + len(panel), panel["ticker"].nunique(), out.name) | |
| 136 | + return panel | |
added
src/wp12/evaluation.py
+292 −0
@@ -0,0 +1,292 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : evaluation.py | |
| 8 | +Description : Évaluation des prévisions — pertes QLIKE/MSE, | |
| 9 | + test de Diebold-Mariano (HAC), Model Confidence | |
| 10 | + Set (bootstrap stationnaire), régressions de | |
| 11 | + Mincer-Zarnowitz, tests d'encompassing et | |
| 12 | + combinaisons de prévisions. | |
| 13 | +================================================================ | |
| 14 | +""" | |
| 15 | + | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import logging | |
| 19 | + | |
| 20 | +import numpy as np | |
| 21 | +import pandas as pd | |
| 22 | +from scipy import stats | |
| 23 | + | |
| 24 | +logger = logging.getLogger(__name__) | |
| 25 | + | |
| 26 | +EPS = 1e-12 | |
| 27 | + | |
| 28 | + | |
| 29 | +# ---------------------------------------------------------------- losses | |
| 30 | +def qlike(actual: np.ndarray, forecast: np.ndarray) -> np.ndarray: | |
| 31 | + """QLIKE loss (per observation), robust ranking under noisy proxies. | |
| 32 | + | |
| 33 | + Parameters | |
| 34 | + ---------- | |
| 35 | + actual, forecast : numpy.ndarray | |
| 36 | + Realized and predicted variance (both strictly positive). | |
| 37 | + | |
| 38 | + Returns | |
| 39 | + ------- | |
| 40 | + numpy.ndarray | |
| 41 | + ``a/f - log(a/f) - 1`` — non-negative, zero iff perfect. | |
| 42 | + """ | |
| 43 | + a = np.maximum(actual, EPS) | |
| 44 | + f = np.maximum(forecast, EPS) | |
| 45 | + ratio = a / f | |
| 46 | + return ratio - np.log(ratio) - 1.0 | |
| 47 | + | |
| 48 | + | |
| 49 | +def mse(actual: np.ndarray, forecast: np.ndarray) -> np.ndarray: | |
| 50 | + """Squared-error loss (per observation) on variance levels.""" | |
| 51 | + return (actual - forecast) ** 2 | |
| 52 | + | |
| 53 | + | |
| 54 | +LOSSES = {"qlike": qlike, "mse": mse} | |
| 55 | + | |
| 56 | + | |
| 57 | +# ---------------------------------------------------------------- DM test | |
| 58 | +def newey_west_var(x: np.ndarray, lags: int) -> float: | |
| 59 | + """Newey-West long-run variance of the mean of a series.""" | |
| 60 | + x = x - x.mean() | |
| 61 | + n = len(x) | |
| 62 | + v = float(x @ x) / n | |
| 63 | + for k in range(1, min(lags, n - 1) + 1): | |
| 64 | + w = 1.0 - k / (lags + 1.0) | |
| 65 | + v += 2.0 * w * float(x[k:] @ x[:-k]) / n | |
| 66 | + return v | |
| 67 | + | |
| 68 | + | |
| 69 | +def diebold_mariano( | |
| 70 | + loss_a: np.ndarray, loss_b: np.ndarray, h: int = 1 | |
| 71 | +) -> tuple[float, float]: | |
| 72 | + """Diebold-Mariano test of equal predictive accuracy. | |
| 73 | + | |
| 74 | + Parameters | |
| 75 | + ---------- | |
| 76 | + loss_a, loss_b : numpy.ndarray | |
| 77 | + Per-observation losses of models A and B (aligned). | |
| 78 | + h : int | |
| 79 | + Forecast horizon; the HAC bandwidth is ``h - 1``. | |
| 80 | + | |
| 81 | + Returns | |
| 82 | + ------- | |
| 83 | + tuple | |
| 84 | + ``(statistic, p_value)``; negative statistic favours model A. | |
| 85 | + """ | |
| 86 | + d = loss_a - loss_b | |
| 87 | + n = len(d) | |
| 88 | + if n < 30: | |
| 89 | + return np.nan, np.nan | |
| 90 | + v = newey_west_var(d, max(h - 1, 0)) | |
| 91 | + if v <= 0: | |
| 92 | + return np.nan, np.nan | |
| 93 | + stat = float(d.mean() / np.sqrt(v / n)) | |
| 94 | + return stat, float(2 * stats.norm.sf(abs(stat))) | |
| 95 | + | |
| 96 | + | |
| 97 | +# ---------------------------------------------------------------- MCS | |
| 98 | +def _stationary_bootstrap_idx( | |
| 99 | + n: int, b: int, avg_block: float, rng: np.random.Generator | |
| 100 | +) -> np.ndarray: | |
| 101 | + """Index matrix (b x n) for the Politis-Romano stationary bootstrap.""" | |
| 102 | + p = 1.0 / avg_block | |
| 103 | + starts = rng.integers(0, n, size=(b, n)) | |
| 104 | + jumps = rng.random((b, n)) < p | |
| 105 | + jumps[:, 0] = True | |
| 106 | + idx = np.zeros((b, n), dtype=np.int64) | |
| 107 | + for j in range(n): | |
| 108 | + if j == 0: | |
| 109 | + idx[:, 0] = starts[:, 0] | |
| 110 | + else: | |
| 111 | + cont = (idx[:, j - 1] + 1) % n | |
| 112 | + idx[:, j] = np.where(jumps[:, j], starts[:, j], cont) | |
| 113 | + return idx | |
| 114 | + | |
| 115 | + | |
| 116 | +def model_confidence_set( | |
| 117 | + losses: pd.DataFrame, | |
| 118 | + alpha: float = 0.10, | |
| 119 | + n_boot: int = 5000, | |
| 120 | + avg_block: float = 10.0, | |
| 121 | + seed: int = 20260810, | |
| 122 | +) -> pd.DataFrame: | |
| 123 | + """Model Confidence Set of Hansen, Lunde & Nason (2011), T_max statistic. | |
| 124 | + | |
| 125 | + Iteratively eliminates the worst model until the null of equal | |
| 126 | + predictive ability survives at level ``alpha``. | |
| 127 | + | |
| 128 | + Parameters | |
| 129 | + ---------- | |
| 130 | + losses : pandas.DataFrame | |
| 131 | + Per-observation losses, one column per model (aligned rows). | |
| 132 | + alpha : float | |
| 133 | + MCS level (models kept form the ``1 - alpha`` confidence set). | |
| 134 | + n_boot : int | |
| 135 | + Bootstrap replications. | |
| 136 | + avg_block : float | |
| 137 | + Mean block length of the stationary bootstrap. | |
| 138 | + seed : int | |
| 139 | + RNG seed. | |
| 140 | + | |
| 141 | + Returns | |
| 142 | + ------- | |
| 143 | + pandas.DataFrame | |
| 144 | + One row per model with its elimination-order ``mcs_pvalue`` and | |
| 145 | + an ``in_mcs`` flag. | |
| 146 | + """ | |
| 147 | + losses = losses.dropna() | |
| 148 | + models = list(losses.columns) | |
| 149 | + L = losses.to_numpy() | |
| 150 | + n = len(L) | |
| 151 | + rng = np.random.default_rng(seed) | |
| 152 | + idx = _stationary_bootstrap_idx(n, n_boot, avg_block, rng) | |
| 153 | + | |
| 154 | + included = list(range(len(models))) | |
| 155 | + pvals: dict[str, float] = {} | |
| 156 | + running_max_p = 0.0 | |
| 157 | + while len(included) > 1: | |
| 158 | + Ls = L[:, included] | |
| 159 | + dbar_i = Ls.mean(axis=0) # mean loss per model | |
| 160 | + d_i_dot = dbar_i - dbar_i.mean() # vs. set average | |
| 161 | + # bootstrap distribution of the max t-stat | |
| 162 | + boot_means = Ls[idx].mean(axis=1) # (b, k) | |
| 163 | + boot_center = boot_means - dbar_i | |
| 164 | + var_i = (boot_center - boot_center.mean(axis=0)).var(axis=0) + EPS | |
| 165 | + t_i = d_i_dot / np.sqrt(var_i) | |
| 166 | + t_max = float(t_i.max()) | |
| 167 | + boot_d = boot_center - boot_center.mean(axis=1, keepdims=True) | |
| 168 | + boot_t = boot_d / np.sqrt(var_i) | |
| 169 | + boot_max = boot_t.max(axis=1) | |
| 170 | + p = float(np.mean(boot_max >= t_max)) | |
| 171 | + running_max_p = max(running_max_p, p) | |
| 172 | + worst = included[int(np.argmax(t_i))] | |
| 173 | + pvals[models[worst]] = running_max_p | |
| 174 | + if p >= alpha: | |
| 175 | + for i in included: | |
| 176 | + pvals.setdefault(models[i], max(running_max_p, p)) | |
| 177 | + break | |
| 178 | + included.remove(worst) | |
| 179 | + if len(included) == 1: | |
| 180 | + pvals[models[included[0]]] = 1.0 | |
| 181 | + out = pd.DataFrame( | |
| 182 | + {"model": models, "mcs_pvalue": [pvals[m] for m in models]} | |
| 183 | + ) | |
| 184 | + out["in_mcs"] = out["mcs_pvalue"] >= alpha | |
| 185 | + return out.sort_values("mcs_pvalue", ascending=False).reset_index(drop=True) | |
| 186 | + | |
| 187 | + | |
| 188 | +# ---------------------------------------------------------------- MZ / encompassing | |
| 189 | +def mincer_zarnowitz( | |
| 190 | + actual: np.ndarray, forecast: np.ndarray, h: int = 1 | |
| 191 | +) -> dict[str, float]: | |
| 192 | + """Mincer-Zarnowitz levels regression with HAC inference. | |
| 193 | + | |
| 194 | + Regresses realized variance on a constant and the forecast and tests | |
| 195 | + the joint null ``(a, b) = (0, 1)``. | |
| 196 | + | |
| 197 | + Returns | |
| 198 | + ------- | |
| 199 | + dict | |
| 200 | + ``alpha``, ``beta``, ``r2`` and the Wald ``p_joint``. | |
| 201 | + """ | |
| 202 | + mask = np.isfinite(actual) & np.isfinite(forecast) | |
| 203 | + y, f = actual[mask], forecast[mask] | |
| 204 | + n = len(y) | |
| 205 | + if n < 30: | |
| 206 | + return {"alpha": np.nan, "beta": np.nan, "r2": np.nan, "p_joint": np.nan} | |
| 207 | + X = np.column_stack([np.ones(n), f]) | |
| 208 | + beta, *_ = np.linalg.lstsq(X, y, rcond=None) | |
| 209 | + e = y - X @ beta | |
| 210 | + r2 = 1.0 - e.var() / y.var() | |
| 211 | + # HAC covariance (Newey-West, bandwidth h-1) | |
| 212 | + lags = max(h - 1, 0) | |
| 213 | + Xe = X * e[:, None] | |
| 214 | + S = Xe.T @ Xe / n | |
| 215 | + for k in range(1, min(lags, n - 1) + 1): | |
| 216 | + w = 1.0 - k / (lags + 1.0) | |
| 217 | + G = Xe[k:].T @ Xe[:-k] / n | |
| 218 | + S += w * (G + G.T) | |
| 219 | + XX_inv = np.linalg.inv(X.T @ X / n) | |
| 220 | + V = XX_inv @ S @ XX_inv / n | |
| 221 | + r = beta - np.array([0.0, 1.0]) | |
| 222 | + try: | |
| 223 | + wald = float(r @ np.linalg.solve(V, r)) | |
| 224 | + p_joint = float(stats.chi2.sf(wald, df=2)) | |
| 225 | + except np.linalg.LinAlgError: | |
| 226 | + p_joint = np.nan | |
| 227 | + return {"alpha": float(beta[0]), "beta": float(beta[1]), | |
| 228 | + "r2": float(r2), "p_joint": p_joint} | |
| 229 | + | |
| 230 | + | |
| 231 | +def forecast_encompassing( | |
| 232 | + actual: np.ndarray, f_base: np.ndarray, f_rival: np.ndarray, h: int = 1 | |
| 233 | +) -> dict[str, float]: | |
| 234 | + """Test whether ``f_base`` encompasses ``f_rival``. | |
| 235 | + | |
| 236 | + Runs ``y = a + b1 f_base + b2 f_rival + e`` and reports the HAC | |
| 237 | + t-test of ``b2 = 0``; rejection means the rival adds information. | |
| 238 | + | |
| 239 | + Returns | |
| 240 | + ------- | |
| 241 | + dict | |
| 242 | + ``b1``, ``b2``, ``t_b2``, ``p_b2``. | |
| 243 | + """ | |
| 244 | + mask = np.isfinite(actual) & np.isfinite(f_base) & np.isfinite(f_rival) | |
| 245 | + y, f1, f2 = actual[mask], f_base[mask], f_rival[mask] | |
| 246 | + n = len(y) | |
| 247 | + if n < 30: | |
| 248 | + return {"b1": np.nan, "b2": np.nan, "t_b2": np.nan, "p_b2": np.nan} | |
| 249 | + X = np.column_stack([np.ones(n), f1, f2]) | |
| 250 | + beta, *_ = np.linalg.lstsq(X, y, rcond=None) | |
| 251 | + e = y - X @ beta | |
| 252 | + lags = max(h - 1, 0) | |
| 253 | + Xe = X * e[:, None] | |
| 254 | + S = Xe.T @ Xe / n | |
| 255 | + for k in range(1, min(lags, n - 1) + 1): | |
| 256 | + w = 1.0 - k / (lags + 1.0) | |
| 257 | + G = Xe[k:].T @ Xe[:-k] / n | |
| 258 | + S += w * (G + G.T) | |
| 259 | + XX_inv = np.linalg.inv(X.T @ X / n) | |
| 260 | + V = XX_inv @ S @ XX_inv / n | |
| 261 | + t2 = float(beta[2] / np.sqrt(V[2, 2])) | |
| 262 | + return {"b1": float(beta[1]), "b2": float(beta[2]), "t_b2": t2, | |
| 263 | + "p_b2": float(2 * stats.norm.sf(abs(t2)))} | |
| 264 | + | |
| 265 | + | |
| 266 | +# ---------------------------------------------------------------- combinations | |
| 267 | +def combine_forecasts( | |
| 268 | + forecasts: pd.DataFrame, actual: pd.Series, window: int = 60 | |
| 269 | +) -> pd.DataFrame: | |
| 270 | + """Simple and inverse-MSE forecast combinations. | |
| 271 | + | |
| 272 | + Parameters | |
| 273 | + ---------- | |
| 274 | + forecasts : pandas.DataFrame | |
| 275 | + One column per model, indexed by forecast origin. | |
| 276 | + actual : pandas.Series | |
| 277 | + Realized target aligned on the same index. | |
| 278 | + window : int | |
| 279 | + Trailing window used for the inverse-MSE weights. | |
| 280 | + | |
| 281 | + Returns | |
| 282 | + ------- | |
| 283 | + pandas.DataFrame | |
| 284 | + Columns ``Comb-Mean`` and ``Comb-InvMSE``. | |
| 285 | + """ | |
| 286 | + mean_comb = forecasts.mean(axis=1) | |
| 287 | + sq_err = forecasts.sub(actual, axis=0) ** 2 | |
| 288 | + inv_mse = 1.0 / sq_err.rolling(window, min_periods=window // 2).mean().shift(1) | |
| 289 | + weights = inv_mse.div(inv_mse.sum(axis=1), axis=0) | |
| 290 | + inv_comb = (forecasts * weights).sum(axis=1) | |
| 291 | + inv_comb[weights.isna().all(axis=1)] = np.nan | |
| 292 | + return pd.DataFrame({"Comb-Mean": mean_comb, "Comb-InvMSE": inv_comb}) | |
added
src/wp12/models_econ.py
+379 −0
@@ -0,0 +1,379 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : models_econ.py | |
| 8 | +Description : Modèles économétriques — famille HAR (HAR-RV, | |
| 9 | + HAR-J, HAR-CJ, SHAR, HARQ, log-HAR) estimée par | |
| 10 | + projections directes OLS roulantes, et famille | |
| 11 | + GARCH (GARCH, EGARCH, GJR, Realized GARCH) en | |
| 12 | + réestimation roulante. | |
| 13 | +================================================================ | |
| 14 | +""" | |
| 15 | + | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import logging | |
| 19 | +import warnings | |
| 20 | + | |
| 21 | +import numpy as np | |
| 22 | +import pandas as pd | |
| 23 | +from arch import arch_model | |
| 24 | +from scipy import optimize | |
| 25 | + | |
| 26 | +logger = logging.getLogger(__name__) | |
| 27 | + | |
| 28 | +EPS = 1e-12 | |
| 29 | + | |
| 30 | + | |
| 31 | +# ---------------------------------------------------------------- features | |
| 32 | +def har_features(df: pd.DataFrame, measure: str = "rv5ss") -> pd.DataFrame: | |
| 33 | + """Build the HAR information set from a daily realized-measure panel. | |
| 34 | + | |
| 35 | + Parameters | |
| 36 | + ---------- | |
| 37 | + df : pandas.DataFrame | |
| 38 | + Daily measures of one instrument (output of | |
| 39 | + :func:`wp12.realized_vol.build_daily_rv`). | |
| 40 | + measure : str | |
| 41 | + Column used as the realized-variance measure. | |
| 42 | + | |
| 43 | + Returns | |
| 44 | + ------- | |
| 45 | + pandas.DataFrame | |
| 46 | + Features at the forecast origin *t*: daily/weekly/monthly RV | |
| 47 | + averages, continuous and jump components, semivariances, | |
| 48 | + realized quarticity — all lagged consistently (values known | |
| 49 | + at the end of day *t*). | |
| 50 | + """ | |
| 51 | + rv = df[measure].clip(lower=EPS) | |
| 52 | + out = pd.DataFrame(index=df.index) | |
| 53 | + out["rv_d"] = rv | |
| 54 | + out["rv_w"] = rv.rolling(5).mean() | |
| 55 | + out["rv_m"] = rv.rolling(22).mean() | |
| 56 | + out["j_d"] = df["jump"].clip(lower=0.0) | |
| 57 | + out["c_d"] = df["cont"].clip(lower=EPS) | |
| 58 | + out["c_w"] = out["c_d"].rolling(5).mean() | |
| 59 | + out["c_m"] = out["c_d"].rolling(22).mean() | |
| 60 | + out["rsp_d"] = df["rsp"].clip(lower=0.0) | |
| 61 | + out["rsn_d"] = df["rsn"].clip(lower=0.0) | |
| 62 | + out["rvq_d"] = rv * np.sqrt(df["rq"].clip(lower=0.0)) | |
| 63 | + return out | |
| 64 | + | |
| 65 | + | |
| 66 | +def horizon_target(rv: pd.Series, h: int) -> pd.Series: | |
| 67 | + """Average realized variance over days t+1 .. t+h, indexed at origin t. | |
| 68 | + | |
| 69 | + Parameters | |
| 70 | + ---------- | |
| 71 | + rv : pandas.Series | |
| 72 | + Daily realized variance. | |
| 73 | + h : int | |
| 74 | + Forecast horizon in trading days. | |
| 75 | + | |
| 76 | + Returns | |
| 77 | + ------- | |
| 78 | + pandas.Series | |
| 79 | + ``mean(rv[t+1..t+h])`` aligned on the forecast origin ``t``. | |
| 80 | + """ | |
| 81 | + fwd = rv.shift(-1).rolling(h).mean().shift(-(h - 1)) | |
| 82 | + fwd.name = f"target_h{h}" | |
| 83 | + return fwd | |
| 84 | + | |
| 85 | + | |
| 86 | +HAR_SPECS: dict[str, list[str]] = { | |
| 87 | + "HAR": ["rv_d", "rv_w", "rv_m"], | |
| 88 | + "HAR-J": ["rv_d", "rv_w", "rv_m", "j_d"], | |
| 89 | + "HAR-CJ": ["c_d", "c_w", "c_m", "j_d"], | |
| 90 | + "SHAR": ["rsp_d", "rsn_d", "rv_w", "rv_m"], | |
| 91 | + "HARQ": ["rv_d", "rvq_d", "rv_w", "rv_m"], | |
| 92 | +} | |
| 93 | + | |
| 94 | + | |
| 95 | +def _ols_forecast( | |
| 96 | + x: np.ndarray, y: np.ndarray, x_new: np.ndarray | |
| 97 | +) -> tuple[float, np.ndarray]: | |
| 98 | + """Fit OLS with intercept and predict one observation. | |
| 99 | + | |
| 100 | + Returns | |
| 101 | + ------- | |
| 102 | + tuple | |
| 103 | + ``(prediction, coefficients)``. | |
| 104 | + """ | |
| 105 | + xx = np.column_stack([np.ones(len(x)), x]) | |
| 106 | + beta, *_ = np.linalg.lstsq(xx, y, rcond=None) | |
| 107 | + return float(np.concatenate([[1.0], x_new]) @ beta), beta | |
| 108 | + | |
| 109 | + | |
| 110 | +def rolling_har_forecasts( | |
| 111 | + df: pd.DataFrame, | |
| 112 | + spec: str, | |
| 113 | + horizons: tuple[int, ...] = (1, 5, 22), | |
| 114 | + measure: str = "rv5ss", | |
| 115 | + window: int = 1000, | |
| 116 | + refit_every: int = 22, | |
| 117 | +) -> pd.DataFrame: | |
| 118 | + """Rolling out-of-sample forecasts for one HAR-family specification. | |
| 119 | + | |
| 120 | + Direct projections: for each horizon *h* the average RV over | |
| 121 | + ``t+1..t+h`` is regressed on time-*t* features; parameters are | |
| 122 | + re-estimated every ``refit_every`` days on the trailing ``window`` | |
| 123 | + observations. Forecasts are clipped to the in-sample target range | |
| 124 | + (the "insanity filter" of Bollerslev, Patton & Quaedvlieg 2016). | |
| 125 | + | |
| 126 | + Parameters | |
| 127 | + ---------- | |
| 128 | + df : pandas.DataFrame | |
| 129 | + Daily realized measures of one instrument. | |
| 130 | + spec : str | |
| 131 | + One of ``HAR``, ``HAR-J``, ``HAR-CJ``, ``SHAR``, ``HARQ``, | |
| 132 | + ``LogHAR``. | |
| 133 | + horizons : tuple of int | |
| 134 | + Forecast horizons (days). | |
| 135 | + measure : str | |
| 136 | + Realized-variance column to forecast. | |
| 137 | + window, refit_every : int | |
| 138 | + Rolling estimation window length and re-estimation frequency. | |
| 139 | + | |
| 140 | + Returns | |
| 141 | + ------- | |
| 142 | + pandas.DataFrame | |
| 143 | + Long frame with columns ``date`` (forecast origin), ``h``, | |
| 144 | + ``model``, ``forecast`` (average daily variance over the | |
| 145 | + horizon). | |
| 146 | + """ | |
| 147 | + is_log = spec == "LogHAR" | |
| 148 | + feats = har_features(df, measure) | |
| 149 | + cols = HAR_SPECS["HAR"] if is_log else HAR_SPECS[spec] | |
| 150 | + x_all = np.log(feats[cols].clip(lower=EPS)) if is_log else feats[cols] | |
| 151 | + rv = df[measure].clip(lower=EPS) | |
| 152 | + | |
| 153 | + rows: list[tuple] = [] | |
| 154 | + for h in horizons: | |
| 155 | + y_all = horizon_target(rv, h) | |
| 156 | + y_fit = np.log(y_all.clip(lower=EPS)) if is_log else y_all | |
| 157 | + data = pd.concat([x_all, y_fit.rename("y"), y_all.rename("y_raw")], axis=1) | |
| 158 | + valid = data.notna().all(axis=1).to_numpy() | |
| 159 | + beta = None | |
| 160 | + sigma2 = 0.0 | |
| 161 | + lo = hi = np.nan | |
| 162 | + for t in range(window + h, len(data)): | |
| 163 | + # fitting sample: origins whose targets are fully realized by t | |
| 164 | + if beta is None or (t - window - h) % refit_every == 0: | |
| 165 | + sl = slice(max(0, t - h - window), t - h) | |
| 166 | + sub = data.iloc[sl] | |
| 167 | + sub = sub[valid[sl]] | |
| 168 | + if len(sub) < 100: | |
| 169 | + continue | |
| 170 | + x = sub[cols].to_numpy() | |
| 171 | + y = sub["y"].to_numpy() | |
| 172 | + xx = np.column_stack([np.ones(len(x)), x]) | |
| 173 | + beta, *_ = np.linalg.lstsq(xx, y, rcond=None) | |
| 174 | + resid = y - xx @ beta | |
| 175 | + sigma2 = float(resid.var()) | |
| 176 | + lo, hi = float(sub["y_raw"].min()), float(sub["y_raw"].max()) | |
| 177 | + if beta is None or not valid[t]: | |
| 178 | + continue | |
| 179 | + x_new = np.concatenate([[1.0], data[cols].iloc[t].to_numpy()]) | |
| 180 | + pred = float(x_new @ beta) | |
| 181 | + if is_log: | |
| 182 | + pred = float(np.exp(pred + 0.5 * sigma2)) # lognormal smearing | |
| 183 | + pred = float(np.clip(pred, lo, hi)) # insanity filter | |
| 184 | + rows.append((data.index[t], h, spec, pred)) | |
| 185 | + out = pd.DataFrame(rows, columns=["date", "h", "model", "forecast"]) | |
| 186 | + logger.info("%s: %d forecasts", spec, len(out)) | |
| 187 | + return out | |
| 188 | + | |
| 189 | + | |
| 190 | +# ---------------------------------------------------------------- GARCH family | |
| 191 | +def _fit_garch(r_pct: np.ndarray, kind: str): | |
| 192 | + """Fit one GARCH-family model on percentage returns (arch package).""" | |
| 193 | + kw = {"GARCH": dict(vol="GARCH", p=1, q=1), | |
| 194 | + "GJR": dict(vol="GARCH", p=1, o=1, q=1), | |
| 195 | + "EGARCH": dict(vol="EGARCH", p=1, o=1, q=1)}[kind] | |
| 196 | + am = arch_model(r_pct, mean="Constant", dist="normal", rescale=False, **kw) | |
| 197 | + with warnings.catch_warnings(): | |
| 198 | + warnings.simplefilter("ignore") | |
| 199 | + return am.fit(disp="off", show_warning=False) | |
| 200 | + | |
| 201 | + | |
| 202 | +def rolling_garch_forecasts( | |
| 203 | + df: pd.DataFrame, | |
| 204 | + kind: str, | |
| 205 | + horizons: tuple[int, ...] = (1, 5, 22), | |
| 206 | + measure: str = "rv5ss", | |
| 207 | + window: int = 1000, | |
| 208 | + refit_every: int = 22, | |
| 209 | + n_sim: int = 500, | |
| 210 | + seed: int = 20260810, | |
| 211 | +) -> pd.DataFrame: | |
| 212 | + """Rolling GARCH-family forecasts of average daily realized variance. | |
| 213 | + | |
| 214 | + The model is fit on daily close-to-close returns; variance forecasts | |
| 215 | + are mapped into RV units with the in-sample proportionality factor | |
| 216 | + ``c = mean(RV) / mean(eps^2)`` (Hansen & Lunde 2005), which corrects | |
| 217 | + for the overnight-variance wedge in session-limited markets. | |
| 218 | + | |
| 219 | + Parameters | |
| 220 | + ---------- | |
| 221 | + df : pandas.DataFrame | |
| 222 | + Daily measures with ``ret_cc`` and the RV column. | |
| 223 | + kind : str | |
| 224 | + ``GARCH``, ``GJR`` or ``EGARCH``. | |
| 225 | + horizons, measure, window, refit_every | |
| 226 | + As in :func:`rolling_har_forecasts`. | |
| 227 | + n_sim : int | |
| 228 | + Simulation paths for multi-step EGARCH forecasts. | |
| 229 | + seed : int | |
| 230 | + RNG seed for simulated forecasts. | |
| 231 | + | |
| 232 | + Returns | |
| 233 | + ------- | |
| 234 | + pandas.DataFrame | |
| 235 | + Long frame ``date, h, model, forecast``. | |
| 236 | + """ | |
| 237 | + data = df[["ret_cc", measure]].dropna() | |
| 238 | + r_pct = data["ret_cc"].to_numpy() * 100.0 | |
| 239 | + rv = data[measure].to_numpy() | |
| 240 | + idx = data.index | |
| 241 | + hmax = max(horizons) | |
| 242 | + | |
| 243 | + rows: list[tuple] = [] | |
| 244 | + res = None | |
| 245 | + scale_c = 1.0 | |
| 246 | + for t in range(window, len(data)): | |
| 247 | + if res is None or (t - window) % refit_every == 0: | |
| 248 | + r_win = r_pct[t - window:t] | |
| 249 | + try: | |
| 250 | + res = _fit_garch(r_win, kind) | |
| 251 | + except Exception as exc: # noqa: BLE001 — keep last params | |
| 252 | + logger.warning("%s fit failed at %s: %s", kind, idx[t], exc) | |
| 253 | + if res is not None: | |
| 254 | + eps2 = (r_win - r_win.mean()) ** 2 / 1e4 | |
| 255 | + scale_c = float(np.mean(rv[t - window:t]) / max(np.mean(eps2), EPS)) | |
| 256 | + if res is None: | |
| 257 | + continue | |
| 258 | + try: | |
| 259 | + method = "simulation" if kind == "EGARCH" else "analytic" | |
| 260 | + fc = res.forecast( | |
| 261 | + horizon=hmax, reindex=False, start=None, | |
| 262 | + params=res.params, | |
| 263 | + method=method, | |
| 264 | + simulations=n_sim, | |
| 265 | + x=None, | |
| 266 | + random_state=np.random.RandomState(seed) if method == "simulation" else None, | |
| 267 | + align="origin", | |
| 268 | + ) | |
| 269 | + # variance path for the last in-sample date of this window slice | |
| 270 | + var_path = fc.variance.iloc[-1].to_numpy() / 1e4 # back to raw units | |
| 271 | + except Exception as exc: # noqa: BLE001 | |
| 272 | + logger.warning("%s forecast failed at %s: %s", kind, idx[t], exc) | |
| 273 | + continue | |
| 274 | + for h in horizons: | |
| 275 | + rows.append((idx[t], h, kind, scale_c * float(np.mean(var_path[:h])))) | |
| 276 | + out = pd.DataFrame(rows, columns=["date", "h", "model", "forecast"]) | |
| 277 | + logger.info("%s: %d forecasts", kind, len(out)) | |
| 278 | + return out | |
| 279 | + | |
| 280 | + | |
| 281 | +# ---------------------------------------------------------------- Realized GARCH | |
| 282 | +def _rg_negll(params: np.ndarray, r: np.ndarray, logx: np.ndarray) -> float: | |
| 283 | + """Negative log-likelihood of the log-linear Realized GARCH(1,1).""" | |
| 284 | + omega, beta, gamma, xi, phi, tau1, tau2, log_su = params | |
| 285 | + su2 = np.exp(2 * log_su) | |
| 286 | + n = len(r) | |
| 287 | + logh = np.empty(n) | |
| 288 | + logh[0] = np.mean(logx) | |
| 289 | + ll = 0.0 | |
| 290 | + for t in range(n): | |
| 291 | + if t > 0: | |
| 292 | + logh[t] = omega + beta * logh[t - 1] + gamma * logx[t - 1] | |
| 293 | + h = np.exp(logh[t]) | |
| 294 | + z = r[t] / np.sqrt(h) | |
| 295 | + u = logx[t] - xi - phi * logh[t] - tau1 * z - tau2 * (z * z - 1.0) | |
| 296 | + ll += -0.5 * (logh[t] + z * z) - 0.5 * (np.log(su2) + u * u / su2) | |
| 297 | + return -ll | |
| 298 | + | |
| 299 | + | |
| 300 | +def _rg_fit(r: np.ndarray, logx: np.ndarray) -> np.ndarray | None: | |
| 301 | + """Fit the Realized GARCH by quasi-maximum likelihood (L-BFGS-B).""" | |
| 302 | + x0 = np.array([0.06 * np.mean(logx), 0.55, 0.40, -0.20, 1.00, -0.07, 0.07, np.log(0.4)]) | |
| 303 | + bounds = [(-5, 5), (0.0, 0.99), (0.0, 0.99), (-5, 5), | |
| 304 | + (0.5, 1.5), (-1, 1), (-1, 1), (np.log(0.05), np.log(2.0))] | |
| 305 | + with warnings.catch_warnings(): | |
| 306 | + warnings.simplefilter("ignore") | |
| 307 | + res = optimize.minimize( | |
| 308 | + _rg_negll, x0, args=(r, logx), method="L-BFGS-B", | |
| 309 | + bounds=bounds, options={"maxiter": 300}, | |
| 310 | + ) | |
| 311 | + if not np.all(np.isfinite(res.x)) or (res.x[1] + res.x[2] * res.x[4]) >= 0.999: | |
| 312 | + return None | |
| 313 | + return res.x | |
| 314 | + | |
| 315 | + | |
| 316 | +def rolling_realgarch_forecasts( | |
| 317 | + df: pd.DataFrame, | |
| 318 | + horizons: tuple[int, ...] = (1, 5, 22), | |
| 319 | + measure: str = "rv5ss", | |
| 320 | + window: int = 1000, | |
| 321 | + refit_every: int = 22, | |
| 322 | +) -> pd.DataFrame: | |
| 323 | + """Rolling forecasts from the log-linear Realized GARCH (Hansen et al. 2012). | |
| 324 | + | |
| 325 | + The measurement equation links the latent conditional variance to the | |
| 326 | + realized measure, so the model forecasts RV directly: | |
| 327 | + ``E[x_{t+k}]`` is computed analytically with a lognormal correction. | |
| 328 | + | |
| 329 | + Parameters | |
| 330 | + ---------- | |
| 331 | + df : pandas.DataFrame | |
| 332 | + Daily measures with ``ret_cc`` and the RV column. | |
| 333 | + horizons, measure, window, refit_every | |
| 334 | + As in :func:`rolling_har_forecasts`. | |
| 335 | + | |
| 336 | + Returns | |
| 337 | + ------- | |
| 338 | + pandas.DataFrame | |
| 339 | + Long frame ``date, h, model, forecast``. | |
| 340 | + """ | |
| 341 | + data = df[["ret_cc", measure]].dropna() | |
| 342 | + r = data["ret_cc"].to_numpy() * 100.0 | |
| 343 | + logx = np.log(data[measure].to_numpy() * 1e4).clip(-30, 30) # pct^2 units | |
| 344 | + idx = data.index | |
| 345 | + | |
| 346 | + rows: list[tuple] = [] | |
| 347 | + params = None | |
| 348 | + for t in range(window, len(data)): | |
| 349 | + if params is None or (t - window) % refit_every == 0: | |
| 350 | + new = _rg_fit(r[t - window:t], logx[t - window:t]) | |
| 351 | + if new is not None: | |
| 352 | + params = new | |
| 353 | + elif params is None: | |
| 354 | + continue | |
| 355 | + if params is None: | |
| 356 | + continue | |
| 357 | + omega, beta, gamma, xi, phi, tau1, tau2, log_su = params | |
| 358 | + su2 = np.exp(2 * log_su) | |
| 359 | + vu = tau1**2 + 2 * tau2**2 + su2 # variance of the logx innovation | |
| 360 | + rho = beta + gamma * phi # persistence of log h | |
| 361 | + # filter log h_t through the window to get the current state | |
| 362 | + logh = np.mean(logx[t - window:t]) | |
| 363 | + for s in range(t - window + 1, t): | |
| 364 | + logh = omega + beta * logh + gamma * logx[s - 1] | |
| 365 | + mu = omega + beta * logh + gamma * logx[t - 1] # E[log h_{t+1}] | |
| 366 | + v = 0.0 | |
| 367 | + fpath = [] | |
| 368 | + for _k in range(max(horizons)): | |
| 369 | + m_x = xi + phi * mu | |
| 370 | + v_x = phi**2 * v + vu | |
| 371 | + fpath.append(np.exp(m_x + 0.5 * v_x) / 1e4) # E[x_{t+k}] raw units | |
| 372 | + mu = omega + gamma * xi + rho * mu | |
| 373 | + v = rho**2 * v + gamma**2 * vu | |
| 374 | + fpath_arr = np.array(fpath) | |
| 375 | + for h in horizons: | |
| 376 | + rows.append((idx[t], h, "RealGARCH", float(np.mean(fpath_arr[:h])))) | |
| 377 | + out = pd.DataFrame(rows, columns=["date", "h", "model", "forecast"]) | |
| 378 | + logger.info("RealGARCH: %d forecasts", len(out)) | |
| 379 | + return out | |
added
src/wp12/models_ml.py
+619 −0
@@ -0,0 +1,619 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : models_ml.py | |
| 8 | +Description : Modèles de machine learning — LASSO/Ridge/Elastic | |
| 9 | + Net, Random Forest, XGBoost, LightGBM (rolling, | |
| 10 | + tuning walk-forward), LSTM et Transformer (multi- | |
| 11 | + horizon), modèle pooled multi-actifs avec | |
| 12 | + identifiants d'actif, SHAP et importance par | |
| 13 | + permutation. Cible : log RV moyen sur l'horizon, | |
| 14 | + re-transformé par lissage de Duan. | |
| 15 | +================================================================ | |
| 16 | +""" | |
| 17 | + | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import logging | |
| 21 | +from typing import Any | |
| 22 | + | |
| 23 | +import numpy as np | |
| 24 | +import pandas as pd | |
| 25 | + | |
| 26 | +from . import config | |
| 27 | +from .models_econ import horizon_target | |
| 28 | + | |
| 29 | +logger = logging.getLogger(__name__) | |
| 30 | + | |
| 31 | +EPS = 1e-12 | |
| 32 | + | |
| 33 | +HAR_FEATURES = ["lrv_d", "lrv_w", "lrv_m"] | |
| 34 | + | |
| 35 | + | |
| 36 | +# ---------------------------------------------------------------- features | |
| 37 | +def ml_features( | |
| 38 | + panel: pd.DataFrame, measure: str = "rv5ss" | |
| 39 | +) -> dict[str, pd.DataFrame]: | |
| 40 | + """Build per-ticker ML feature frames from the multi-asset panel. | |
| 41 | + | |
| 42 | + Two nested information sets are produced: the HAR set | |
| 43 | + (``lrv_d/w/m``, logs of the daily/weekly/monthly RV averages) and an | |
| 44 | + extended set adding jumps, semivariances, realized quarticity, | |
| 45 | + volume, returns, calendar effects, lagged VIX and cross-asset RV | |
| 46 | + averages. All predictors are known at the end of day *t*. | |
| 47 | + | |
| 48 | + Parameters | |
| 49 | + ---------- | |
| 50 | + panel : pandas.DataFrame | |
| 51 | + Long panel of daily realized measures (all tickers). | |
| 52 | + measure : str | |
| 53 | + Realized-variance column. | |
| 54 | + | |
| 55 | + Returns | |
| 56 | + ------- | |
| 57 | + dict | |
| 58 | + ``ticker -> DataFrame`` of features indexed by date. | |
| 59 | + """ | |
| 60 | + # cross-asset predictors on the union calendar | |
| 61 | + rv_wide = panel.pivot_table(index=panel.index, columns="ticker", values=measure) | |
| 62 | + cls_map = panel.groupby("ticker")["cls"].first() | |
| 63 | + cross = {} | |
| 64 | + for cls in ["equity", "fx", "crypto", "futures"]: | |
| 65 | + cols = [c for c in rv_wide.columns if cls_map.get(c) == cls] | |
| 66 | + if cols: | |
| 67 | + cross[f"lrv_cross_{cls}"] = np.log(rv_wide[cols].clip(lower=EPS)).mean(axis=1) | |
| 68 | + cross_df = pd.DataFrame(cross) | |
| 69 | + vix = None | |
| 70 | + if "VIX" in set(panel["ticker"]): | |
| 71 | + vix = np.log(panel.loc[panel["ticker"] == "VIX", "close"]).rename("lvix") | |
| 72 | + | |
| 73 | + out: dict[str, pd.DataFrame] = {} | |
| 74 | + for tk, df in panel.groupby("ticker"): | |
| 75 | + if cls_map[tk] == "index": | |
| 76 | + continue | |
| 77 | + df = df.sort_index() | |
| 78 | + rv = df[measure].clip(lower=EPS) | |
| 79 | + f = pd.DataFrame(index=df.index) | |
| 80 | + f["lrv_d"] = np.log(rv) | |
| 81 | + f["lrv_w"] = np.log(rv.rolling(5).mean()) | |
| 82 | + f["lrv_m"] = np.log(rv.rolling(22).mean()) | |
| 83 | + # extended block | |
| 84 | + f["jump_share"] = (df["jump"] / rv).clip(0, 1) | |
| 85 | + f["lrsp"] = np.log(df["rsp"].clip(lower=EPS)) | |
| 86 | + f["lrsn"] = np.log(df["rsn"].clip(lower=EPS)) | |
| 87 | + f["sj_norm"] = (df["sj"] / rv).clip(-1, 1) | |
| 88 | + f["lrq"] = np.log(df["rq"].clip(lower=EPS)) | |
| 89 | + f["ret_d"] = df["ret_cc"] | |
| 90 | + f["ret_w"] = df["ret_cc"].rolling(5).sum() | |
| 91 | + f["ret_m"] = df["ret_cc"].rolling(22).sum() | |
| 92 | + f["lvol_d"] = np.log(df["volume"].clip(lower=1.0)).diff() | |
| 93 | + f["dow"] = df.index.dayofweek.astype(float) | |
| 94 | + f = f.join(cross_df.reindex(f.index).ffill(limit=5)) | |
| 95 | + if vix is not None: | |
| 96 | + f = f.join(vix.reindex(f.index).ffill(limit=5)) | |
| 97 | + # target inputs | |
| 98 | + f["_rv"] = rv | |
| 99 | + f["_cls"] = cls_map[tk] | |
| 100 | + out[tk] = f | |
| 101 | + return out | |
| 102 | + | |
| 103 | + | |
| 104 | +def feature_columns(feature_set: str, frame: pd.DataFrame) -> list[str]: | |
| 105 | + """Column list for a named feature set (``har`` or ``extended``).""" | |
| 106 | + if feature_set == "har": | |
| 107 | + return HAR_FEATURES | |
| 108 | + return [c for c in frame.columns if not c.startswith("_")] | |
| 109 | + | |
| 110 | + | |
| 111 | +# ---------------------------------------------------------------- estimators | |
| 112 | +def _make_estimator(name: str, params: dict[str, Any], seed: int): | |
| 113 | + """Instantiate a tabular estimator by name with given hyperparameters.""" | |
| 114 | + from lightgbm import LGBMRegressor | |
| 115 | + from sklearn.ensemble import RandomForestRegressor | |
| 116 | + from sklearn.linear_model import ElasticNet, Lasso, Ridge | |
| 117 | + from sklearn.pipeline import make_pipeline | |
| 118 | + from sklearn.preprocessing import StandardScaler | |
| 119 | + from xgboost import XGBRegressor | |
| 120 | + | |
| 121 | + if name == "Ridge": | |
| 122 | + return make_pipeline(StandardScaler(), Ridge(alpha=params["alpha"])) | |
| 123 | + if name == "LASSO": | |
| 124 | + return make_pipeline(StandardScaler(), Lasso(alpha=params["alpha"], max_iter=5000)) | |
| 125 | + if name == "ElasticNet": | |
| 126 | + return make_pipeline( | |
| 127 | + StandardScaler(), | |
| 128 | + ElasticNet(alpha=params["alpha"], l1_ratio=params["l1_ratio"], max_iter=5000), | |
| 129 | + ) | |
| 130 | + if name == "RF": | |
| 131 | + return RandomForestRegressor( | |
| 132 | + n_estimators=300, min_samples_leaf=params["min_samples_leaf"], | |
| 133 | + max_features=params["max_features"], random_state=seed, n_jobs=-1, | |
| 134 | + ) | |
| 135 | + if name == "XGBoost": | |
| 136 | + return XGBRegressor( | |
| 137 | + n_estimators=400, learning_rate=0.05, max_depth=params["max_depth"], | |
| 138 | + subsample=0.8, colsample_bytree=0.8, random_state=seed, | |
| 139 | + n_jobs=-1, verbosity=0, | |
| 140 | + ) | |
| 141 | + if name == "LightGBM": | |
| 142 | + return LGBMRegressor( | |
| 143 | + n_estimators=400, learning_rate=0.05, num_leaves=params["num_leaves"], | |
| 144 | + min_child_samples=20, subsample=0.8, colsample_bytree=0.8, | |
| 145 | + random_state=seed, n_jobs=-1, verbose=-1, | |
| 146 | + ) | |
| 147 | + raise ValueError(f"unknown estimator {name}") | |
| 148 | + | |
| 149 | + | |
| 150 | +GRIDS: dict[str, list[dict[str, Any]]] = { | |
| 151 | + "Ridge": [{"alpha": a} for a in (0.1, 1.0, 10.0)], | |
| 152 | + "LASSO": [{"alpha": a} for a in (0.0005, 0.005, 0.05)], | |
| 153 | + "ElasticNet": [{"alpha": a, "l1_ratio": r} for a in (0.001, 0.01) for r in (0.3, 0.7)], | |
| 154 | + "RF": [{"min_samples_leaf": m, "max_features": f} | |
| 155 | + for m in (5, 20) for f in (0.33, "sqrt")], | |
| 156 | + "XGBoost": [{"max_depth": d} for d in (3, 5)], | |
| 157 | + "LightGBM": [{"num_leaves": n} for n in (15, 31)], | |
| 158 | +} | |
| 159 | + | |
| 160 | + | |
| 161 | +def _tune(name: str, x: np.ndarray, y: np.ndarray, seed: int) -> dict[str, Any]: | |
| 162 | + """Walk-forward hyperparameter selection inside the training window. | |
| 163 | + | |
| 164 | + The last 20% of the window (in time order) serves as validation for | |
| 165 | + a small grid; the winner is refit on the full window by the caller. | |
| 166 | + """ | |
| 167 | + split = int(len(x) * 0.8) | |
| 168 | + best, best_loss = GRIDS[name][0], np.inf | |
| 169 | + for params in GRIDS[name]: | |
| 170 | + est = _make_estimator(name, params, seed) | |
| 171 | + est.fit(x[:split], y[:split]) | |
| 172 | + pred = est.predict(x[split:]) | |
| 173 | + loss = float(np.mean((pred - y[split:]) ** 2)) | |
| 174 | + if loss < best_loss: | |
| 175 | + best, best_loss = params, loss | |
| 176 | + return best | |
| 177 | + | |
| 178 | + | |
| 179 | +def rolling_ml_forecasts( | |
| 180 | + frame: pd.DataFrame, | |
| 181 | + model_name: str, | |
| 182 | + feature_set: str = "extended", | |
| 183 | + horizons: tuple[int, ...] = (1, 5, 22), | |
| 184 | + window: int = 1000, | |
| 185 | + refit_every: int = 22, | |
| 186 | + tune_every: int = 12, | |
| 187 | + seed: int | None = None, | |
| 188 | +) -> pd.DataFrame: | |
| 189 | + """Rolling out-of-sample forecasts for one tabular ML model. | |
| 190 | + | |
| 191 | + The target is ``log`` average RV over the horizon; predictions are | |
| 192 | + mapped back to variance units with Duan's (1983) smearing factor | |
| 193 | + computed on training residuals, then clipped to the in-sample target | |
| 194 | + range (insanity filter). | |
| 195 | + | |
| 196 | + Parameters | |
| 197 | + ---------- | |
| 198 | + frame : pandas.DataFrame | |
| 199 | + Feature frame of one ticker (from :func:`ml_features`). | |
| 200 | + model_name : str | |
| 201 | + ``LASSO``, ``Ridge``, ``ElasticNet``, ``RF``, ``XGBoost`` or | |
| 202 | + ``LightGBM``. | |
| 203 | + feature_set : str | |
| 204 | + ``har`` (HAR information set) or ``extended``. | |
| 205 | + horizons, window, refit_every | |
| 206 | + Standard rolling protocol. | |
| 207 | + tune_every : int | |
| 208 | + Hyperparameters re-tuned every ``tune_every`` refits. | |
| 209 | + seed : int, optional | |
| 210 | + RNG seed (defaults to the config seed). | |
| 211 | + | |
| 212 | + Returns | |
| 213 | + ------- | |
| 214 | + pandas.DataFrame | |
| 215 | + Long frame ``date, h, model, forecast``. | |
| 216 | + """ | |
| 217 | + seed = seed if seed is not None else config.load_config()["seed"] | |
| 218 | + cols = feature_columns(feature_set, frame) | |
| 219 | + label = f"{model_name}-{'X' if feature_set == 'extended' else 'H'}" | |
| 220 | + rv = frame["_rv"] | |
| 221 | + rows: list[tuple] = [] | |
| 222 | + for h in horizons: | |
| 223 | + y_raw = horizon_target(rv, h) | |
| 224 | + data = pd.concat( | |
| 225 | + [frame[cols], np.log(y_raw.clip(lower=EPS)).rename("y"), | |
| 226 | + y_raw.rename("y_raw")], axis=1, | |
| 227 | + ) | |
| 228 | + ok = data.notna().all(axis=1).to_numpy() | |
| 229 | + est = None | |
| 230 | + smear = 1.0 | |
| 231 | + lo = hi = np.nan | |
| 232 | + params: dict[str, Any] | None = None | |
| 233 | + n_refit = 0 | |
| 234 | + for t in range(window + h, len(data)): | |
| 235 | + if est is None or (t - window - h) % refit_every == 0: | |
| 236 | + sl = slice(max(0, t - h - window), t - h) | |
| 237 | + sub = data.iloc[sl][ok[sl]] | |
| 238 | + if len(sub) < 200: | |
| 239 | + continue | |
| 240 | + x = sub[cols].to_numpy() | |
| 241 | + y = sub["y"].to_numpy() | |
| 242 | + if params is None or n_refit % tune_every == 0: | |
| 243 | + params = _tune(model_name, x, y, seed) | |
| 244 | + est = _make_estimator(model_name, params, seed) | |
| 245 | + est.fit(x, y) | |
| 246 | + resid = y - est.predict(x) | |
| 247 | + smear = float(np.mean(np.exp(resid))) | |
| 248 | + lo, hi = float(sub["y_raw"].min()), float(sub["y_raw"].max()) | |
| 249 | + n_refit += 1 | |
| 250 | + if est is None or not ok[t]: | |
| 251 | + continue | |
| 252 | + pred_log = float(est.predict(data[cols].iloc[[t]].to_numpy())[0]) | |
| 253 | + pred = float(np.clip(np.exp(pred_log) * smear, lo, hi)) | |
| 254 | + rows.append((data.index[t], h, label, pred)) | |
| 255 | + out = pd.DataFrame(rows, columns=["date", "h", "model", "forecast"]) | |
| 256 | + logger.info("%s: %d forecasts", label, len(out)) | |
| 257 | + return out | |
| 258 | + | |
| 259 | + | |
| 260 | +# ---------------------------------------------------------------- pooled model | |
| 261 | +def rolling_pooled_lgbm( | |
| 262 | + frames: dict[str, pd.DataFrame], | |
| 263 | + horizons: tuple[int, ...] = (1, 5, 22), | |
| 264 | + window: int = 1000, | |
| 265 | + refit_every: int = 22, | |
| 266 | + seed: int | None = None, | |
| 267 | + exclude: str | None = None, | |
| 268 | +) -> pd.DataFrame: | |
| 269 | + """Pooled LightGBM trained on all assets jointly (asset identifiers). | |
| 270 | + | |
| 271 | + One model per horizon is fit on the stacked window observations of | |
| 272 | + every ticker, with ``ticker`` and asset class as categorical | |
| 273 | + features. Set ``exclude`` to hold one ticker out of training while | |
| 274 | + still predicting it (cross-asset transferability experiment). | |
| 275 | + | |
| 276 | + Parameters | |
| 277 | + ---------- | |
| 278 | + frames : dict | |
| 279 | + Output of :func:`ml_features`. | |
| 280 | + horizons, window, refit_every | |
| 281 | + Standard rolling protocol. | |
| 282 | + seed : int, optional | |
| 283 | + RNG seed. | |
| 284 | + exclude : str, optional | |
| 285 | + Ticker excluded from the training pool (leave-one-out). | |
| 286 | + | |
| 287 | + Returns | |
| 288 | + ------- | |
| 289 | + pandas.DataFrame | |
| 290 | + Long frame ``date, ticker, h, model, forecast``. | |
| 291 | + """ | |
| 292 | + from lightgbm import LGBMRegressor | |
| 293 | + | |
| 294 | + seed = seed if seed is not None else config.load_config()["seed"] | |
| 295 | + label = "Pooled-LGBM" if exclude is None else "Pooled-LGBM-LOO" | |
| 296 | + stacked = [] | |
| 297 | + for tk, f in frames.items(): | |
| 298 | + cols = feature_columns("extended", f) | |
| 299 | + g = f.copy() | |
| 300 | + g["_ticker"] = tk | |
| 301 | + stacked.append(g) | |
| 302 | + allf = pd.concat(stacked).sort_index() | |
| 303 | + cols = feature_columns("extended", allf.drop(columns=["_ticker"])) | |
| 304 | + | |
| 305 | + rows: list[tuple] = [] | |
| 306 | + for h in horizons: | |
| 307 | + parts = [] | |
| 308 | + for tk, f in frames.items(): | |
| 309 | + y_raw = horizon_target(f["_rv"], h) | |
| 310 | + part = f.copy() | |
| 311 | + part["_y"] = np.log(y_raw.clip(lower=EPS)) | |
| 312 | + part["_y_raw"] = y_raw | |
| 313 | + part["_ticker"] = tk | |
| 314 | + parts.append(part) | |
| 315 | + data = pd.concat(parts).sort_index() | |
| 316 | + data["_tk_cat"] = data["_ticker"].astype("category") | |
| 317 | + data["_cls_cat"] = data["_cls"].astype("category") | |
| 318 | + xcols = cols + ["_tk_cat", "_cls_cat"] | |
| 319 | + dates = data.index.unique().sort_values() | |
| 320 | + est = None | |
| 321 | + smear = 1.0 | |
| 322 | + bounds: dict[str, tuple[float, float]] = {} | |
| 323 | + for i in range(window + h, len(dates)): | |
| 324 | + if est is None or (i - window - h) % refit_every == 0: | |
| 325 | + d_lo, d_hi = dates[max(0, i - h - window)], dates[i - h] | |
| 326 | + sub = data[(data.index >= d_lo) & (data.index < d_hi)].dropna( | |
| 327 | + subset=cols + ["_y"] | |
| 328 | + ) | |
| 329 | + if exclude is not None: | |
| 330 | + sub = sub[sub["_ticker"] != exclude] | |
| 331 | + if len(sub) < 1000: | |
| 332 | + continue | |
| 333 | + est = LGBMRegressor( | |
| 334 | + n_estimators=500, learning_rate=0.05, num_leaves=63, | |
| 335 | + min_child_samples=50, subsample=0.8, colsample_bytree=0.8, | |
| 336 | + random_state=seed, n_jobs=-1, verbose=-1, | |
| 337 | + ) | |
| 338 | + est.fit(sub[xcols], sub["_y"], categorical_feature=["_tk_cat", "_cls_cat"]) | |
| 339 | + resid = sub["_y"] - est.predict(sub[xcols]) | |
| 340 | + smear = float(np.mean(np.exp(resid))) | |
| 341 | + bounds = { | |
| 342 | + tk: (float(g["_y_raw"].min()), float(g["_y_raw"].max())) | |
| 343 | + for tk, g in sub.groupby("_ticker") | |
| 344 | + } | |
| 345 | + if est is None: | |
| 346 | + continue | |
| 347 | + today = data[data.index == dates[i]].dropna(subset=cols) | |
| 348 | + if exclude is not None: | |
| 349 | + today = today[today["_ticker"] == exclude] | |
| 350 | + if today.empty: | |
| 351 | + continue | |
| 352 | + preds = np.exp(est.predict(today[xcols])) * smear | |
| 353 | + for (tk, p, yr) in zip(today["_ticker"], preds, today["_y_raw"]): | |
| 354 | + lo, hi = bounds.get(tk, (np.nan, np.nan)) | |
| 355 | + if np.isfinite(lo): | |
| 356 | + p = float(np.clip(p, lo, hi)) | |
| 357 | + rows.append((dates[i], tk, h, label, float(p))) | |
| 358 | + out = pd.DataFrame(rows, columns=["date", "ticker", "h", "model", "forecast"]) | |
| 359 | + logger.info("%s%s: %d forecasts", label, f" (excl {exclude})" if exclude else "", len(out)) | |
| 360 | + return out | |
| 361 | + | |
| 362 | + | |
| 363 | +# ---------------------------------------------------------------- deep learning | |
| 364 | +def _make_sequences( | |
| 365 | + values: np.ndarray, targets: np.ndarray, seq_len: int | |
| 366 | +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: | |
| 367 | + """Stack rolling sequences; returns (X, y, end_positions).""" | |
| 368 | + xs, ys, pos = [], [], [] | |
| 369 | + for t in range(seq_len - 1, len(values)): | |
| 370 | + if np.isnan(values[t - seq_len + 1: t + 1]).any() or np.isnan(targets[t]).any(): | |
| 371 | + continue | |
| 372 | + xs.append(values[t - seq_len + 1: t + 1]) | |
| 373 | + ys.append(targets[t]) | |
| 374 | + pos.append(t) | |
| 375 | + return np.asarray(xs, dtype=np.float32), np.asarray(ys, dtype=np.float32), np.asarray(pos) | |
| 376 | + | |
| 377 | + | |
| 378 | +def _torch_device(): | |
| 379 | + import torch | |
| 380 | + | |
| 381 | + if torch.backends.mps.is_available(): | |
| 382 | + return torch.device("mps") | |
| 383 | + return torch.device("cpu") | |
| 384 | + | |
| 385 | + | |
| 386 | +def _build_net(arch: str, n_feat: int, n_out: int, cfg: dict[str, Any]): | |
| 387 | + """Construct the LSTM or Transformer forecasting network.""" | |
| 388 | + import torch.nn as nn | |
| 389 | + | |
| 390 | + if arch == "LSTM": | |
| 391 | + class LSTMNet(nn.Module): | |
| 392 | + def __init__(self) -> None: | |
| 393 | + super().__init__() | |
| 394 | + self.lstm = nn.LSTM(n_feat, cfg["hidden"], cfg["layers"], batch_first=True) | |
| 395 | + self.head = nn.Linear(cfg["hidden"], n_out) | |
| 396 | + | |
| 397 | + def forward(self, x): | |
| 398 | + out, _ = self.lstm(x) | |
| 399 | + return self.head(out[:, -1]) | |
| 400 | + | |
| 401 | + return LSTMNet() | |
| 402 | + | |
| 403 | + class TransformerNet(nn.Module): | |
| 404 | + def __init__(self) -> None: | |
| 405 | + super().__init__() | |
| 406 | + d = cfg["d_model"] | |
| 407 | + self.proj = nn.Linear(n_feat, d) | |
| 408 | + self.pos = nn.Parameter(__import__("torch").zeros(1, cfg["seq_len"], d)) | |
| 409 | + layer = nn.TransformerEncoderLayer( | |
| 410 | + d_model=d, nhead=cfg["heads"], dim_feedforward=4 * d, | |
| 411 | + batch_first=True, dropout=0.1, | |
| 412 | + ) | |
| 413 | + self.enc = nn.TransformerEncoder(layer, cfg["blocks"]) | |
| 414 | + self.head = nn.Linear(d, n_out) | |
| 415 | + | |
| 416 | + def forward(self, x): | |
| 417 | + z = self.proj(x) + self.pos[:, : x.shape[1]] | |
| 418 | + z = self.enc(z) | |
| 419 | + return self.head(z.mean(dim=1)) | |
| 420 | + | |
| 421 | + return TransformerNet() | |
| 422 | + | |
| 423 | + | |
| 424 | +def _train_net(net, x_tr, y_tr, x_va, y_va, epochs: int, patience: int, seed: int): | |
| 425 | + """Train with Adam + early stopping on validation MSE; returns the net.""" | |
| 426 | + import torch | |
| 427 | + | |
| 428 | + torch.manual_seed(seed) | |
| 429 | + dev = _torch_device() | |
| 430 | + net = net.to(dev) | |
| 431 | + opt = torch.optim.Adam(net.parameters(), lr=1e-3) | |
| 432 | + lossf = torch.nn.MSELoss() | |
| 433 | + xt = torch.tensor(x_tr, device=dev) | |
| 434 | + yt = torch.tensor(y_tr, device=dev) | |
| 435 | + xv = torch.tensor(x_va, device=dev) | |
| 436 | + yv = torch.tensor(y_va, device=dev) | |
| 437 | + best_state, best_val, bad = None, np.inf, 0 | |
| 438 | + n = len(xt) | |
| 439 | + for _ep in range(epochs): | |
| 440 | + net.train() | |
| 441 | + perm = torch.randperm(n, device=dev) | |
| 442 | + for i in range(0, n, 128): | |
| 443 | + b = perm[i:i + 128] | |
| 444 | + opt.zero_grad() | |
| 445 | + loss = lossf(net(xt[b]), yt[b]) | |
| 446 | + loss.backward() | |
| 447 | + opt.step() | |
| 448 | + net.eval() | |
| 449 | + with torch.no_grad(): | |
| 450 | + val = float(lossf(net(xv), yv)) | |
| 451 | + if val < best_val - 1e-5: | |
| 452 | + best_val, bad = val, 0 | |
| 453 | + best_state = {k: v.detach().clone() for k, v in net.state_dict().items()} | |
| 454 | + else: | |
| 455 | + bad += 1 | |
| 456 | + if bad >= patience: | |
| 457 | + break | |
| 458 | + if best_state is not None: | |
| 459 | + net.load_state_dict(best_state) | |
| 460 | + net.eval() | |
| 461 | + return net | |
| 462 | + | |
| 463 | + | |
| 464 | +def rolling_deep_forecasts( | |
| 465 | + frame: pd.DataFrame, | |
| 466 | + arch: str, | |
| 467 | + horizons: tuple[int, ...] = (1, 5, 22), | |
| 468 | + window: int = 1000, | |
| 469 | + refit_every: int = 66, | |
| 470 | + seed: int | None = None, | |
| 471 | +) -> pd.DataFrame: | |
| 472 | + """Rolling multi-horizon forecasts from an LSTM or Transformer. | |
| 473 | + | |
| 474 | + One network with a 3-dimensional output head (one per horizon) is | |
| 475 | + trained on sequences of standardized features; refits are quarterly | |
| 476 | + (``refit_every=66``) to keep the computational burden manageable. | |
| 477 | + Targets are log average RV; back-transform uses Duan smearing. | |
| 478 | + | |
| 479 | + Parameters | |
| 480 | + ---------- | |
| 481 | + frame : pandas.DataFrame | |
| 482 | + Feature frame of one ticker (from :func:`ml_features`). | |
| 483 | + arch : str | |
| 484 | + ``LSTM`` or ``Transformer``. | |
| 485 | + horizons, window, refit_every | |
| 486 | + Rolling protocol (quarterly refit by default). | |
| 487 | + seed : int, optional | |
| 488 | + RNG seed. | |
| 489 | + | |
| 490 | + Returns | |
| 491 | + ------- | |
| 492 | + pandas.DataFrame | |
| 493 | + Long frame ``date, h, model, forecast``. | |
| 494 | + """ | |
| 495 | + import torch | |
| 496 | + | |
| 497 | + cfg_all = config.load_config()["models"]["ml"] | |
| 498 | + cfg = dict(cfg_all["lstm"] if arch == "LSTM" else cfg_all["transformer"]) | |
| 499 | + cfg.setdefault("seq_len", 22) | |
| 500 | + seed = seed if seed is not None else config.load_config()["seed"] | |
| 501 | + seq_len = int(cfg["seq_len"]) | |
| 502 | + cols = feature_columns("extended", frame) | |
| 503 | + rv = frame["_rv"] | |
| 504 | + hmax_targets = [] | |
| 505 | + for h in horizons: | |
| 506 | + y = horizon_target(rv, h) | |
| 507 | + hmax_targets.append(np.log(y.clip(lower=EPS))) | |
| 508 | + Y = np.column_stack([y.to_numpy() for y in hmax_targets]) | |
| 509 | + Yraw = np.column_stack([horizon_target(rv, h).to_numpy() for h in horizons]) | |
| 510 | + X = frame[cols].to_numpy(dtype=np.float64) | |
| 511 | + idx = frame.index | |
| 512 | + | |
| 513 | + rows: list[tuple] = [] | |
| 514 | + net = None | |
| 515 | + mu = sd = None | |
| 516 | + smear = np.ones(len(horizons)) | |
| 517 | + lo = np.full(len(horizons), np.nan) | |
| 518 | + hi = np.full(len(horizons), np.nan) | |
| 519 | + hmax = max(horizons) | |
| 520 | + for t in range(window + hmax, len(frame)): | |
| 521 | + if net is None or (t - window - hmax) % refit_every == 0: | |
| 522 | + sl = slice(max(0, t - hmax - window), t - hmax) | |
| 523 | + Xw, Yw = X[sl], Y[sl] | |
| 524 | + mu = np.nanmean(Xw, axis=0) | |
| 525 | + sd = np.nanstd(Xw, axis=0) + 1e-9 | |
| 526 | + Xs = (Xw - mu) / sd | |
| 527 | + xs, ys, _pos = _make_sequences(Xs, Yw, seq_len) | |
| 528 | + if len(xs) < 300: | |
| 529 | + continue | |
| 530 | + split = int(len(xs) * 0.85) | |
| 531 | + net = _build_net(arch, xs.shape[2], len(horizons), cfg) | |
| 532 | + net = _train_net(net, xs[:split], ys[:split], xs[split:], ys[split:], | |
| 533 | + int(cfg["epochs"]), int(cfg["patience"]), seed) | |
| 534 | + with torch.no_grad(): | |
| 535 | + fit_pred = net(torch.tensor(xs, device=_torch_device())).cpu().numpy() | |
| 536 | + resid = ys - fit_pred | |
| 537 | + smear = np.exp(resid).mean(axis=0) | |
| 538 | + Yraw_w = Yraw[sl] | |
| 539 | + lo = np.nanmin(Yraw_w, axis=0) | |
| 540 | + hi = np.nanmax(Yraw_w, axis=0) | |
| 541 | + if net is None: | |
| 542 | + continue | |
| 543 | + x_seq = (X[t - seq_len + 1: t + 1] - mu) / sd | |
| 544 | + if np.isnan(x_seq).any(): | |
| 545 | + continue | |
| 546 | + with torch.no_grad(): | |
| 547 | + p_log = net( | |
| 548 | + torch.tensor(x_seq[None].astype(np.float32), device=_torch_device()) | |
| 549 | + ).cpu().numpy()[0] | |
| 550 | + for j, h in enumerate(horizons): | |
| 551 | + pred = float(np.clip(np.exp(p_log[j]) * smear[j], lo[j], hi[j])) | |
| 552 | + rows.append((idx[t], h, arch, pred)) | |
| 553 | + out = pd.DataFrame(rows, columns=["date", "h", "model", "forecast"]) | |
| 554 | + logger.info("%s: %d forecasts", arch, len(out)) | |
| 555 | + return out | |
| 556 | + | |
| 557 | + | |
| 558 | +# ---------------------------------------------------------------- interpretation | |
| 559 | +def shap_and_permutation( | |
| 560 | + frames: dict[str, pd.DataFrame], | |
| 561 | + h: int = 1, | |
| 562 | + window: int = 1000, | |
| 563 | + seed: int | None = None, | |
| 564 | +) -> tuple[pd.DataFrame, pd.DataFrame]: | |
| 565 | + """Global SHAP values and permutation importance of the pooled LightGBM. | |
| 566 | + | |
| 567 | + A pooled LightGBM is fit on the most recent ``window`` days of every | |
| 568 | + ticker; mean absolute SHAP values (TreeExplainer) and permutation | |
| 569 | + importances (on a temporal validation slice) are reported per | |
| 570 | + feature. | |
| 571 | + | |
| 572 | + Returns | |
| 573 | + ------- | |
| 574 | + tuple of pandas.DataFrame | |
| 575 | + ``(shap_table, permutation_table)`` sorted by importance. | |
| 576 | + """ | |
| 577 | + import shap | |
| 578 | + from lightgbm import LGBMRegressor | |
| 579 | + from sklearn.inspection import permutation_importance | |
| 580 | + | |
| 581 | + seed = seed if seed is not None else config.load_config()["seed"] | |
| 582 | + parts = [] | |
| 583 | + for tk, f in frames.items(): | |
| 584 | + y_raw = horizon_target(f["_rv"], h) | |
| 585 | + part = f.copy() | |
| 586 | + part["_y"] = np.log(y_raw.clip(lower=EPS)) | |
| 587 | + part["_ticker"] = tk | |
| 588 | + parts.append(part) | |
| 589 | + data = pd.concat(parts).sort_index() | |
| 590 | + cols = feature_columns("extended", data.drop(columns=["_ticker"])) | |
| 591 | + data = data.dropna(subset=cols + ["_y"]) | |
| 592 | + dates = data.index.unique().sort_values() | |
| 593 | + data = data[data.index >= dates[-window]] | |
| 594 | + split_date = data.index.unique().sort_values()[int(data.index.nunique() * 0.85)] | |
| 595 | + tr, va = data[data.index < split_date], data[data.index >= split_date] | |
| 596 | + | |
| 597 | + est = LGBMRegressor( | |
| 598 | + n_estimators=500, learning_rate=0.05, num_leaves=63, min_child_samples=50, | |
| 599 | + subsample=0.8, colsample_bytree=0.8, random_state=seed, n_jobs=-1, verbose=-1, | |
| 600 | + ) | |
| 601 | + est.fit(tr[cols], tr["_y"]) | |
| 602 | + | |
| 603 | + explainer = shap.TreeExplainer(est) | |
| 604 | + sv = explainer.shap_values(va[cols]) | |
| 605 | + shap_tab = ( | |
| 606 | + pd.DataFrame({"feature": cols, "mean_abs_shap": np.abs(sv).mean(axis=0)}) | |
| 607 | + .sort_values("mean_abs_shap", ascending=False) | |
| 608 | + .reset_index(drop=True) | |
| 609 | + ) | |
| 610 | + pi = permutation_importance( | |
| 611 | + est, va[cols], va["_y"], n_repeats=10, random_state=seed, n_jobs=-1 | |
| 612 | + ) | |
| 613 | + perm_tab = ( | |
| 614 | + pd.DataFrame({"feature": cols, "perm_importance": pi.importances_mean, | |
| 615 | + "perm_std": pi.importances_std}) | |
| 616 | + .sort_values("perm_importance", ascending=False) | |
| 617 | + .reset_index(drop=True) | |
| 618 | + ) | |
| 619 | + return shap_tab, perm_tab | |
added
src/wp12/realized_vol.py
+317 −0
@@ -0,0 +1,317 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : realized_vol.py | |
| 8 | +Description : Mesures de volatilité réalisée journalières — | |
| 9 | + RV 1-min, RV 5-min sous-échantillonnée, realized | |
| 10 | + kernel (Parzen, BNHLS 2008), bipower variation, | |
| 11 | + test de sauts BNS, semivariances signées et | |
| 12 | + signed jumps. Gestion des sessions par classe. | |
| 13 | +================================================================ | |
| 14 | +""" | |
| 15 | + | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import logging | |
| 19 | +from math import gamma as _gamma | |
| 20 | + | |
| 21 | +import numpy as np | |
| 22 | +import pandas as pd | |
| 23 | +from scipy import stats | |
| 24 | + | |
| 25 | +logger = logging.getLogger(__name__) | |
| 26 | + | |
| 27 | +MU1 = np.sqrt(2.0 / np.pi) # E|Z| | |
| 28 | +MU43 = 2 ** (2 / 3) * _gamma(7 / 6) / _gamma(1 / 2) # E|Z|^{4/3} | |
| 29 | +THETA_BNS = (np.pi**2 / 4.0) + np.pi - 5.0 # variance constant of the BNS ratio test | |
| 30 | +PARZEN_CSTAR = 3.5134 # optimal bandwidth constant, Parzen kernel | |
| 31 | + | |
| 32 | +# minimum number of intraday bars for a valid day, per asset class | |
| 33 | +MIN_BARS = {"equity": 250, "fx": 700, "crypto": 700, "futures": 700, "index": 250} | |
| 34 | + | |
| 35 | +# regular trading hours filter (inclusive start, exclusive end), or None for 24h | |
| 36 | +SESSION = { | |
| 37 | + "equity": ("09:30", "16:00"), | |
| 38 | + "index": ("09:30", "16:00"), | |
| 39 | + "fx": None, | |
| 40 | + "crypto": None, | |
| 41 | + "futures": None, | |
| 42 | +} | |
| 43 | + | |
| 44 | + | |
| 45 | +# ---------------------------------------------------------------- primitives | |
| 46 | +def log_returns(prices: np.ndarray) -> np.ndarray: | |
| 47 | + """Log returns of a strictly positive price array. | |
| 48 | + | |
| 49 | + Parameters | |
| 50 | + ---------- | |
| 51 | + prices : numpy.ndarray | |
| 52 | + Intraday price levels. | |
| 53 | + | |
| 54 | + Returns | |
| 55 | + ------- | |
| 56 | + numpy.ndarray | |
| 57 | + First differences of log prices (length ``len(prices) - 1``). | |
| 58 | + """ | |
| 59 | + return np.diff(np.log(prices)) | |
| 60 | + | |
| 61 | + | |
| 62 | +def realized_variance(returns: np.ndarray) -> float: | |
| 63 | + """Plain realized variance: sum of squared intraday returns.""" | |
| 64 | + return float(np.sum(returns**2)) | |
| 65 | + | |
| 66 | + | |
| 67 | +def rv_subsampled(prices: pd.Series, grid_min: int = 5, offsets: int = 5) -> float: | |
| 68 | + """Subsampled sparse-grid realized variance. | |
| 69 | + | |
| 70 | + Averages the realized variance computed on ``offsets`` staggered | |
| 71 | + ``grid_min``-minute grids, following Zhang, Mykland and | |
| 72 | + Aït-Sahalia (2005). | |
| 73 | + | |
| 74 | + Parameters | |
| 75 | + ---------- | |
| 76 | + prices : pandas.Series | |
| 77 | + 1-minute prices indexed by timestamp (one trading day). | |
| 78 | + grid_min : int | |
| 79 | + Sparse grid spacing in minutes. | |
| 80 | + offsets : int | |
| 81 | + Number of staggered grids averaged. | |
| 82 | + | |
| 83 | + Returns | |
| 84 | + ------- | |
| 85 | + float | |
| 86 | + Subsampled realized variance. | |
| 87 | + """ | |
| 88 | + rvs = [] | |
| 89 | + for off in range(offsets): | |
| 90 | + sub = prices.iloc[off::grid_min] | |
| 91 | + if len(sub) > 2: | |
| 92 | + rvs.append(realized_variance(log_returns(sub.to_numpy()))) | |
| 93 | + return float(np.mean(rvs)) if rvs else np.nan | |
| 94 | + | |
| 95 | + | |
| 96 | +def bipower_variation(returns: np.ndarray) -> float: | |
| 97 | + """Realized bipower variation (Barndorff-Nielsen & Shephard 2004). | |
| 98 | + | |
| 99 | + Robust to jumps; scaled to estimate integrated variance. | |
| 100 | + """ | |
| 101 | + n = len(returns) | |
| 102 | + if n < 3: | |
| 103 | + return np.nan | |
| 104 | + absr = np.abs(returns) | |
| 105 | + return float(MU1**-2 * (n / (n - 1)) * np.sum(absr[1:] * absr[:-1])) | |
| 106 | + | |
| 107 | + | |
| 108 | +def tripower_quarticity(returns: np.ndarray) -> float: | |
| 109 | + """Tripower quarticity, a jump-robust estimator of integrated quarticity.""" | |
| 110 | + n = len(returns) | |
| 111 | + if n < 4: | |
| 112 | + return np.nan | |
| 113 | + a = np.abs(returns) ** (4 / 3) | |
| 114 | + return float(n * MU43**-3 * (n / (n - 2)) * np.sum(a[2:] * a[1:-1] * a[:-2])) | |
| 115 | + | |
| 116 | + | |
| 117 | +def bns_jump_test(rv: float, bv: float, tq: float, n: int) -> float: | |
| 118 | + """Ratio-form BNS jump test statistic (Barndorff-Nielsen & Shephard 2006). | |
| 119 | + | |
| 120 | + Parameters | |
| 121 | + ---------- | |
| 122 | + rv, bv, tq : float | |
| 123 | + Realized variance, bipower variation and tripower quarticity. | |
| 124 | + n : int | |
| 125 | + Number of intraday returns. | |
| 126 | + | |
| 127 | + Returns | |
| 128 | + ------- | |
| 129 | + float | |
| 130 | + Asymptotically N(0,1) statistic; large positive values indicate a | |
| 131 | + jump day. | |
| 132 | + """ | |
| 133 | + if not np.isfinite(rv) or not np.isfinite(bv) or bv <= 0 or n < 4: | |
| 134 | + return np.nan | |
| 135 | + ratio = max(tq / bv**2, 1.0) if np.isfinite(tq) else 1.0 | |
| 136 | + denom = np.sqrt(THETA_BNS * ratio / n) | |
| 137 | + return float((1.0 - bv / rv) / denom) if denom > 0 else np.nan | |
| 138 | + | |
| 139 | + | |
| 140 | +def semivariances(returns: np.ndarray) -> tuple[float, float]: | |
| 141 | + """Positive and negative realized semivariance (BNKS 2010). | |
| 142 | + | |
| 143 | + Returns | |
| 144 | + ------- | |
| 145 | + tuple of float | |
| 146 | + ``(RS+, RS-)`` — sums of squared positive and negative returns. | |
| 147 | + """ | |
| 148 | + return ( | |
| 149 | + float(np.sum(returns[returns > 0] ** 2)), | |
| 150 | + float(np.sum(returns[returns < 0] ** 2)), | |
| 151 | + ) | |
| 152 | + | |
| 153 | + | |
| 154 | +def parzen_kernel(x: np.ndarray) -> np.ndarray: | |
| 155 | + """Parzen kernel weights on [0, 1].""" | |
| 156 | + w = np.zeros_like(x) | |
| 157 | + m1 = x <= 0.5 | |
| 158 | + m2 = (x > 0.5) & (x <= 1.0) | |
| 159 | + w[m1] = 1 - 6 * x[m1] ** 2 + 6 * x[m1] ** 3 | |
| 160 | + w[m2] = 2 * (1 - x[m2]) ** 3 | |
| 161 | + return w | |
| 162 | + | |
| 163 | + | |
| 164 | +def realized_kernel(returns: np.ndarray, iv_proxy: float | None = None) -> float: | |
| 165 | + """Realized kernel with Parzen weights (Barndorff-Nielsen et al. 2008). | |
| 166 | + | |
| 167 | + The bandwidth follows the feasible rule | |
| 168 | + :math:`H^* = c^* \\xi^{4/5} n^{3/5}` with | |
| 169 | + :math:`\\xi^2 = \\hat\\omega^2 / \\sqrt{\\widehat{IQ}}` approximated by | |
| 170 | + :math:`\\hat\\omega^2 / IV` where the noise variance is estimated as | |
| 171 | + ``RV_dense / (2 n)`` and *IV* by a sparse-grid RV. | |
| 172 | + | |
| 173 | + Parameters | |
| 174 | + ---------- | |
| 175 | + returns : numpy.ndarray | |
| 176 | + Dense (1-minute) intraday log returns. | |
| 177 | + iv_proxy : float, optional | |
| 178 | + Noise-robust estimate of integrated variance; defaults to the | |
| 179 | + realized variance of the input returns when omitted. | |
| 180 | + | |
| 181 | + Returns | |
| 182 | + ------- | |
| 183 | + float | |
| 184 | + Realized kernel estimate of integrated variance (non-negative by | |
| 185 | + construction with non-flat-top Parzen weights). | |
| 186 | + """ | |
| 187 | + n = len(returns) | |
| 188 | + if n < 10: | |
| 189 | + return np.nan | |
| 190 | + rv_dense = realized_variance(returns) | |
| 191 | + iv = iv_proxy if iv_proxy and np.isfinite(iv_proxy) and iv_proxy > 0 else rv_dense | |
| 192 | + omega2 = rv_dense / (2.0 * n) | |
| 193 | + xi2 = omega2 / iv if iv > 0 else 0.0 | |
| 194 | + H = int(np.clip(PARZEN_CSTAR * xi2**0.4 * n**0.6, 1, n - 1)) | |
| 195 | + gamma0 = float(returns @ returns) | |
| 196 | + k = gamma0 | |
| 197 | + weights = parzen_kernel(np.arange(1, H + 1) / (H + 1)) | |
| 198 | + for h in range(1, H + 1): | |
| 199 | + gam = float(returns[h:] @ returns[:-h]) | |
| 200 | + k += 2.0 * weights[h - 1] * gam | |
| 201 | + return float(max(k, 0.0)) | |
| 202 | + | |
| 203 | + | |
| 204 | +# ---------------------------------------------------------------- daily loop | |
| 205 | +def _day_measures(day: pd.DataFrame, alpha: float) -> dict[str, float]: | |
| 206 | + """Compute all volatility measures for one trading day. | |
| 207 | + | |
| 208 | + Parameters | |
| 209 | + ---------- | |
| 210 | + day : pandas.DataFrame | |
| 211 | + 1-minute bars of a single day (columns ``datetime``, ``close``). | |
| 212 | + alpha : float | |
| 213 | + Size of the BNS jump test. | |
| 214 | + | |
| 215 | + Returns | |
| 216 | + ------- | |
| 217 | + dict | |
| 218 | + All daily realized measures (see module docstring). | |
| 219 | + """ | |
| 220 | + prices = day.set_index("datetime")["close"] | |
| 221 | + r1 = log_returns(prices.to_numpy()) | |
| 222 | + # sparse 5-min grid (offset 0) for jump-robust quantities | |
| 223 | + p5 = prices.iloc[::5] | |
| 224 | + r5 = log_returns(p5.to_numpy()) | |
| 225 | + | |
| 226 | + rv1 = realized_variance(r1) | |
| 227 | + rv5 = rv_subsampled(prices, grid_min=5, offsets=5) | |
| 228 | + rk = realized_kernel(r1, iv_proxy=rv5) | |
| 229 | + rv5_plain = realized_variance(r5) | |
| 230 | + bv = bipower_variation(r5) | |
| 231 | + tq = tripower_quarticity(r5) | |
| 232 | + z = bns_jump_test(rv5_plain, bv, tq, len(r5)) | |
| 233 | + crit = stats.norm.ppf(1 - alpha) | |
| 234 | + is_jump = bool(np.isfinite(z) and z > crit) | |
| 235 | + jump = max(rv5_plain - bv, 0.0) if is_jump else 0.0 | |
| 236 | + cont = rv5_plain - jump | |
| 237 | + rsp, rsn = semivariances(r5) | |
| 238 | + # realized quarticity on the 5-min grid (for HARQ) | |
| 239 | + rq = float(len(r5) / 3.0 * np.sum(r5**4)) if len(r5) > 1 else np.nan | |
| 240 | + | |
| 241 | + return { | |
| 242 | + "rv1": rv1, | |
| 243 | + "rv5ss": rv5, | |
| 244 | + "rk": rk, | |
| 245 | + "rv5": rv5_plain, | |
| 246 | + "bv": bv, | |
| 247 | + "tq": tq, | |
| 248 | + "rq": rq, | |
| 249 | + "z_bns": z, | |
| 250 | + "jump": jump, | |
| 251 | + "cont": cont, | |
| 252 | + "rsp": rsp, | |
| 253 | + "rsn": rsn, | |
| 254 | + "sj": rsp - rsn, | |
| 255 | + "n_bars": float(len(prices)), | |
| 256 | + "ret_intraday": float(np.sum(r1)), | |
| 257 | + "close": float(prices.iloc[-1]), | |
| 258 | + "volume": float(day["volume"].sum()), | |
| 259 | + } | |
| 260 | + | |
| 261 | + | |
| 262 | +def build_daily_rv( | |
| 263 | + bars: pd.DataFrame, | |
| 264 | + cls: str, | |
| 265 | + jump_alpha: float = 0.001, | |
| 266 | + min_bars: int | None = None, | |
| 267 | +) -> pd.DataFrame: | |
| 268 | + """Build the daily realized-measure panel for one instrument. | |
| 269 | + | |
| 270 | + Parameters | |
| 271 | + ---------- | |
| 272 | + bars : pandas.DataFrame | |
| 273 | + 1-minute bars (columns ``datetime``, ``open``, ``high``, ``low``, | |
| 274 | + ``close``, ``volume``) in exchange-local time. | |
| 275 | + cls : str | |
| 276 | + Asset class (``equity``, ``fx``, ``crypto``, ``futures``, | |
| 277 | + ``index``); controls session filtering and the valid-day | |
| 278 | + threshold. | |
| 279 | + jump_alpha : float | |
| 280 | + Size of the BNS jump test used to shrink the jump component. | |
| 281 | + min_bars : int, optional | |
| 282 | + Override the per-class minimum number of intraday bars. | |
| 283 | + | |
| 284 | + Returns | |
| 285 | + ------- | |
| 286 | + pandas.DataFrame | |
| 287 | + One row per valid trading day, indexed by date, with all | |
| 288 | + realized measures plus the close-to-close daily log return | |
| 289 | + (``ret_cc``, includes overnight for session-limited markets). | |
| 290 | + """ | |
| 291 | + if bars.empty: | |
| 292 | + return pd.DataFrame() | |
| 293 | + df = bars.copy() | |
| 294 | + session = SESSION[cls] | |
| 295 | + if session is not None: | |
| 296 | + t = df["datetime"].dt.time | |
| 297 | + lo = pd.Timestamp(f"2000-01-01 {session[0]}").time() | |
| 298 | + hi = pd.Timestamp(f"2000-01-01 {session[1]}").time() | |
| 299 | + df = df[(t >= lo) & (t < hi)] | |
| 300 | + df = df[df["close"] > 0] | |
| 301 | + threshold = min_bars if min_bars is not None else MIN_BARS[cls] | |
| 302 | + | |
| 303 | + rows: list[dict[str, float]] = [] | |
| 304 | + dates: list[pd.Timestamp] = [] | |
| 305 | + for date, day in df.groupby(df["datetime"].dt.normalize()): | |
| 306 | + if len(day) < threshold: | |
| 307 | + continue | |
| 308 | + rows.append(_day_measures(day, jump_alpha)) | |
| 309 | + dates.append(date) | |
| 310 | + if not rows: | |
| 311 | + logger.warning("no valid days for class=%s", cls) | |
| 312 | + return pd.DataFrame() | |
| 313 | + out = pd.DataFrame(rows, index=pd.DatetimeIndex(dates, name="date")) | |
| 314 | + out["ret_cc"] = np.log(out["close"]).diff() | |
| 315 | + logger.info("daily RV built: %d valid days (%s -> %s)", | |
| 316 | + len(out), out.index[0].date(), out.index[-1].date()) | |
| 317 | + return out | |
added
src/wp12/robustness.py
+155 −0
@@ -0,0 +1,155 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : robustness.py | |
| 8 | +Description : Analyses de robustesse — sous-périodes, découpage | |
| 9 | + par classe/actif, sensibilité à la fréquence RV et | |
| 10 | + à la taille de fenêtre, test de fluctuation de | |
| 11 | + Giacomini-Rossi (2010). | |
| 12 | +================================================================ | |
| 13 | +""" | |
| 14 | + | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import logging | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | +import pandas as pd | |
| 21 | + | |
| 22 | +from . import evaluation | |
| 23 | + | |
| 24 | +logger = logging.getLogger(__name__) | |
| 25 | + | |
| 26 | +# Two-sided 5% critical values of the Giacomini-Rossi (2010) fluctuation | |
| 27 | +# test, indexed by the window fraction mu = m / n (their Table 1). | |
| 28 | +GR_CRIT_5PCT = { | |
| 29 | + 0.1: 3.393, 0.2: 3.179, 0.3: 3.012, 0.4: 2.890, 0.5: 2.779, | |
| 30 | + 0.6: 2.634, 0.7: 2.560, 0.8: 2.433, 0.9: 2.278, 1.0: 2.062, | |
| 31 | +} | |
| 32 | + | |
| 33 | + | |
| 34 | +def loss_by_period( | |
| 35 | + merged: pd.DataFrame, | |
| 36 | + subperiods: dict[str, tuple[str, str]], | |
| 37 | + loss: str = "qlike", | |
| 38 | +) -> pd.DataFrame: | |
| 39 | + """Average loss per model within each sub-period. | |
| 40 | + | |
| 41 | + Parameters | |
| 42 | + ---------- | |
| 43 | + merged : pandas.DataFrame | |
| 44 | + Long forecast table with columns ``date, ticker, h, model, | |
| 45 | + forecast, actual``. | |
| 46 | + subperiods : dict | |
| 47 | + ``name -> (start, end)`` date bounds (inclusive). | |
| 48 | + loss : str | |
| 49 | + ``qlike`` or ``mse``. | |
| 50 | + | |
| 51 | + Returns | |
| 52 | + ------- | |
| 53 | + pandas.DataFrame | |
| 54 | + Mean loss per (period, h, model). | |
| 55 | + """ | |
| 56 | + lf = evaluation.LOSSES[loss] | |
| 57 | + rows = [] | |
| 58 | + for name, (lo, hi) in subperiods.items(): | |
| 59 | + sub = merged[(merged["date"] >= lo) & (merged["date"] <= hi)] | |
| 60 | + if sub.empty: | |
| 61 | + continue | |
| 62 | + g = sub.groupby(["h", "model"], observed=True).apply( | |
| 63 | + lambda d: float(np.mean(lf(d["actual"].to_numpy(), d["forecast"].to_numpy()))), | |
| 64 | + include_groups=False, | |
| 65 | + ) | |
| 66 | + for (h, model), v in g.items(): | |
| 67 | + rows.append((name, h, model, v)) | |
| 68 | + return pd.DataFrame(rows, columns=["period", "h", "model", loss]) | |
| 69 | + | |
| 70 | + | |
| 71 | +def loss_by_group( | |
| 72 | + merged: pd.DataFrame, group_col: str, loss: str = "qlike" | |
| 73 | +) -> pd.DataFrame: | |
| 74 | + """Average loss per model within each group (asset class or ticker).""" | |
| 75 | + lf = evaluation.LOSSES[loss] | |
| 76 | + g = merged.groupby([group_col, "h", "model"], observed=True).apply( | |
| 77 | + lambda d: float(np.mean(lf(d["actual"].to_numpy(), d["forecast"].to_numpy()))), | |
| 78 | + include_groups=False, | |
| 79 | + ) | |
| 80 | + return g.rename(loss).reset_index() | |
| 81 | + | |
| 82 | + | |
| 83 | +def giacomini_rossi_fluctuation( | |
| 84 | + loss_a: pd.Series, | |
| 85 | + loss_b: pd.Series, | |
| 86 | + h: int = 1, | |
| 87 | + mu: float = 0.3, | |
| 88 | +) -> pd.DataFrame: | |
| 89 | + """Giacomini-Rossi (2010) fluctuation test of local relative performance. | |
| 90 | + | |
| 91 | + Computes the standardized rolling-mean loss differential of A minus B | |
| 92 | + over a centred window of fraction ``mu`` of the sample, together with | |
| 93 | + the two-sided 5% critical value. | |
| 94 | + | |
| 95 | + Parameters | |
| 96 | + ---------- | |
| 97 | + loss_a, loss_b : pandas.Series | |
| 98 | + Per-date losses of the two models (aligned on dates). | |
| 99 | + h : int | |
| 100 | + Forecast horizon (HAC bandwidth ``h - 1``). | |
| 101 | + mu : float | |
| 102 | + Rolling window as a fraction of the evaluation sample. | |
| 103 | + | |
| 104 | + Returns | |
| 105 | + ------- | |
| 106 | + pandas.DataFrame | |
| 107 | + ``date, stat, crit`` — the fluctuation statistic path; values | |
| 108 | + below ``-crit`` mean A significantly better locally. | |
| 109 | + """ | |
| 110 | + d = (loss_a - loss_b).dropna() | |
| 111 | + n = len(d) | |
| 112 | + m = max(int(round(mu * n)), 30) | |
| 113 | + if n < m + 10: | |
| 114 | + return pd.DataFrame(columns=["date", "stat", "crit"]) | |
| 115 | + # HAC variance estimated on the full sample (GR recommendation) | |
| 116 | + sig2 = evaluation.newey_west_var(d.to_numpy(), max(h - 1, 0)) | |
| 117 | + sig = np.sqrt(max(sig2, 1e-30)) | |
| 118 | + roll = d.rolling(m).mean() | |
| 119 | + stat = np.sqrt(m) * roll / sig | |
| 120 | + mu_grid = np.array(sorted(GR_CRIT_5PCT)) | |
| 121 | + crit = float(np.interp(m / n, mu_grid, [GR_CRIT_5PCT[k] for k in mu_grid])) | |
| 122 | + out = pd.DataFrame({"date": d.index, "stat": stat.to_numpy()}) | |
| 123 | + out["crit"] = crit | |
| 124 | + return out.dropna().reset_index(drop=True) | |
| 125 | + | |
| 126 | + | |
| 127 | +def sensitivity_table( | |
| 128 | + results: dict[str, pd.DataFrame], key_name: str, loss: str = "qlike" | |
| 129 | +) -> pd.DataFrame: | |
| 130 | + """Stack per-variant loss tables into one comparison frame. | |
| 131 | + | |
| 132 | + Parameters | |
| 133 | + ---------- | |
| 134 | + results : dict | |
| 135 | + ``variant -> merged forecast table`` (with ``actual`` column). | |
| 136 | + key_name : str | |
| 137 | + Name of the variant dimension (e.g. ``frequency``, ``window``). | |
| 138 | + loss : str | |
| 139 | + Loss function name. | |
| 140 | + | |
| 141 | + Returns | |
| 142 | + ------- | |
| 143 | + pandas.DataFrame | |
| 144 | + Mean loss per (variant, h, model). | |
| 145 | + """ | |
| 146 | + lf = evaluation.LOSSES[loss] | |
| 147 | + rows = [] | |
| 148 | + for variant, merged in results.items(): | |
| 149 | + g = merged.groupby(["h", "model"], observed=True).apply( | |
| 150 | + lambda d: float(np.mean(lf(d["actual"].to_numpy(), d["forecast"].to_numpy()))), | |
| 151 | + include_groups=False, | |
| 152 | + ) | |
| 153 | + for (h, model), v in g.items(): | |
| 154 | + rows.append((variant, h, model, v)) | |
| 155 | + return pd.DataFrame(rows, columns=[key_name, "h", "model", loss]) | |
added
tests/test_evaluation.py
+125 −0
@@ -0,0 +1,125 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : test_evaluation.py | |
| 8 | +Description : Tests pytest de l'évaluation — propriétés des | |
| 9 | + pertes, taille/puissance du test DM, MCS, Mincer- | |
| 10 | + Zarnowitz, encompassing et combinaisons. | |
| 11 | +================================================================ | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import sys | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | +import pandas as pd | |
| 21 | +import pytest | |
| 22 | + | |
| 23 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 24 | + | |
| 25 | +from wp12 import evaluation as ev # noqa: E402 | |
| 26 | + | |
| 27 | +RNG = np.random.default_rng(20260810) | |
| 28 | + | |
| 29 | + | |
| 30 | +class TestLosses: | |
| 31 | + def test_qlike_zero_at_perfect_forecast(self) -> None: | |
| 32 | + a = RNG.uniform(0.5, 2.0, 100) | |
| 33 | + assert np.allclose(ev.qlike(a, a), 0.0) | |
| 34 | + | |
| 35 | + def test_qlike_positive_and_asymmetric(self) -> None: | |
| 36 | + a = np.ones(10) | |
| 37 | + under = ev.qlike(a, 0.5 * a).mean() | |
| 38 | + over = ev.qlike(a, 2.0 * a).mean() | |
| 39 | + assert under > 0 and over > 0 | |
| 40 | + assert under > over # QLIKE punishes under-prediction more | |
| 41 | + | |
| 42 | + def test_mse_basic(self) -> None: | |
| 43 | + assert ev.mse(np.array([2.0]), np.array([1.0]))[0] == 1.0 | |
| 44 | + | |
| 45 | + | |
| 46 | +class TestDieboldMariano: | |
| 47 | + def test_dm_detects_better_model(self) -> None: | |
| 48 | + n = 800 | |
| 49 | + actual = np.exp(RNG.normal(0, 0.5, n)) | |
| 50 | + good = actual * np.exp(RNG.normal(0, 0.1, n)) | |
| 51 | + bad = actual * np.exp(RNG.normal(0.5, 0.5, n)) | |
| 52 | + la, lb = ev.qlike(actual, good), ev.qlike(actual, bad) | |
| 53 | + stat, p = ev.diebold_mariano(la, lb, h=1) | |
| 54 | + assert stat < -2 and p < 0.05 | |
| 55 | + | |
| 56 | + def test_dm_size_under_null(self) -> None: | |
| 57 | + """Two equally noisy forecasts: rejection rate ~ nominal.""" | |
| 58 | + rejects = 0 | |
| 59 | + for _ in range(200): | |
| 60 | + actual = np.exp(RNG.normal(0, 0.5, 300)) | |
| 61 | + f1 = actual * np.exp(RNG.normal(0, 0.3, 300)) | |
| 62 | + f2 = actual * np.exp(RNG.normal(0, 0.3, 300)) | |
| 63 | + _, p = ev.diebold_mariano(ev.qlike(actual, f1), ev.qlike(actual, f2)) | |
| 64 | + rejects += p < 0.05 | |
| 65 | + assert rejects / 200 < 0.12 | |
| 66 | + | |
| 67 | + | |
| 68 | +class TestMCS: | |
| 69 | + def test_mcs_keeps_good_removes_bad(self) -> None: | |
| 70 | + n = 600 | |
| 71 | + actual = np.exp(RNG.normal(0, 0.5, n)) | |
| 72 | + losses = pd.DataFrame({ | |
| 73 | + "good1": ev.qlike(actual, actual * np.exp(RNG.normal(0, 0.1, n))), | |
| 74 | + "good2": ev.qlike(actual, actual * np.exp(RNG.normal(0, 0.1, n))), | |
| 75 | + "bad": ev.qlike(actual, actual * np.exp(RNG.normal(1.0, 0.5, n))), | |
| 76 | + }) | |
| 77 | + out = ev.model_confidence_set(losses, alpha=0.10, n_boot=800) | |
| 78 | + assert not out.loc[out["model"] == "bad", "in_mcs"].iloc[0] | |
| 79 | + assert out["in_mcs"].sum() >= 1 | |
| 80 | + good_kept = out.loc[out["model"].str.startswith("good"), "in_mcs"] | |
| 81 | + assert good_kept.any() | |
| 82 | + | |
| 83 | + | |
| 84 | +class TestMZAndEncompassing: | |
| 85 | + def test_mz_unbiased_forecast(self) -> None: | |
| 86 | + n = 1000 | |
| 87 | + f = np.exp(RNG.normal(0, 0.5, n)) | |
| 88 | + actual = f * np.exp(RNG.normal(-0.005, 0.1, n)) | |
| 89 | + res = ev.mincer_zarnowitz(actual, f) | |
| 90 | + assert res["beta"] == pytest.approx(1.0, abs=0.1) | |
| 91 | + assert res["r2"] > 0.8 | |
| 92 | + | |
| 93 | + def test_mz_biased_forecast_rejected(self) -> None: | |
| 94 | + n = 1000 | |
| 95 | + f = np.exp(RNG.normal(0, 0.5, n)) | |
| 96 | + actual = 2.0 * f * np.exp(RNG.normal(0, 0.1, n)) | |
| 97 | + res = ev.mincer_zarnowitz(actual, f) | |
| 98 | + assert res["p_joint"] < 0.01 | |
| 99 | + | |
| 100 | + def test_encompassing_detects_added_info(self) -> None: | |
| 101 | + n = 1000 | |
| 102 | + true = np.exp(RNG.normal(0, 0.5, n)) | |
| 103 | + actual = true * np.exp(RNG.normal(0, 0.2, n)) | |
| 104 | + f_base = true * np.exp(RNG.normal(0, 0.4, n)) # noisy version | |
| 105 | + f_rival = 0.5 * f_base + 0.5 * true # contains extra info | |
| 106 | + res = ev.forecast_encompassing(actual, f_base, f_rival) | |
| 107 | + assert res["p_b2"] < 0.05 | |
| 108 | + assert res["b2"] > 0 | |
| 109 | + | |
| 110 | + | |
| 111 | +class TestCombination: | |
| 112 | + def test_combined_beats_worst(self) -> None: | |
| 113 | + n = 700 | |
| 114 | + idx = pd.date_range("2020-01-01", periods=n, freq="B") | |
| 115 | + actual = pd.Series(np.exp(RNG.normal(0, 0.5, n)), index=idx) | |
| 116 | + f = pd.DataFrame({ | |
| 117 | + "m1": actual * np.exp(RNG.normal(0, 0.2, n)), | |
| 118 | + "m2": actual * np.exp(RNG.normal(0.3, 0.4, n)), | |
| 119 | + }, index=idx) | |
| 120 | + combs = ev.combine_forecasts(f, actual, window=60) | |
| 121 | + q_mean = ev.qlike(actual.to_numpy(), combs["Comb-Mean"].to_numpy()).mean() | |
| 122 | + q_inv = np.nanmean(ev.qlike(actual.to_numpy(), combs["Comb-InvMSE"].to_numpy())) | |
| 123 | + q_worst = ev.qlike(actual.to_numpy(), f["m2"].to_numpy()).mean() | |
| 124 | + assert q_mean < q_worst | |
| 125 | + assert q_inv < q_worst | |
added
tests/test_realized_vol.py
+150 −0
@@ -0,0 +1,150 @@ | ||
| 1 | +""" | |
| 2 | +================================================================ | |
| 3 | +Auteur : Simon-Pierre Boucher | |
| 4 | +Contact : contact@spboucher.ai | |
| 5 | +Projet : Prévision de volatilité réalisée multi-actifs | |
| 6 | + (HAR-RV vs GARCH vs Machine Learning) | |
| 7 | +Fichier : test_realized_vol.py | |
| 8 | +Description : Tests pytest des mesures de volatilité réalisée — | |
| 9 | + cohérence RV/BV/kernel sur des diffusions simulées, | |
| 10 | + détection de sauts, semivariances, sessions. | |
| 11 | +================================================================ | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import sys | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | +import pandas as pd | |
| 21 | +import pytest | |
| 22 | + | |
| 23 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 24 | + | |
| 25 | +from wp12 import realized_vol as rvol # noqa: E402 | |
| 26 | + | |
| 27 | +RNG = np.random.default_rng(20260810) | |
| 28 | + | |
| 29 | + | |
| 30 | +def _simulate_day(n: int = 390, sigma_day: float = 0.01, jump: float = 0.0, | |
| 31 | + noise: float = 0.0) -> pd.DataFrame: | |
| 32 | + """Simulate one day of 1-min prices from a Brownian motion (+ optional | |
| 33 | + jump midday and iid microstructure noise).""" | |
| 34 | + r = RNG.normal(0.0, sigma_day / np.sqrt(n), n) | |
| 35 | + if jump: | |
| 36 | + r[n // 2] += jump | |
| 37 | + logp = np.cumsum(r) + np.log(100.0) | |
| 38 | + if noise: | |
| 39 | + logp = logp + RNG.normal(0.0, noise, n) | |
| 40 | + ts = pd.date_range("2024-01-02 09:30", periods=n, freq="1min") | |
| 41 | + return pd.DataFrame({"datetime": ts, "close": np.exp(logp), | |
| 42 | + "open": np.exp(logp), "high": np.exp(logp), | |
| 43 | + "low": np.exp(logp), "volume": 100.0}) | |
| 44 | + | |
| 45 | + | |
| 46 | +class TestPrimitives: | |
| 47 | + def test_rv_matches_true_variance(self) -> None: | |
| 48 | + """RV averaged over many simulated days converges to sigma^2.""" | |
| 49 | + sigma = 0.012 | |
| 50 | + rvs = [] | |
| 51 | + for _ in range(300): | |
| 52 | + day = _simulate_day(sigma_day=sigma) | |
| 53 | + rvs.append(rvol.realized_variance( | |
| 54 | + rvol.log_returns(day["close"].to_numpy()))) | |
| 55 | + assert np.mean(rvs) == pytest.approx(sigma**2, rel=0.05) | |
| 56 | + | |
| 57 | + def test_bipower_robust_to_jump(self) -> None: | |
| 58 | + """BV is nearly unaffected by a jump while RV jumps by ~jump^2.""" | |
| 59 | + sigma, jump = 0.01, 0.02 | |
| 60 | + rv_j, bv_j = [], [] | |
| 61 | + for _ in range(300): | |
| 62 | + day = _simulate_day(sigma_day=sigma, jump=jump) | |
| 63 | + r = rvol.log_returns(day["close"].to_numpy()) | |
| 64 | + rv_j.append(rvol.realized_variance(r)) | |
| 65 | + bv_j.append(rvol.bipower_variation(r)) | |
| 66 | + assert np.mean(rv_j) == pytest.approx(sigma**2 + jump**2, rel=0.1) | |
| 67 | + # BV has a small finite-sample jump bias (adjacent cross-products, | |
| 68 | + # ~ 2*mu1^-2*jump*E|r|), but must stay FAR below the RV inflation. | |
| 69 | + assert np.mean(bv_j) < sigma**2 + 0.5 * jump**2 / 4 | |
| 70 | + assert (np.mean(rv_j) - sigma**2) / (np.mean(bv_j) - sigma**2) > 5 | |
| 71 | + | |
| 72 | + def test_semivariances_sum_to_rv(self) -> None: | |
| 73 | + day = _simulate_day() | |
| 74 | + r = rvol.log_returns(day["close"].to_numpy()) | |
| 75 | + rsp, rsn = rvol.semivariances(r) | |
| 76 | + assert rsp + rsn == pytest.approx(rvol.realized_variance(r), rel=1e-10) | |
| 77 | + assert rsp >= 0 and rsn >= 0 | |
| 78 | + | |
| 79 | + def test_kernel_removes_noise_bias(self) -> None: | |
| 80 | + """With iid noise, RV explodes with n while the kernel stays close | |
| 81 | + to integrated variance.""" | |
| 82 | + sigma, noise = 0.01, 3e-4 | |
| 83 | + rv_list, rk_list = [], [] | |
| 84 | + for _ in range(200): | |
| 85 | + day = _simulate_day(n=390, sigma_day=sigma, noise=noise) | |
| 86 | + prices = day.set_index("datetime")["close"] | |
| 87 | + r = rvol.log_returns(prices.to_numpy()) | |
| 88 | + rv_list.append(rvol.realized_variance(r)) | |
| 89 | + rk_list.append(rvol.realized_kernel( | |
| 90 | + r, iv_proxy=rvol.rv_subsampled(prices))) | |
| 91 | + bias_rv = np.mean(rv_list) / sigma**2 | |
| 92 | + bias_rk = np.mean(rk_list) / sigma**2 | |
| 93 | + assert bias_rv > 1.5 # noise inflates plain RV | |
| 94 | + assert abs(bias_rk - 1) < abs(bias_rv - 1) # kernel shrinks the bias | |
| 95 | + assert bias_rk == pytest.approx(1.0, abs=0.35) | |
| 96 | + | |
| 97 | + def test_kernel_non_negative(self) -> None: | |
| 98 | + for _ in range(50): | |
| 99 | + day = _simulate_day(n=60) | |
| 100 | + r = rvol.log_returns(day["close"].to_numpy()) | |
| 101 | + assert rvol.realized_kernel(r) >= 0.0 | |
| 102 | + | |
| 103 | + def test_bns_flags_jump_days(self) -> None: | |
| 104 | + """The BNS statistic is larger on jump days than diffusion days.""" | |
| 105 | + z_nojump, z_jump = [], [] | |
| 106 | + for _ in range(200): | |
| 107 | + d0 = _simulate_day(sigma_day=0.01) | |
| 108 | + d1 = _simulate_day(sigma_day=0.01, jump=0.02) | |
| 109 | + for day, store in ((d0, z_nojump), (d1, z_jump)): | |
| 110 | + r = rvol.log_returns(day["close"].to_numpy()[::5]) | |
| 111 | + z = rvol.bns_jump_test( | |
| 112 | + rvol.realized_variance(r), rvol.bipower_variation(r), | |
| 113 | + rvol.tripower_quarticity(r), len(r)) | |
| 114 | + store.append(z) | |
| 115 | + # size: ~1% of diffusion days flagged at the 0.1% level | |
| 116 | + crit = 3.09 | |
| 117 | + assert np.mean(np.array(z_nojump) > crit) < 0.05 | |
| 118 | + # power: most jump days flagged | |
| 119 | + assert np.mean(np.array(z_jump) > crit) > 0.60 | |
| 120 | + | |
| 121 | + | |
| 122 | +class TestDailyPanel: | |
| 123 | + def test_build_daily_rv_sessions(self) -> None: | |
| 124 | + """Equity session filtering keeps only 09:30-16:00 bars.""" | |
| 125 | + days = [] | |
| 126 | + for d in pd.date_range("2024-01-02", periods=8, freq="B"): | |
| 127 | + ts = pd.date_range(d + pd.Timedelta(hours=4), periods=960, freq="1min") | |
| 128 | + logp = np.cumsum(RNG.normal(0, 3e-4, 960)) + np.log(100) | |
| 129 | + days.append(pd.DataFrame({ | |
| 130 | + "datetime": ts, "close": np.exp(logp), "open": np.exp(logp), | |
| 131 | + "high": np.exp(logp), "low": np.exp(logp), "volume": 10.0})) | |
| 132 | + bars = pd.concat(days, ignore_index=True) | |
| 133 | + out = rvol.build_daily_rv(bars, cls="equity") | |
| 134 | + assert len(out) == 8 | |
| 135 | + assert (out["n_bars"] == 390).all() | |
| 136 | + # columns present | |
| 137 | + for c in ("rv1", "rv5ss", "rk", "bv", "jump", "cont", "rsp", "rsn", "sj", "rq"): | |
| 138 | + assert c in out.columns | |
| 139 | + # decomposition adds up | |
| 140 | + assert np.allclose(out["cont"] + out["jump"], out["rv5"], rtol=1e-10) | |
| 141 | + | |
| 142 | + def test_min_bars_filter(self) -> None: | |
| 143 | + """Days with too few bars are dropped.""" | |
| 144 | + ts = pd.date_range("2024-01-02 09:30", periods=100, freq="1min") | |
| 145 | + logp = np.cumsum(RNG.normal(0, 3e-4, 100)) + np.log(100) | |
| 146 | + bars = pd.DataFrame({"datetime": ts, "close": np.exp(logp), | |
| 147 | + "open": np.exp(logp), "high": np.exp(logp), | |
| 148 | + "low": np.exp(logp), "volume": 1.0}) | |
| 149 | + out = rvol.build_daily_rv(bars, cls="equity") | |
| 150 | + assert out.empty | |
| 151 | ||