M16 — HAR asymétrique débiaisé : le signal survit-il hors biais ?

Ce notebook réévalue le modèle HAR asymétrique fondé sur les semivariances positive (RV+) et négative (RV-) face à une baseline HAR classique débiaisée selon le même protocole train-only.

Objectif

Le résultat historique sur BTC annonçait trois horizons gagnants, mais comparait deux prévisionneurs dont les biais OOS différaient. Comme MSE = biais² + variance, une baseline biaisée peut gonfler artificiellement l’avantage apparent du modèle asymétrique.

Nous testons donc une question plus stricte : après calibration sur la seule queue d’entraînement, l’asymétrie RV-/RV+ conserve-t-elle un avantage de précision sur BTC ?

Protocole

  • walk-forward expanding, 5 folds ;
  • horizons 1, 5 et 10 jours ;
  • quatre seeds de contrôle (OLS déterministe : elles doivent être identiques) ;
  • calibration du biais sur 60 observations train-only ;
  • test Diebold-Mariano sur la perte MSE ;
  • biais signé et variance des erreurs reportés séparément.
log RV_{t+h} = b0 + b1 log(RV-_t) + b2 log(RV+_t)
               + b3 moyenne_5j(log RV) + b4 moyenne_22j(log RV) + e
import json
import subprocess
import sys
from pathlib import Path

import numpy as np
import pandas as pd

results_path = Path("scripts/results/m16_har_asymmetric_btc_debiased.json")
if not results_path.exists():
    command = [
        sys.executable,
        "scripts/har_asymmetric.py",
        "--coins", "BTC-USD",
        "--horizons", "1", "5", "10",
        "--seeds", "0", "7", "42", "99",
        "--n-splits", "5",
        "--skip-remote",
        "--debias",
        "--calibration-size", "60",
        "--out-json", str(results_path),
    ]
    subprocess.run(command, check=True)

with results_path.open(encoding="utf-8") as stream:
    data = json.load(stream)

df = pd.DataFrame(data["rows"])
aggregated = pd.DataFrame(data["aggregated"])
print(
    f"Résultats chargés : {len(df)} lignes, "
    f"{df['coin'].nunique()} actif, {df['horizon'].nunique()} horizons, "
    f"{df['seed'].nunique()} seeds."
)
print(f"Configuration : {data['config']}")
Résultats chargés : 12 lignes, 1 actif, 3 horizons, 4 seeds.
Configuration : {'coins': ['BTC-USD'], 'horizons': [1, 5, 10], 'seeds': [0, 7, 42, 99], 'n_splits': 5, 'refit_every': 22, 'debias': True, 'calibration_size': 60}

1. Contrôle de stabilité des quatre seeds

Les seeds ne créent pas quatre expériences indépendantes : l’estimation OLS est déterministe. Elles servent ici de contrôle de reproductibilité. Une différence entre seeds signalerait une source de non-déterminisme inattendue.

stability = (
    df.groupby("horizon")
    .agg(
        asym_mse_min=("asym_mse_logrv", "min"),
        asym_mse_max=("asym_mse_logrv", "max"),
        classic_mse_min=("classic_mse_logrv", "min"),
        classic_mse_max=("classic_mse_logrv", "max"),
    )
)
stability["asym_spread"] = stability["asym_mse_max"] - stability["asym_mse_min"]
stability["classic_spread"] = stability["classic_mse_max"] - stability["classic_mse_min"]
print("=== Stabilité exacte entre seeds ===")
print(stability[["asym_spread", "classic_spread"]].to_string(float_format="%.3e"))
assert (stability[["asym_spread", "classic_spread"]].to_numpy() == 0.0).all()
print("\nContrôle réussi : les quatre seeds OLS sont bit-identiques.")
=== Stabilité exacte entre seeds ===
         asym_spread  classic_spread
horizon                             
1          0.000e+00       0.000e+00
5          0.000e+00       0.000e+00
10         0.000e+00       0.000e+00

Contrôle réussi : les quatre seeds OLS sont bit-identiques.

2. Résultats débiaisés et test Diebold-Mariano

Chaque modèle soustrait un biais estimé sur une queue de calibration appartenant exclusivement à l’entraînement. Le test DM compare ensuite les pertes quadratiques sur les mêmes dates OOS. Le verdict BEATS exige une amélioration positive et un DM significatif ; pour l’OLS déterministe, le ratio cross-seed en σ est non applicable plutôt qu’artificiellement infini.

table = aggregated[[
    "horizon",
    "mean_classic_mse",
    "mean_asym_mse",
    "mean_reduction_pct",
    "median_dm_pvalue",
    "seed_stable",
    "verdict",
]].copy()
table.columns = [
    "Horizon", "MSE HAR débiaisé", "MSE asymétrique débiaisé",
    "Réduction MSE (%)", "DM p médiane", "Seeds stables", "Verdict",
]
print("=== HAR asymétrique contre HAR classique, calibration symétrique ===")
print(table.to_string(index=False, float_format="%.6f"))
print(
    "\nSynthèse : "
    f"{int((table['Verdict'] == 'BEATS').sum())}/3 BEATS, "
    f"{int((table['Verdict'] == 'INCONCLUSIVE').sum())}/3 INCONCLUSIVE."
)
=== HAR asymétrique contre HAR classique, calibration symétrique ===
 Horizon  MSE HAR débiaisé  MSE asymétrique débiaisé  Réduction MSE (%)  DM p médiane  Seeds stables      Verdict
       1          0.843774                  0.848154          -0.519177      0.244862           True INCONCLUSIVE
       5          0.417886                  0.403593           3.420461      0.011363           True        BEATS
      10          0.389457                  0.369614           5.095250      0.005123           True        BEATS

Synthèse : 2/3 BEATS, 1/3 INCONCLUSIVE.

3. Décomposition MSE = biais² + variance

Le débiaisage doit ramener le biais signé près de zéro sans éditer les erreurs OOS. Cette décomposition distingue un gain de niveau moyen d’un véritable gain de dispersion des erreurs.

bias_variance = (
    df.drop_duplicates(subset=["horizon"])
    [[
        "horizon", "asym_bias_oos", "classic_bias_oos",
        "asym_error_variance", "classic_error_variance",
    ]]
    .sort_values("horizon")
    .copy()
)
bias_variance["asym_bias_squared"] = bias_variance["asym_bias_oos"] ** 2
bias_variance["classic_bias_squared"] = bias_variance["classic_bias_oos"] ** 2
print("=== Biais signé et variance des erreurs OOS ===")
print(bias_variance.to_string(index=False, float_format="%.6f"))
print("\nLes biais résiduels sont petits ; l'écart restant porte surtout sur la variance.")
=== Biais signé et variance des erreurs OOS ===
 horizon  asym_bias_oos  classic_bias_oos  asym_error_variance  classic_error_variance  asym_bias_squared  classic_bias_squared
       1      -0.005109         -0.003880             0.848128                0.843759           0.000026              0.000015
       5      -0.003967         -0.001552             0.403577                0.417884           0.000016              0.000002
      10      -0.004194         -0.002419             0.369596                0.389452           0.000018              0.000006

Les biais résiduels sont petits ; l'écart restant porte surtout sur la variance.

4. Ce qui change par rapport au résultat historique

Le verdict historique brut était BEATS sur les trois horizons BTC. Après calibration symétrique et sans fuite, l’horizon court ne montre plus d’avantage : l’asymétrie conserve un signal uniquement aux horizons 5 et 10 jours.

comparison = table[[
    "Horizon", "Réduction MSE (%)", "DM p médiane", "Verdict",
]].copy()
print("=== Verdict BTC après débiaisage symétrique ===")
print(comparison.to_string(index=False, float_format="%.6f"))

h1 = comparison.loc[comparison["Horizon"] == 1].iloc[0]
assert h1["Verdict"] == "INCONCLUSIVE"
assert float(h1["Réduction MSE (%)"]) < 0.0
assert set(comparison.loc[comparison["Verdict"] == "BEATS", "Horizon"]) == {5, 10}
print("\nVerdict mesuré : 2/3 BEATS (h=5, h=10), h=1 INCONCLUSIVE.")
=== Verdict BTC après débiaisage symétrique ===
 Horizon  Réduction MSE (%)  DM p médiane      Verdict
       1          -0.519177      0.244862 INCONCLUSIVE
       5           3.420461      0.011363        BEATS
      10           5.095250      0.005123        BEATS

Verdict mesuré : 2/3 BEATS (h=5, h=10), h=1 INCONCLUSIVE.

5. Audit des séries persistées

Le script conserve les dates, les cibles et les deux prévisions sur un support strictement commun. Cela permet de recalculer le MSE et le biais indépendamment du résumé JSON, plutôt que de faire confiance à un verdict opaque.

checks = []
for _, row in df.drop_duplicates(subset=["horizon"]).iterrows():
    pred_asym = np.asarray(row["pred_asym"], dtype=float)
    pred_classic = np.asarray(row["pred_classic_har"], dtype=float)
    target = np.asarray(row["pred_target"], dtype=float)
    dates = row["pred_dates"]
    assert len(dates) == len(pred_asym) == len(pred_classic) == len(target)
    asym_mse = float(np.mean((pred_asym - target) ** 2))
    classic_mse = float(np.mean((pred_classic - target) ** 2))
    assert np.isclose(asym_mse, row["asym_mse_logrv"])
    assert np.isclose(classic_mse, row["classic_mse_logrv"])
    checks.append({
        "horizon": int(row["horizon"]),
        "n": len(target),
        "asym_mse_recalculé": asym_mse,
        "classic_mse_recalculé": classic_mse,
        "dates_uniques": len(set(dates)) == len(dates),
    })
checks = pd.DataFrame(checks)
print("=== Recalcul indépendant depuis les séries persistées ===")
print(checks.to_string(index=False, float_format="%.6f"))
=== Recalcul indépendant depuis les séries persistées ===
 horizon    n  asym_mse_recalculé  classic_mse_recalculé  dates_uniques
       1 1890            0.848154               0.843774           True
       5 1870            0.403593               0.417886           True
      10 1845            0.369614               0.389457           True

6. Lecture par horizon

La calibration change fortement l’interprétation : le modèle asymétrique est légèrement moins précis à un jour, puis meilleur à cinq et dix jours. Le résultat soutient un signal de persistance à moyen terme, pas un avantage universel de la décomposition asymétrique.

horizon_reading = table[[
    "Horizon", "Réduction MSE (%)", "DM p médiane", "Verdict",
]].copy()
horizon_reading["Lecture"] = horizon_reading["Horizon"].map({
    1: "aucun avantage après débiaisage",
    5: "gain modeste et significatif",
    10: "gain persistant et significatif",
})
print("=== Lecture par horizon ===")
print(horizon_reading.to_string(index=False, float_format="%.6f"))
=== Lecture par horizon ===
 Horizon  Réduction MSE (%)  DM p médiane      Verdict                         Lecture
       1          -0.519177      0.244862 INCONCLUSIVE aucun avantage après débiaisage
       5           3.420461      0.011363        BEATS    gain modeste et significatif
      10           5.095250      0.005123        BEATS gain persistant et significatif

Conclusion

Le re-test symétriquement débiaisé réfute le verdict brut 3/3 :

  • h=1 devient INCONCLUSIVE et l’avantage numérique s’inverse légèrement ;
  • h=5 et h=10 restent BEATS sous DM-MSE ;
  • les quatre seeds sont bit-identiques, comme attendu pour OLS ;
  • les biais OOS résiduels sont proches de zéro, donc le résultat restant porte surtout sur la variance des erreurs.

Verdict M16 BTC : 2/3 BEATS, 1/3 INCONCLUSIVE, 0/3 NO BEATS.

La décomposition RV-/RV+ reste utile pour les horizons moyens et longs, mais ne doit plus être présentée comme supérieure à tous les horizons. Ce notebook évalue une prévision de volatilité, pas une stratégie : coûts de transaction, Sharpe et drawdown sont donc hors de ce verdict et devront être mesurés avant tout claim de performance de trading.

final_summary = table.copy()
final_summary["Biais² asym."] = bias_variance["asym_bias_squared"].to_numpy()
final_summary["Biais² HAR"] = bias_variance["classic_bias_squared"].to_numpy()
print("=== Synthèse finale M16 BTC débiaisé ===")
print(final_summary.to_string(index=False, float_format="%.6f"))
print("\nVerdict final : 2/3 BEATS, 1/3 INCONCLUSIVE, 0/3 NO BEATS.")
=== Synthèse finale M16 BTC débiaisé ===
 Horizon  MSE HAR débiaisé  MSE asymétrique débiaisé  Réduction MSE (%)  DM p médiane  Seeds stables      Verdict  Biais² asym.  Biais² HAR
       1          0.843774                  0.848154          -0.519177      0.244862           True INCONCLUSIVE      0.000026    0.000015
       5          0.417886                  0.403593           3.420461      0.011363           True        BEATS      0.000016    0.000002
      10          0.389457                  0.369614           5.095250      0.005123           True        BEATS      0.000018    0.000006

Verdict final : 2/3 BEATS, 1/3 INCONCLUSIVE, 0/3 NO BEATS.
Retour au sommet