From fe10be5eccb0979649700aee5568850298df6af7 Mon Sep 17 00:00:00 2001 From: Pietro Giacobazzi Date: Wed, 17 Jun 2026 07:45:46 +0000 Subject: [PATCH] PaPP v2: Fase 3 - architettura classificatore extra-rendimento (walk-forward + OOS) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- PaPP v2/Modello/.gitignore | 4 + PaPP v2/Modello/README.md | 88 ++++++++++++++ PaPP v2/Modello/config.yaml | 53 +++++++++ PaPP v2/Modello/data/README.md | 8 ++ PaPP v2/Modello/requirements.txt | 8 ++ PaPP v2/Modello/results/oos_metrics.json | 9 ++ PaPP v2/Modello/results/walkforward_folds.csv | 14 +++ PaPP v2/Modello/src/data.py | 54 +++++++++ PaPP v2/Modello/src/evaluate.py | 23 ++++ PaPP v2/Modello/src/features.py | 33 ++++++ PaPP v2/Modello/src/labeling.py | 40 +++++++ PaPP v2/Modello/src/model.py | 31 +++++ PaPP v2/Modello/src/splits.py | 30 +++++ PaPP v2/Modello/src/train.py | 108 ++++++++++++++++++ 14 files changed, 503 insertions(+) create mode 100644 PaPP v2/Modello/.gitignore create mode 100644 PaPP v2/Modello/README.md create mode 100644 PaPP v2/Modello/config.yaml create mode 100644 PaPP v2/Modello/data/README.md create mode 100644 PaPP v2/Modello/requirements.txt create mode 100644 PaPP v2/Modello/results/oos_metrics.json create mode 100644 PaPP v2/Modello/results/walkforward_folds.csv create mode 100644 PaPP v2/Modello/src/data.py create mode 100644 PaPP v2/Modello/src/evaluate.py create mode 100644 PaPP v2/Modello/src/features.py create mode 100644 PaPP v2/Modello/src/labeling.py create mode 100644 PaPP v2/Modello/src/model.py create mode 100644 PaPP v2/Modello/src/splits.py create mode 100644 PaPP v2/Modello/src/train.py diff --git a/PaPP v2/Modello/.gitignore b/PaPP v2/Modello/.gitignore new file mode 100644 index 0000000..52b7884 --- /dev/null +++ b/PaPP v2/Modello/.gitignore @@ -0,0 +1,4 @@ +data/*.csv +results/*.joblib +src/__pycache__/ +*.pyc diff --git a/PaPP v2/Modello/README.md b/PaPP v2/Modello/README.md new file mode 100644 index 0000000..eb8f067 --- /dev/null +++ b/PaPP v2/Modello/README.md @@ -0,0 +1,88 @@ +# PaPP v2 — Fase 3: classificatore extra-rendimento (architettura) + +Architettura del modello che prova a prevedere, al momento di un incrocio, se +l'**extra-rendimento a 10 giorni** (rispetto alla baseline dello stesso regime) +sarà **positivo o no**. È lo scheletro modulare della Fase 3, già pronto per +l'export ONNX della Fase 4. + +> Stato: **scaffolding funzionante end-to-end**. Il modello di riferimento è un +> baseline volutamente semplice; i risultati attuali confermano che l'edge è +> debole (vedi sotto) e indicano dove migliorare. L'obiettivo di questa cartella +> è la **struttura**, non ancora un modello ottimizzato. + +--- + +## 1. Idea e target + +- **Target** (binario): `1` se `extra-rendimento a 10g > 0`, altrimenti `0`, dove + `extra = cret_10(incrocio) − media cret_10 della baseline nello stesso regime`. +- **Regime** = `trend (sopra/sotto MA365) × tercile(cluster) × tercile(velocità)`. +- **Feature** (solo informazioni note all'incrocio, nessun esito futuro): + `dist_med_pct, cluster_pct, cluster_exp, slope_a, slope_b, vel_med, acc_med, + vol_med` (numeriche) + `pair, dir, trend, dow, month` (categoriche). + +### Niente leakage (punto chiave) +Terzili del regime e medie di baseline sono stimati **solo sul training** +(`labeling.RegimeBaseline.fit`) e applicati a validazione/OOS. Così la label di +test non usa mai informazione futura. + +--- + +## 2. Validazione + +- **Walk-forward espansivo**: train = tutti gli anni fino a T, test = anno T+1, + scorrendo T (configurabile `min_train_years`, `step_years`). +- **Hold-out OOS**: tutti gli anni `>= oos_start_year` (default **2020**) sono + tenuti fuori dal training e usati solo alla fine. + +Metriche (`evaluate.py`): AUC, accuracy, Brier, e **precisione/lift sul decile a +probabilità più alta** (uso pratico: si opera solo sui segnali più forti). + +--- + +## 3. Struttura + +``` +Modello/ +├── config.yaml tutti i parametri (target, regime, feature, split, modello) +├── requirements.txt +├── src/ +│ ├── data.py caricamento CSV + etichetta di regime +│ ├── labeling.py RegimeBaseline: baseline per regime e target (fit su train) +│ ├── features.py ColumnTransformer (one-hot + impute), selezione X +│ ├── splits.py split OOS + walk-forward espansivo +│ ├── model.py factory modello (hist_gbdt | logistic) +│ ├── evaluate.py metriche +│ └── train.py pipeline end-to-end +├── results/ metriche walk-forward + OOS (modello .joblib non versionato) +└── data/ i 2 CSV di input (non versionati) +``` + +## 4. Come eseguire + +```bash +pip install -r requirements.txt +# copia i 2 CSV in ./data (vedi data/README.md), poi: +cd src +python train.py # usa ../config.yaml +``` + +## 5. Risultati attuali (EURUSD, hold-out 2020+) + +Con il baseline `hist_gbdt` e tutte le coppie: +- walk-forward medio **AUC ≈ 0.51**, OOS 2020+ **AUC ≈ 0.52**, lift sul decile ≈ 1.0×. + +Lettura onesta: **vicino al caso**. È coerente con la Fase 2 (edge direzionali +piccoli) e ci dà un riferimento pulito e senza leakage da cui partire. + +## 6. Prossimi miglioramenti previsti + +- **Restringere ai pattern robusti** (q<0.10): in `config.yaml` valorizzare + `restrict_pairs` con gli incroci MA121/MA182/MA365 emersi in Fase 2. +- **Target alternativi**: classificare solo le code (es. extra nel top/bottom 30%) + invece del semplice segno; oppure regressione sull'extra-rendimento. +- **Feature aggiuntive**: percentile storico delle metriche (come nel pannello), + interazioni coppia×regime, stato di mean-reversion in corso. +- **Calibrazione** delle probabilità e scelta soglia per massimizzare la + precisione sui segnali operativi. +- **Fase 4**: export del `Pipeline` in **ONNX** (skl2onnx) e inferenza in MT5. diff --git a/PaPP v2/Modello/config.yaml b/PaPP v2/Modello/config.yaml new file mode 100644 index 0000000..0837c96 --- /dev/null +++ b/PaPP v2/Modello/config.yaml @@ -0,0 +1,53 @@ +# Configurazione Fase 3 — classificatore extra-rendimento positivo + +symbol: EURUSD +year_min: 1999 # esclude i dati EURUSD sintetici pre-1999 + +# --- target --- +horizon: 10 # orizzonte (giorni D1) per l'extra-rendimento +target: excess_positive # 1 se extra-rendimento a `horizon` > 0, altrimenti 0 + +# --- definizione del regime per la baseline (terzili stimati SOLO sul train) --- +regime: + use_trend: true # sopra/sotto MA365 + cluster_terciles: true + velocity_terciles: true + +# --- feature usate dal modello (note al momento dell'incrocio) --- +features_numeric: + - dist_med_pct + - cluster_pct + - cluster_exp + - slope_a + - slope_b + - vel_med + - acc_med + - vol_med +features_categorical: + - pair + - dir + - trend + - dow + - month + +# --- validazione --- +oos_start_year: 2020 # hold-out finale mai usato in training +walkforward: + scheme: expanding # finestra espansiva + min_train_years: 8 # anni minimi nel primo fold + step_years: 1 + +# --- modello di riferimento --- +model: + kind: hist_gbdt # hist_gbdt | logistic + hist_gbdt: + max_depth: 4 + learning_rate: 0.05 + max_iter: 400 + l2_regularization: 1.0 + early_stopping: true + +# opzionale: limita ai pattern robusti (q<0.10). Vuoto = usa tutti gli incroci. +restrict_pairs: [] + +seed: 42 diff --git a/PaPP v2/Modello/data/README.md b/PaPP v2/Modello/data/README.md new file mode 100644 index 0000000..ba640a7 --- /dev/null +++ b/PaPP v2/Modello/data/README.md @@ -0,0 +1,8 @@ +# Dati di input (non versionati) + +Copia qui i due CSV prodotti da `PaPP v2/PaPP_CrossExport.mq5`: + +- `PaPP_crosses__D1.csv` +- `PaPP_bars__D1.csv` + +Sono gli stessi file usati in `PaPP v2/Analisi/`. Poi lancia `src/train.py`. diff --git a/PaPP v2/Modello/requirements.txt b/PaPP v2/Modello/requirements.txt new file mode 100644 index 0000000..45e0baa --- /dev/null +++ b/PaPP v2/Modello/requirements.txt @@ -0,0 +1,8 @@ +pandas +numpy +scikit-learn +pyyaml +joblib +# Fase 4 (export ONNX), opzionali finche' non si esporta: +# skl2onnx +# onnxruntime diff --git a/PaPP v2/Modello/results/oos_metrics.json b/PaPP v2/Modello/results/oos_metrics.json new file mode 100644 index 0000000..0ecadf3 --- /dev/null +++ b/PaPP v2/Modello/results/oos_metrics.json @@ -0,0 +1,9 @@ +{ + "n": 3951, + "base_rate": 0.49253353581371806, + "auc": 0.5241849128463528, + "acc": 0.5206276891926095, + "brier": 0.2758629924455611, + "prec_top_decile": 0.48860759493670886, + "lift_top_decile": 0.9920290892060312 +} \ No newline at end of file diff --git a/PaPP v2/Modello/results/walkforward_folds.csv b/PaPP v2/Modello/results/walkforward_folds.csv new file mode 100644 index 0000000..2185a3b --- /dev/null +++ b/PaPP v2/Modello/results/walkforward_folds.csv @@ -0,0 +1,14 @@ +n,base_rate,auc,acc,brier,prec_top_decile,lift_top_decile,fold,test_years +506,0.6027667984189723,0.5552565043634288,0.5533596837944664,0.2929861795083319,0.66,1.0949508196721314,0,2007-2007 +528,0.4431818181818182,0.4868160939589511,0.48674242424242425,0.3025718576380564,0.19230769230769232,0.43392504930966475,1,2008-2008 +618,0.5857605177993528,0.48292904005524856,0.46116504854368934,0.32693476077811057,0.5081967213114754,0.8675844579295353,2,2009-2009 +570,0.44912280701754387,0.5532568670382165,0.531578947368421,0.28012422532136644,0.631578947368421,1.40625,3,2010-2010 +669,0.554559043348281,0.3955570831599703,0.4200298953662182,0.3276694334072587,0.3484848484848485,0.6283999019848078,4,2011-2011 +616,0.5227272727272727,0.5127920733510796,0.5032467532467533,0.2825509892233342,0.5901639344262295,1.129009265858874,5,2012-2012 +583,0.5265866209262435,0.5670466883821933,0.5214408233276158,0.2796922887853283,0.7068965517241379,1.342412669886555,6,2013-2013 +565,0.2884955752212389,0.5871104599700881,0.4424778761061947,0.3559192608214337,0.17857142857142858,0.6189745836985101,7,2014-2014 +627,0.45614035087719296,0.46299448352234274,0.49920255183413076,0.31880860582951115,0.3709677419354839,0.8132754342431763,8,2015-2015 +749,0.4512683578104139,0.5659165838840179,0.5046728971962616,0.2895300011880442,0.6756756756756757,1.4972813049736124,9,2016-2016 +628,0.6496815286624203,0.4584336007130124,0.49044585987261147,0.2895065709863026,0.3870967741935484,0.5958254269449715,10,2017-2017 +622,0.5,0.41347794170862584,0.45980707395498394,0.29186125547058084,0.2903225806451613,0.5806451612903226,11,2018-2018 +707,0.36633663366336633,0.5378947186982901,0.5487977369165488,0.27594238285206885,0.5142857142857142,1.4038610038610038,12,2019-2019 diff --git a/PaPP v2/Modello/src/data.py b/PaPP v2/Modello/src/data.py new file mode 100644 index 0000000..c5ada04 --- /dev/null +++ b/PaPP v2/Modello/src/data.py @@ -0,0 +1,54 @@ +"""Caricamento dei dati e definizione del regime. + +I dati sono i due CSV prodotti da `PaPP v2/PaPP_CrossExport.mq5`: + - PaPP_crosses__D1.csv (un record per incrocio) + - PaPP_bars__D1.csv (un record per giorno D1 = baseline) + +Per default cercati in ../data; sovrascrivibili via argomento. + +NB sulla correttezza temporale: i terzili del regime e le medie di baseline +NON vengono calcolati qui su tutti i dati, ma stimati sul SOLO training +(vedi labeling.RegimeBaseline.fit) e applicati al test. Qui carichiamo solo. +""" +from __future__ import annotations +import os +import numpy as np +import pandas as pd + +HERE = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.normpath(os.path.join(HERE, "..", "data")) + + +def _read(path: str) -> pd.DataFrame: + df = pd.read_csv(path) + df["time"] = pd.to_datetime(df["time"], format="%Y.%m.%d", errors="coerce") + if df["time"].isna().all(): + df["time"] = pd.to_datetime(df["time"], errors="coerce") + return df.dropna(subset=["time"]).sort_values("time").reset_index(drop=True) + + +def load(symbol: str = "EURUSD", year_min: int = 1999, data_dir: str | None = None): + d = data_dir or DATA_DIR + crosses = _read(os.path.join(d, f"PaPP_crosses_{symbol}_D1.csv")) + bars = _read(os.path.join(d, f"PaPP_bars_{symbol}_D1.csv")) + if year_min: + crosses = crosses[crosses.time.dt.year >= year_min].reset_index(drop=True) + bars = bars[bars.time.dt.year >= year_min].reset_index(drop=True) + return crosses, bars + + +def regime_labels(df: pd.DataFrame, edges: dict, cfg_regime: dict) -> pd.Series: + """Etichetta di regime coerente con `edges` (terzili stimati sul train).""" + parts = [] + if cfg_regime.get("use_trend", True): + parts.append((df["trend"] > 0).astype(int).astype(str)) + if cfg_regime.get("cluster_terciles", True): + parts.append(np.digitize(df["cluster_pct"], edges["cl"]).astype(str)) + if cfg_regime.get("velocity_terciles", True): + parts.append(np.digitize(df["vel_med"], edges["ve"]).astype(str)) + if not parts: + return pd.Series(["all"] * len(df), index=df.index) + out = parts[0] + for p in parts[1:]: + out = out + "_" + p + return out diff --git a/PaPP v2/Modello/src/evaluate.py b/PaPP v2/Modello/src/evaluate.py new file mode 100644 index 0000000..1fecb9e --- /dev/null +++ b/PaPP v2/Modello/src/evaluate.py @@ -0,0 +1,23 @@ +"""Metriche di valutazione del classificatore.""" +from __future__ import annotations +import numpy as np +from sklearn.metrics import roc_auc_score, accuracy_score, brier_score_loss + + +def metrics(y_true, p_pred) -> dict: + y_true = np.asarray(y_true) + p = np.asarray(p_pred) + out = {"n": int(len(y_true)), "base_rate": float(y_true.mean())} + if len(np.unique(y_true)) < 2: + out.update({"auc": np.nan, "acc": np.nan, "brier": np.nan, + "prec_top_decile": np.nan, "lift_top_decile": np.nan}) + return out + out["auc"] = float(roc_auc_score(y_true, p)) + out["acc"] = float(accuracy_score(y_true, (p >= 0.5).astype(int))) + out["brier"] = float(brier_score_loss(y_true, p)) + # precisione sul decile a piu' alta probabilita' (uso pratico: prendo solo i segnali piu' forti) + k = max(1, int(0.1 * len(p))) + top = np.argsort(p)[-k:] + out["prec_top_decile"] = float(y_true[top].mean()) + out["lift_top_decile"] = float(y_true[top].mean() / max(y_true.mean(), 1e-9)) + return out diff --git a/PaPP v2/Modello/src/features.py b/PaPP v2/Modello/src/features.py new file mode 100644 index 0000000..4ea4f31 --- /dev/null +++ b/PaPP v2/Modello/src/features.py @@ -0,0 +1,33 @@ +"""Feature engineering: costruisce la matrice X dalle colonne di contesto. + +Le feature sono SOLO informazioni note al momento dell'incrocio (nessun esito +futuro). Categoriche e numeriche sono gestite da un ColumnTransformer +(one-hot per le categoriche, passthrough per le numeriche) per restare +compatibili con l'export ONNX della Fase 4. +""" +from __future__ import annotations +import pandas as pd +from sklearn.compose import ColumnTransformer +from sklearn.preprocessing import OneHotEncoder +from sklearn.impute import SimpleImputer +from sklearn.pipeline import Pipeline + + +def build_preprocessor(num_cols: list[str], cat_cols: list[str]) -> ColumnTransformer: + num = Pipeline([("impute", SimpleImputer(strategy="median"))]) + cat = Pipeline([ + ("impute", SimpleImputer(strategy="most_frequent")), + ("oh", OneHotEncoder(handle_unknown="ignore", sparse_output=False)), + ]) + return ColumnTransformer([ + ("num", num, num_cols), + ("cat", cat, cat_cols), + ]) + + +def select_xy(df: pd.DataFrame, num_cols: list[str], cat_cols: list[str]): + cols = num_cols + cat_cols + X = df[cols].copy() + for c in cat_cols: + X[c] = X[c].astype(str) + return X diff --git a/PaPP v2/Modello/src/labeling.py b/PaPP v2/Modello/src/labeling.py new file mode 100644 index 0000000..2878bee --- /dev/null +++ b/PaPP v2/Modello/src/labeling.py @@ -0,0 +1,40 @@ +"""Costruzione del target: extra-rendimento positivo a `horizon` giorni. + +extra = cret_h(incrocio) - baseline_mean_h(regime) + +`RegimeBaseline` impara i terzili del regime e la media di baseline per regime +SOLO sui dati di training (fit), e li applica a qualunque set (transform). +Cosi' la label di test non usa informazione futura. +""" +from __future__ import annotations +import numpy as np +import pandas as pd +from data import regime_labels + + +class RegimeBaseline: + def __init__(self, cfg_regime: dict, horizon: int): + self.cfg_regime = cfg_regime + self.h = horizon + self.edges_: dict = {} + self.base_mean_: pd.Series | None = None + self.base_glob_: float = 0.0 + + def fit(self, bars_train: pd.DataFrame) -> "RegimeBaseline": + self.edges_ = { + "cl": np.quantile(bars_train["cluster_pct"], [1/3, 2/3]), + "ve": np.quantile(bars_train["vel_med"], [1/3, 2/3]), + } + reg = regime_labels(bars_train, self.edges_, self.cfg_regime) + col = f"cret_{self.h}" + self.base_mean_ = bars_train.assign(_r=reg).groupby("_r")[col].mean() + self.base_glob_ = float(bars_train[col].mean()) + return self + + def excess(self, crosses: pd.DataFrame) -> np.ndarray: + reg = regime_labels(crosses, self.edges_, self.cfg_regime) + bexp = reg.map(self.base_mean_).fillna(self.base_glob_).values + return crosses[f"cret_{self.h}"].values - bexp + + def label(self, crosses: pd.DataFrame) -> np.ndarray: + return (self.excess(crosses) > 0).astype(int) diff --git a/PaPP v2/Modello/src/model.py b/PaPP v2/Modello/src/model.py new file mode 100644 index 0000000..f2e296a --- /dev/null +++ b/PaPP v2/Modello/src/model.py @@ -0,0 +1,31 @@ +"""Factory del modello di riferimento. + +`hist_gbdt` (HistGradientBoosting) come baseline forte e veloce; `logistic` +come riferimento lineare interpretabile. Entrambi esportabili in ONNX +(skl2onnx) per la Fase 4. +""" +from __future__ import annotations +from sklearn.ensemble import HistGradientBoostingClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import Pipeline +from features import build_preprocessor + + +def make_model(cfg: dict, num_cols: list[str], cat_cols: list[str]) -> Pipeline: + kind = cfg["model"]["kind"] + pre = build_preprocessor(num_cols, cat_cols) + if kind == "hist_gbdt": + p = cfg["model"]["hist_gbdt"] + clf = HistGradientBoostingClassifier( + max_depth=p["max_depth"], + learning_rate=p["learning_rate"], + max_iter=p["max_iter"], + l2_regularization=p["l2_regularization"], + early_stopping=p["early_stopping"], + random_state=cfg["seed"], + ) + elif kind == "logistic": + clf = LogisticRegression(max_iter=1000, C=1.0, random_state=cfg["seed"]) + else: + raise ValueError(f"model.kind sconosciuto: {kind}") + return Pipeline([("pre", pre), ("clf", clf)]) diff --git a/PaPP v2/Modello/src/splits.py b/PaPP v2/Modello/src/splits.py new file mode 100644 index 0000000..d318291 --- /dev/null +++ b/PaPP v2/Modello/src/splits.py @@ -0,0 +1,30 @@ +"""Split temporali: walk-forward espansivo + hold-out finale out-of-sample. + +- `walk_forward_folds`: per la validazione interna sui dati pre-OOS. Finestra + espansiva: train = [inizio .. anno T], test = anno T+1, scorrendo T. +- L'hold-out OOS (>= oos_start_year) e' tenuto separato e usato solo alla fine. +""" +from __future__ import annotations +import pandas as pd + + +def split_oos(df: pd.DataFrame, oos_start_year: int): + dev = df[df.time.dt.year < oos_start_year].reset_index(drop=True) + oos = df[df.time.dt.year >= oos_start_year].reset_index(drop=True) + return dev, oos + + +def walk_forward_folds(df: pd.DataFrame, min_train_years: int, step_years: int = 1): + """Genera (train_idx, test_idx) su `df` (gia' solo periodo di sviluppo).""" + years = sorted(df.time.dt.year.unique()) + if len(years) <= min_train_years: + return + start = years[0] + t = start + min_train_years - 1 + while t < years[-1]: + test_years = list(range(t + 1, min(t + 1 + step_years, years[-1] + 1))) + train_idx = df.index[df.time.dt.year <= t].to_numpy() + test_idx = df.index[df.time.dt.year.isin(test_years)].to_numpy() + if len(test_idx) > 0 and len(train_idx) > 0: + yield train_idx, test_idx + t += step_years diff --git a/PaPP v2/Modello/src/train.py b/PaPP v2/Modello/src/train.py new file mode 100644 index 0000000..65ef692 --- /dev/null +++ b/PaPP v2/Modello/src/train.py @@ -0,0 +1,108 @@ +"""Pipeline Fase 3 end-to-end. + +1. carica incroci + baseline +2. tiene da parte l'hold-out OOS (>= oos_start_year) +3. walk-forward espansivo sul periodo di sviluppo: + - per ogni fold: fit baseline+terzili sul SOLO train, costruisce le label, + addestra il modello, valuta sul fold di test +4. riaddestra sul periodo di sviluppo completo e valuta sull'hold-out OOS +5. salva metriche e modello finale + +Uso: + python train.py [config.yaml] +""" +from __future__ import annotations +import os, sys, json +import numpy as np +import pandas as pd +import yaml + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from data import load +from labeling import RegimeBaseline +from features import select_xy +from splits import split_oos, walk_forward_folds +from model import make_model +from evaluate import metrics + +RESULTS = os.path.normpath(os.path.join(HERE, "..", "results")) + + +def load_cfg(path: str) -> dict: + with open(path) as f: + return yaml.safe_load(f) + + +def run(cfg_path: str): + cfg = load_cfg(cfg_path) + np.random.seed(cfg["seed"]) + num_cols = cfg["features_numeric"] + cat_cols = cfg["features_categorical"] + h = cfg["horizon"] + + crosses, bars = load(cfg["symbol"], cfg["year_min"]) + if cfg.get("restrict_pairs"): + crosses = crosses[crosses.pair.isin(cfg["restrict_pairs"])].reset_index(drop=True) + # serve cret_h presente (le ultime barre non hanno il futuro completo) + crosses = crosses.dropna(subset=[f"cret_{h}"]).reset_index(drop=True) + + dev_cr, oos_cr = split_oos(crosses, cfg["oos_start_year"]) + dev_ba, oos_ba = split_oos(bars, cfg["oos_start_year"]) + print(f"sviluppo: {len(dev_cr)} incroci | OOS({cfg['oos_start_year']}+): {len(oos_cr)} incroci") + + # ---------- walk-forward ---------- + fold_rows = [] + for i, (tr_idx, te_idx) in enumerate(walk_forward_folds( + dev_cr, cfg["walkforward"]["min_train_years"], cfg["walkforward"]["step_years"])): + tr, te = dev_cr.loc[tr_idx], dev_cr.loc[te_idx] + # baseline/terzili stimati SOLO sul train (barre fino all'ultimo anno di train) + max_train_year = tr.time.dt.year.max() + ba_tr = dev_ba[dev_ba.time.dt.year <= max_train_year] + rb = RegimeBaseline(cfg["regime"], h).fit(ba_tr) + ytr, yte = rb.label(tr), rb.label(te) + mdl = make_model(cfg, num_cols, cat_cols) + mdl.fit(select_xy(tr, num_cols, cat_cols), ytr) + pte = mdl.predict_proba(select_xy(te, num_cols, cat_cols))[:, 1] + m = metrics(yte, pte) + m["fold"] = i + m["test_years"] = f"{te.time.dt.year.min()}-{te.time.dt.year.max()}" + fold_rows.append(m) + print(f" fold {i} test {m['test_years']:>9} n={m['n']:5d} " + f"AUC={m['auc']:.3f} acc={m['acc']:.3f} " + f"prec@10%={m['prec_top_decile']:.3f} (base {m['base_rate']:.3f})") + + folds = pd.DataFrame(fold_rows) + if len(folds): + print(f"\nWalk-forward medio: AUC={folds.auc.mean():.3f} " + f"acc={folds.acc.mean():.3f} prec@10%={folds.prec_top_decile.mean():.3f}") + + # ---------- modello finale + OOS ---------- + rb_full = RegimeBaseline(cfg["regime"], h).fit(dev_ba) + y_dev = rb_full.label(dev_cr) + final = make_model(cfg, num_cols, cat_cols) + final.fit(select_xy(dev_cr, num_cols, cat_cols), y_dev) + + y_oos = rb_full.label(oos_cr) # baseline fittata sullo sviluppo: no leakage + p_oos = final.predict_proba(select_xy(oos_cr, num_cols, cat_cols))[:, 1] + oos_m = metrics(y_oos, p_oos) + print(f"\nHOLD-OUT OOS {cfg['oos_start_year']}+ : n={oos_m['n']} AUC={oos_m['auc']:.3f} " + f"acc={oos_m['acc']:.3f} prec@10%={oos_m['prec_top_decile']:.3f} " + f"(base {oos_m['base_rate']:.3f}, lift {oos_m['lift_top_decile']:.2f}x)") + + os.makedirs(RESULTS, exist_ok=True) + folds.to_csv(os.path.join(RESULTS, "walkforward_folds.csv"), index=False) + with open(os.path.join(RESULTS, "oos_metrics.json"), "w") as f: + json.dump(oos_m, f, indent=2) + try: + import joblib + joblib.dump(final, os.path.join(RESULTS, "model_final.joblib")) + except Exception as e: + print("modello non serializzato:", e) + print(f"\nsalvati risultati in {RESULTS}") + return folds, oos_m + + +if __name__ == "__main__": + cfg_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "config.yaml") + run(cfg_path)