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>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
data/*.csv
|
||||
results/*.joblib
|
||||
src/__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
# Dati di input (non versionati)
|
||||
|
||||
Copia qui i due CSV prodotti da `PaPP v2/PaPP_CrossExport.mq5`:
|
||||
|
||||
- `PaPP_crosses_<SYMBOL>_D1.csv`
|
||||
- `PaPP_bars_<SYMBOL>_D1.csv`
|
||||
|
||||
Sono gli stessi file usati in `PaPP v2/Analisi/`. Poi lancia `src/train.py`.
|
||||
@@ -0,0 +1,8 @@
|
||||
pandas
|
||||
numpy
|
||||
scikit-learn
|
||||
pyyaml
|
||||
joblib
|
||||
# Fase 4 (export ONNX), opzionali finche' non si esporta:
|
||||
# skl2onnx
|
||||
# onnxruntime
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -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_<SYM>_D1.csv (un record per incrocio)
|
||||
- PaPP_bars_<SYM>_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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user