diff --git a/PaPP v2/Modello/README_mean_reversion.md b/PaPP v2/Modello/README_mean_reversion.md index 03e137d..4cfe557 100644 --- a/PaPP v2/Modello/README_mean_reversion.md +++ b/PaPP v2/Modello/README_mean_reversion.md @@ -99,12 +99,15 @@ Modello/ ├── config_mr.yaml parametri del modello di mean-reversion ├── src/ │ ├── mr_labeling.py costruzione dei 3 target + maschere -│ └── mr_train.py pool + walk-forward + LOSO + modelli finali +│ ├── mr_train.py pool + walk-forward + LOSO + modelli finali +│ └── mr_calibrate.py calibrazione + curva precisione/copertura └── results/ - ├── mr_wf_.csv metriche walk-forward per anno - ├── mr_loso_.csv metriche leave-one-symbol-out - ├── mr_summary.json riepilogo medio - └── mr_model_.joblib modelli finali (non versionati) + ├── mr_wf_.csv metriche walk-forward per anno + ├── mr_loso_.csv metriche leave-one-symbol-out + ├── mr_precision_coverage.csv soglia -> copertura/precisione/lift + ├── mr_calibration_metrics.json Brier prima/dopo + LOSO + ├── mr_*.png grafici (affidabilita', precisione/copertura) + └── mr_*.joblib modelli (non versionati) ``` ```bash @@ -112,12 +115,48 @@ pip install -r requirements.txt # copia in ./data i CSV crosses+bars dei 4 simboli (vedi data/README.md), poi: cd src python mr_train.py # usa ../config_mr.yaml +python mr_calibrate.py # calibrazione + soglia operativa (sez. 8) ``` -## 8. Prossimi passi +## 8. Calibrazione e soglia operativa (`src/mr_calibrate.py`) + +Il modello `rev` **ordina** bene gli eventi, ma la probabilità grezza non è +"vera" (vedi curva rossa sotto: un 0.55 grezzo corrispondeva a ~46% di rientri +reali). La **calibrazione isotonica** la rende affidabile nella zona operativa +(0.7–0.9) e migliora il Brier score da **0.198 → 0.189** (più basso = meglio). + +Split temporale onesto: modello base su anni `< 2016`, calibratore su `2016–2017`, +valutazione su `2018+` (mai visto). LOSO calibrato: AUC 0.67–0.70 su simbolo mai +visto → regge. + +![Affidabilità](results/mr_calibration_reliability.png) + +### Curva precisione/copertura (test 2018+, base rate 73%) + +Per ogni soglia di probabilità: **copertura** = quanti segnali tieni, +**precisione** = tasso di rientro effettivo tra quelli tenuti. + +| soglia | copertura | precisione | lift | +|---|---|---|---| +| 0.50 | 99% | 73.1% | 1.00 | +| 0.70 | 85% | 76.2% | 1.04 | +| **0.75** | **51%** | **81.2%** | **1.11** | +| 0.80 | 42% | 82.2% | 1.13 | +| 0.85 | 28% | 83.3% | 1.14 | +| 0.90 | 6% | 87.0% | 1.19 | + +![Precisione vs Copertura](results/mr_precision_coverage.png) + +**Soglia consigliata: ~0.75** → l'EA opera su ~metà dei setup di mean-reversion +ma il tasso di rientro sale dal 73% all'**81%**. Chi vuole essere più selettivo +usa 0.80–0.85 (precisione ~82–83%, copertura 28–42%). La soglia è un parametro: +la tabella completa è in `results/mr_precision_coverage.csv`. + +Nota: tra 0.70 e 0.75 la copertura crolla (85%→51%) perché lì si concentra la +massa delle probabilità; è il punto in cui il filtro inizia a "mordere". + +## 9. Prossimi passi -- **Calibrazione** delle probabilità di `rev` e scelta soglia operativa - (precisione vs copertura) per l'EA. - Aggiungere altri major FX al pool per irrobustire ancora il LOSO. -- **Fase 4**: export del modello `rev` in **ONNX** (skl2onnx) e inferenza in MT5 - come filtro dei segnali di mean-reversion. +- **Fase 4**: export del modello `rev` **calibrato** in **ONNX** (skl2onnx) e + inferenza in MT5 come filtro dei segnali di mean-reversion (soglia ~0.75). diff --git a/PaPP v2/Modello/results/mr_calibration_metrics.json b/PaPP v2/Modello/results/mr_calibration_metrics.json new file mode 100644 index 0000000..cf4a4c7 --- /dev/null +++ b/PaPP v2/Modello/results/mr_calibration_metrics.json @@ -0,0 +1,33 @@ +{ + "method": "isotonic", + "brier_raw": 0.19825470393871938, + "brier_cal": 0.18872294228809272, + "auc": 0.6457651952507415, + "test_base_rate": 0.7297476759628154, + "loso": [ + { + "symbol": "EURUSD", + "brier": 0.1813803944436018, + "auc": 0.6746989606877932, + "base": 0.7324227355699628 + }, + { + "symbol": "USDJPY", + "brier": 0.19381118796660657, + "auc": 0.6742950427540226, + "base": 0.7028252001812962 + }, + { + "symbol": "USDCHF", + "brier": 0.16726747719373602, + "auc": 0.699568173561329, + "base": 0.7603328331741918 + }, + { + "symbol": "GBPUSD", + "brier": 0.17639890281257165, + "auc": 0.6957124361813346, + "base": 0.7374227714033539 + } + ] +} \ No newline at end of file diff --git a/PaPP v2/Modello/results/mr_calibration_reliability.png b/PaPP v2/Modello/results/mr_calibration_reliability.png new file mode 100644 index 0000000..602529c Binary files /dev/null and b/PaPP v2/Modello/results/mr_calibration_reliability.png differ diff --git a/PaPP v2/Modello/results/mr_precision_coverage.csv b/PaPP v2/Modello/results/mr_precision_coverage.csv new file mode 100644 index 0000000..0f1ce83 --- /dev/null +++ b/PaPP v2/Modello/results/mr_precision_coverage.csv @@ -0,0 +1,11 @@ +soglia,copertura,precisione,n_tenuti,lift_vs_base +0.5,0.9932577382776586,0.7307929651342179,19446,1.0014323980820128 +0.55,0.9445295740116457,0.744646333549643,18492,1.02041617682053 +0.6,0.9440698743487588,0.7448466158091219,18483,1.0206906309449841 +0.65,0.8972826642149351,0.7543689873057438,17567,1.0337394857893085 +0.7,0.8463581571151292,0.7623415811707905,16570,1.0446646235152053 +0.75,0.509704770660946,0.8117045796171961,9979,1.1123085504126453 +0.8,0.42312800081724383,0.8221873491067118,8284,1.1266734738441382 +0.85,0.2760241086934314,0.8330866025166543,5404,1.1416091204641323 +0.9,0.0641025641025641,0.8701195219123506,1255,1.1923566878980891 +0.95,0.0,,0, diff --git a/PaPP v2/Modello/results/mr_precision_coverage.png b/PaPP v2/Modello/results/mr_precision_coverage.png new file mode 100644 index 0000000..7843a81 Binary files /dev/null and b/PaPP v2/Modello/results/mr_precision_coverage.png differ diff --git a/PaPP v2/Modello/src/mr_calibrate.py b/PaPP v2/Modello/src/mr_calibrate.py new file mode 100644 index 0000000..a56e88e --- /dev/null +++ b/PaPP v2/Modello/src/mr_calibrate.py @@ -0,0 +1,166 @@ +"""Calibrazione delle probabilita' del modello `rev` + scelta soglia operativa. + +Perche': il modello `rev` ordina bene gli eventi (AUC ~0.68) ma la probabilita' +grezza puo' non essere "vera" (un 0.80 deve voler dire ~80% di rientri reali). +La calibrazione rende le probabilita' affidabili; la curva precisione/copertura +serve a scegliere la soglia per l'EA. + +Split temporale onesto (nessun leakage): + train_core : anni < calib_start -> addestra il modello base + calib : [calib_start, test_start) -> stima il calibratore + test : >= test_start -> valutazione (mai vista) + +Output (in ../results): + mr_calibration_reliability.png curva di affidabilita' (prima/dopo) + mr_precision_coverage.png precisione e copertura vs soglia + mr_precision_coverage.csv tabella soglia -> copertura/precisione/lift + mr_calibration_metrics.json Brier prima/dopo + LOSO + mr_rev_calibrated.joblib modello calibrato finale (non versionato) + +Uso: + python mr_calibrate.py [config_mr.yaml] [method] # method: isotonic|sigmoid +""" +from __future__ import annotations +import os, sys, json +import numpy as np, pandas as pd +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from mr_train import load_cfg, load_pool +from mr_labeling import make_targets +from features import select_xy +from model import make_model +from sklearn.calibration import CalibratedClassifierCV +from sklearn.frozen import FrozenEstimator +from sklearn.metrics import brier_score_loss, roc_auc_score + +RESULTS = os.path.normpath(os.path.join(HERE, "..", "results")) +CALIB_START = 2016 +TEST_START = 2018 + + +def reliability(y, p, bins=10): + edges = np.linspace(0, 1, bins + 1) + idx = np.clip(np.digitize(p, edges) - 1, 0, bins - 1) + xs, ys, ns = [], [], [] + for b in range(bins): + m = idx == b + if m.sum() > 0: + xs.append(p[m].mean()); ys.append(y[m].mean()); ns.append(int(m.sum())) + return np.array(xs), np.array(ys), np.array(ns) + + +def prec_cov(y, p, taus): + rows = [] + base = y.mean() + for t in taus: + keep = p >= t + cov = keep.mean() + prec = y[keep].mean() if keep.sum() > 0 else np.nan + rows.append({"soglia": round(float(t), 3), "copertura": float(cov), + "precisione": float(prec) if not np.isnan(prec) else np.nan, + "n_tenuti": int(keep.sum()), + "lift_vs_base": float(prec / base) if keep.sum() > 0 else np.nan}) + return pd.DataFrame(rows) + + +def run(cfg_path, method="isotonic"): + cfg = load_cfg(cfg_path) + num, cat = cfg["features_numeric"], cfg["features_categorical"] + df = load_pool(cfg) + y_all, mask = make_targets(df, cfg["horizon"], cfg["drop_trivial"], cfg["trivial_eps"])["rev"] + d = df[mask].reset_index(drop=True) + y = y_all[mask] + yr = d.time.dt.year.values + + tr = yr < CALIB_START + ca = (yr >= CALIB_START) & (yr < TEST_START) + te = yr >= TEST_START + print(f"train_core<{CALIB_START}: {tr.sum()} calib[{CALIB_START}-{TEST_START}): " + f"{ca.sum()} test>={TEST_START}: {te.sum()} base_rate test={y[te].mean():.3f}") + + base = make_model(cfg, num, cat) + base.fit(select_xy(d[tr], num, cat), y[tr]) + + Xte = select_xy(d[te], num, cat); yte = y[te] + p_raw = base.predict_proba(Xte)[:, 1] + + cal = CalibratedClassifierCV(FrozenEstimator(base), method=method) + cal.fit(select_xy(d[ca], num, cat), y[ca]) + p_cal = cal.predict_proba(Xte)[:, 1] + + b_raw, b_cal = brier_score_loss(yte, p_raw), brier_score_loss(yte, p_cal) + auc = roc_auc_score(yte, p_cal) + print(f"Brier grezzo={b_raw:.4f} calibrato={b_cal:.4f} (piu' basso = meglio) AUC={auc:.3f}") + + # ---- reliability plot ---- + os.makedirs(RESULTS, exist_ok=True) + xr, yr_, _ = reliability(yte, p_raw) + xc, yc, _ = reliability(yte, p_cal) + plt.figure(figsize=(5, 5)) + plt.plot([0, 1], [0, 1], "k--", lw=1, label="ideale") + plt.plot(xr, yr_, "o-", color="#c0392b", label=f"grezzo (Brier {b_raw:.3f})") + plt.plot(xc, yc, "o-", color="#27ae60", label=f"{method} (Brier {b_cal:.3f})") + plt.xlabel("probabilita' prevista"); plt.ylabel("frequenza reale di rientro") + plt.title(f"Affidabilita' rev (test >= {TEST_START})"); plt.legend(); plt.tight_layout() + plt.savefig(os.path.join(RESULTS, "mr_calibration_reliability.png"), dpi=110); plt.close() + + # ---- precisione/copertura ---- + taus = np.round(np.arange(0.50, 0.951, 0.05), 2) + pc = prec_cov(yte, p_cal, taus) + pc.to_csv(os.path.join(RESULTS, "mr_precision_coverage.csv"), index=False) + print("\nsoglia copertura precisione lift n") + for _, r in pc.iterrows(): + print(f" {r.soglia:.2f} {r.copertura*100:5.1f}% " + f"{r.precisione*100:5.1f}% {r.lift_vs_base:.2f} {int(r.n_tenuti)}") + + fig, ax1 = plt.subplots(figsize=(6, 4)) + ax1.plot(pc.soglia, pc.precisione * 100, "o-", color="#27ae60") + ax1.set_xlabel("soglia di probabilita'"); ax1.set_ylabel("precisione (% rientri)", color="#27ae60") + ax1.axhline(yte.mean() * 100, color="#7f8c8d", ls=":", label=f"base rate {yte.mean()*100:.0f}%") + ax2 = ax1.twinx() + ax2.plot(pc.soglia, pc.copertura * 100, "s--", color="#2980b9") + ax2.set_ylabel("copertura (% segnali tenuti)", color="#2980b9") + ax1.set_title("Precisione vs Copertura (rev calibrato)"); ax1.legend(loc="lower left") + fig.tight_layout(); fig.savefig(os.path.join(RESULTS, "mr_precision_coverage.png"), dpi=110) + plt.close() + + # ---- LOSO: Brier calibrato su simbolo mai visto ---- + loso = [] + for s in cfg["symbols"]: + m_tr = d.symbol.values != s; m_te = d.symbol.values == s + b = make_model(cfg, num, cat) + cv = CalibratedClassifierCV(b, method=method, cv=5) + cv.fit(select_xy(d[m_tr], num, cat), y[m_tr]) + pp = cv.predict_proba(select_xy(d[m_te], num, cat))[:, 1] + loso.append({"symbol": s, "brier": float(brier_score_loss(y[m_te], pp)), + "auc": float(roc_auc_score(y[m_te], pp)), "base": float(y[m_te].mean())}) + print("\nLOSO (calibrato, simbolo mai visto):") + for r in loso: + print(f" {r['symbol']:7s} Brier={r['brier']:.4f} AUC={r['auc']:.3f} base={r['base']:.3f}") + + with open(os.path.join(RESULTS, "mr_calibration_metrics.json"), "w") as f: + json.dump({"method": method, "brier_raw": b_raw, "brier_cal": b_cal, + "auc": auc, "test_base_rate": float(yte.mean()), "loso": loso}, f, indent=2) + + # ---- modello finale: base su (train+calib) + calibratore sull'ultimo blocco ---- + fit_mask = yr < TEST_START + base_f = make_model(cfg, num, cat); base_f.fit(select_xy(d[fit_mask], num, cat), y[fit_mask]) + final = CalibratedClassifierCV(base_f, method=method, cv=5) + final.fit(select_xy(d[fit_mask], num, cat), y[fit_mask]) + try: + import joblib + joblib.dump(final, os.path.join(RESULTS, "mr_rev_calibrated.joblib")) + except Exception as e: + print("modello non serializzato:", e) + print(f"\nsalvati grafici, tabella e modello in {RESULTS}") + return pc + + +if __name__ == "__main__": + cfg_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "config_mr.yaml") + method = sys.argv[2] if len(sys.argv) > 2 else "isotonic" + run(cfg_path, method)