Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ec58440a | |||
| 7d329a0591 | |||
| 0187cb6bd7 | |||
| 58ed32ff3b | |||
| 375dbf1dc3 | |||
| 4084008642 | |||
| fe10be5ecc | |||
| d441454767 |
@@ -0,0 +1,4 @@
|
||||
data/*.csv
|
||||
results/*.joblib
|
||||
src/__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,111 @@
|
||||
# PaPP v2 — Fase 3: modelli
|
||||
|
||||
> **Modello principale → [`README_mean_reversion.md`](README_mean_reversion.md).**
|
||||
> La validazione esterna (sez. 5b) ha mostrato che l'edge **direzionale** non
|
||||
> generalizza fuori da EURUSD, mentre la **mean-reversion** sì. Il modeling è
|
||||
> quindi orientato sul **rientro alla Mediana**. Questo documento descrive il
|
||||
> primo modello (direzionale), tenuto come riferimento metodologico.
|
||||
|
||||
---
|
||||
|
||||
# Fase 3 (riferimento): classificatore extra-rendimento direzionale
|
||||
|
||||
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
|
||||
python selftest.py # verifiche di correttezza (reproducibilita', anti-leakage, varianti)
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
### 5b. Validazione esterna multi-simbolo (`src/external_validation.py`)
|
||||
Addestrando SOLO su EURUSD e testando su USDJPY/USDCHF/GBPUSD (mai visti):
|
||||
- i pattern direzionali "robusti" di EURUSD **non si replicano**: concordanza di
|
||||
segno solo **38%**; il modello esterno ha **AUC ≈ 0.48–0.55** (caso) contro
|
||||
AUC 0.91 in-sample (memorizzazione) → l'edge direzionale è **specifico di
|
||||
EURUSD**, non generale.
|
||||
- al contrario la **mean-reversion è universale**: ritorno alla Mediana entro 20g
|
||||
nell'**87–90%** dei casi su tutti i simboli, mediana **3 giorni**.
|
||||
|
||||
Conclusione operativa: il bersaglio "su/giù" non regge fuori campione; il segnale
|
||||
generalizzabile e' la **mean-reversion**. Il modeling va orientato lì.
|
||||
|
||||
## 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,123 @@
|
||||
# Fase 3 — Modello di MEAN-REVERSION (rientro alla Mediana)
|
||||
|
||||
> Questo è il **modello principale** della Fase 3. Nasce dopo aver dimostrato
|
||||
> (vedi `README.md` sez. 5b, validazione esterna) che l'edge **direzionale**
|
||||
> "su/giù" **non generalizza** fuori da EURUSD, mentre la **mean-reversion** è
|
||||
> universale (rientro alla Mediana entro 20g nell'87–90% dei casi su tutti i
|
||||
> major FX). Quindi modelliamo ciò che è davvero robusto: il **rientro alla
|
||||
> Mediana**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Idea
|
||||
|
||||
Dopo un incrocio il prezzo tende a tornare sulla Mediana. Non vogliamo prevedere
|
||||
la direzione, ma **caratterizzare il rientro**. Tre target, costruiti da esiti
|
||||
già presenti nei CSV (nessuna baseline di regime: sono osservazioni dirette):
|
||||
|
||||
| target | tipo | colonna | domanda |
|
||||
|---|---|---|---|
|
||||
| `rev` | classificazione | `rev_10` | rientra alla Mediana **entro 10 giorni**? (sì/no) |
|
||||
| `speed` | regressione | `bars_to_revert` | in **quanti giorni** rientra (solo eventi rientrati) |
|
||||
| `disc` | regressione | `disc_max_pct` | **quanto si allontana** (max %) prima di rientrare |
|
||||
|
||||
Esclusione del banale (`drop_trivial`): gli eventi già praticamente sulla
|
||||
Mediana (`|dist_med_pct| < 0.05%`) rientrano per costruzione; vengono esclusi per
|
||||
studiare il rientro "vero".
|
||||
|
||||
## 2. Feature (note al momento dell'incrocio)
|
||||
|
||||
`abs_dist` (=|dist_med_pct|), `dist_med_pct`, `cluster_pct`, `cluster_exp`,
|
||||
`slope_a`, `slope_b`, `vel_med`, `acc_med`, `vol_med` (numeriche) +
|
||||
`pair`, `dir`, `trend`, `dow`, `month` (categoriche). Nessuna colonna di esito
|
||||
futuro è tra le feature.
|
||||
|
||||
## 3. Pooling pulito + validazione (anti-inquinamento)
|
||||
|
||||
I 4 major FX (EURUSD, USDJPY, USDCHF, GBPUSD) hanno mean-reversion omogenea →
|
||||
si possono **mettere in pool**. Le feature sono già in percentuale/relative,
|
||||
quindi confrontabili tra simboli. Due validazioni, entrambe oneste:
|
||||
|
||||
1. **Walk-forward pooled**: train tutti i simboli fino all'anno T, test anno T+1
|
||||
→ generalizzazione **nel tempo**.
|
||||
2. **Leave-one-symbol-out (LOSO)**: train su 3 simboli, test sul 4° **mai visto**
|
||||
→ generalizzazione **tra strumenti** (la prova più severa anti-inquinamento).
|
||||
|
||||
Ogni metrica è confrontata con una **baseline banale**:
|
||||
- classificazione → accuratezza della classe maggioritaria;
|
||||
- regressione → MAE ottenuto prevedendo sempre la mediana del train
|
||||
(`skill_% = 1 − MAE/MAE_baseline`).
|
||||
|
||||
## 4. Risultati (pool 4 major FX, ~39k incroci)
|
||||
|
||||
**Walk-forward (nel tempo):**
|
||||
- `rev` : **AUC ≈ 0.65** (la baseline è 0.5) → il modello sa **ordinare** quali
|
||||
incroci rientreranno entro 10g e quali no.
|
||||
- `speed` : skill ≈ **+2%** → marginale (la mediana è ~3g e cambia poco).
|
||||
- `disc` : skill ≈ **−3%** → non meglio della mediana.
|
||||
|
||||
**Leave-one-symbol-out (su simbolo mai visto):**
|
||||
|
||||
| target | EURUSD | USDJPY | USDCHF | GBPUSD |
|
||||
|---|---|---|---|---|
|
||||
| `rev` AUC | 0.677 | 0.687 | 0.682 | 0.688 |
|
||||
| `speed` skill | +1.3% | +4.8% | −2.9% | +2.8% |
|
||||
| `disc` skill | +2.0% | +5.8% | −1.1% | −0.1% |
|
||||
|
||||
**Lettura:** il classificatore di rientro (`rev`) ha un edge **reale e
|
||||
generalizzabile** — AUC ~0.68 anche su un simbolo completamente fuori dal
|
||||
training. I due regressori (`speed`, `disc`) aggiungono poco: la velocità e
|
||||
l'ampiezza dell'allontanamento sono in pratica vicine alla mediana storica.
|
||||
|
||||
## 5. Cosa guida il rientro (importanza, permutation su holdout 2018+)
|
||||
|
||||
```
|
||||
abs_dist +0.104 <- distanza dalla Mediana: di gran lunga il fattore #1
|
||||
dist_med_pct +0.025 <- il segno della distanza (sopra/sotto)
|
||||
trend +0.021
|
||||
cluster_exp +0.017 <- cluster in espansione/contrazione
|
||||
vel_med +0.010
|
||||
... (vol_med, cluster_pct ~0)
|
||||
pair ~0 <- QUALI linee si incrociano e' irrilevante
|
||||
```
|
||||
|
||||
Conferma economica: il rientro dipende da **quanto sei lontano dalla Mediana** e
|
||||
dal **regime**, non dallo specifico tipo di incrocio. È coerente con la Fase 2 e
|
||||
con la validazione esterna (il "pattern" di linee era rumore).
|
||||
|
||||
## 6. Uso operativo
|
||||
|
||||
Il modello `rev` non dice "compra/vendi": stima la **probabilità di rientro
|
||||
rapido**. Serve da **filtro**: tra i setup di mean-reversion (prezzo lontano
|
||||
dalla Mediana), privilegiare quelli con alta probabilità ed evitare il ~25% che
|
||||
tende a NON rientrare in fretta (i casi rischiosi, spesso inizio di trend).
|
||||
|
||||
## 7. File e come eseguire
|
||||
|
||||
```
|
||||
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
|
||||
└── results/
|
||||
├── mr_wf_<task>.csv metriche walk-forward per anno
|
||||
├── mr_loso_<task>.csv metriche leave-one-symbol-out
|
||||
├── mr_summary.json riepilogo medio
|
||||
└── mr_model_<task>.joblib modelli finali (non versionati)
|
||||
```
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## 8. 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.
|
||||
@@ -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,44 @@
|
||||
# Configurazione Fase 3 — modello di MEAN-REVERSION (rientro alla Mediana)
|
||||
|
||||
# pooling pulito sui major FX (dinamica di mean-reversion omogenea)
|
||||
symbols: [EURUSD, USDJPY, USDCHF, GBPUSD]
|
||||
year_min: 1999
|
||||
|
||||
horizon: 10 # orizzonte per il target di classificazione rev_N
|
||||
drop_trivial: true # esclude gli eventi gia' praticamente sulla Mediana
|
||||
trivial_eps: 0.05 # soglia |dist_med_pct| (%) sotto cui l'evento e' "banale"
|
||||
|
||||
# feature note al momento dell'incrocio
|
||||
features_numeric:
|
||||
- abs_dist # |dist_med_pct| (derivata): quanto e' lontano dalla Mediana
|
||||
- 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
|
||||
walkforward:
|
||||
min_train_years: 8
|
||||
step_years: 1
|
||||
|
||||
model:
|
||||
kind: hist_gbdt
|
||||
hist_gbdt:
|
||||
max_depth: 4
|
||||
learning_rate: 0.05
|
||||
max_iter: 400
|
||||
l2_regularization: 1.0
|
||||
early_stopping: true
|
||||
|
||||
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,5 @@
|
||||
symbol,n,base_rate,auc,acc,brier,prec_top_decile,lift_top_decile
|
||||
EURUSD (in-sample),16895,0.4976028410772418,0.913591163259108,0.8320213080793134,0.15547236980731827,0.9875666074600356,1.9846482494394315
|
||||
USDJPY,7084,0.5172219085262564,0.4800203976097449,0.49110671936758893,0.28183992572414085,0.4646892655367232,0.8984330668837737
|
||||
USDCHF,7967,0.5049579515501443,0.47560023778083327,0.48449855654575125,0.2879368779869159,0.4296482412060301,0.8508594426270052
|
||||
GBPUSD,7239,0.5020030390937975,0.5483399577270303,0.5322558364414974,0.2593864013259506,0.5712309820193637,1.137903433912541
|
||||
|
@@ -0,0 +1,5 @@
|
||||
n,mae,mae_baseline,skill_%,r2,test_symbol
|
||||
15898,0.7016688523942158,0.7156681469367216,1.956115359112609,0.056705406202969044,EURUSD
|
||||
6635,0.766700972158277,0.8138184204973624,5.789675823544238,0.09699286957940556,USDJPY
|
||||
7349,0.7110993410001212,0.703173893046673,-1.1270964453912402,0.13820883251935367,USDCHF
|
||||
6807,0.7712259995571173,0.7706658777728809,-0.07268023671362922,0.10481685322904755,GBPUSD
|
||||
|
@@ -0,0 +1,5 @@
|
||||
n,base_rate,acc_baseline,auc,acc,test_symbol
|
||||
15887,0.7324227355699628,0.7324227355699628,0.6765454819075187,0.7357587965002832,EURUSD
|
||||
6619,0.7028252001812962,0.7028252001812962,0.6869979773747487,0.719292944553558,USDJPY
|
||||
7331,0.7603328331741918,0.7603328331741918,0.6816590320250597,0.7442368026190151,USDCHF
|
||||
6798,0.7374227714033539,0.7374227714033539,0.687844098341511,0.7375698734922036,GBPUSD
|
||||
|
@@ -0,0 +1,5 @@
|
||||
n,mae,mae_baseline,skill_%,r2,test_symbol
|
||||
13909,3.610173858972498,3.65878208354303,1.3285356564187945,0.09908303164055288,EURUSD
|
||||
5729,3.615529034826519,3.7987432361668705,4.823021456044085,0.1281263482188003,USDJPY
|
||||
6570,3.627862458240022,3.5245053272450533,-2.9325287210094197,0.07345132592032444,USDCHF
|
||||
6090,3.638542831980693,3.744006568144499,2.8168683533072225,0.11825074333860008,GBPUSD
|
||||
|
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"wf": {
|
||||
"rev": {
|
||||
"n": 1594.0,
|
||||
"base_rate": 0.7183856164543461,
|
||||
"acc_baseline": 0.7183856164543461,
|
||||
"auc": 0.6478491068895667,
|
||||
"acc": 0.7130101340748866,
|
||||
"test_year": 2016.5
|
||||
},
|
||||
"speed": {
|
||||
"n": 1406.55,
|
||||
"mae": 3.6537111122909542,
|
||||
"mae_baseline": 3.7337467740160712,
|
||||
"skill_%": 1.7998203587207346,
|
||||
"r2": 0.07833366023387726,
|
||||
"test_year": 2016.5
|
||||
},
|
||||
"disc": {
|
||||
"n": 1596.7,
|
||||
"mae": 0.7847562209218487,
|
||||
"mae_baseline": 0.7657217825432683,
|
||||
"skill_%": -3.381481774117468,
|
||||
"r2": -0.36203395606382444,
|
||||
"test_year": 2016.5
|
||||
}
|
||||
},
|
||||
"loso": {
|
||||
"rev": {
|
||||
"n": 9158.75,
|
||||
"base_rate": 0.7332508850822013,
|
||||
"acc_baseline": 0.7332508850822013,
|
||||
"auc": 0.6832616474122095,
|
||||
"acc": 0.734214604291265
|
||||
},
|
||||
"speed": {
|
||||
"n": 8074.5,
|
||||
"mae": 3.623027046004933,
|
||||
"mae_baseline": 3.681509303774863,
|
||||
"skill_%": 1.5089741861901707,
|
||||
"r2": 0.10472786227956943
|
||||
},
|
||||
"disc": {
|
||||
"n": 9172.25,
|
||||
"mae": 0.7376737912774329,
|
||||
"mae_baseline": 0.7508315845634095,
|
||||
"skill_%": 1.6365036251379943,
|
||||
"r2": 0.09918099038269396
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
n,mae,mae_baseline,skill_%,r2,test_year
|
||||
468,0.5210450337997958,0.4877705555555555,-6.82174802584008,-0.440278374819538,2007
|
||||
511,1.4979005200832076,1.46627927592955,-2.1565635327970734,-0.4428980601648529,2008
|
||||
590,0.5372706093503566,0.624245779661017,13.932840740691965,0.09814855360679986,2009
|
||||
548,0.8435515547481117,0.9210056204379563,8.40972779873087,-0.20129888622520697,2010
|
||||
649,0.6264895295465607,0.7349227426810478,14.754368974746313,-0.23270335993701874,2011
|
||||
563,0.8444189035765441,0.7120972291296623,-18.581967326092208,-0.6572098948790617,2012
|
||||
574,0.6570009471217217,0.5640415679442509,-16.480944749564785,-1.9530900867332583,2013
|
||||
1089,0.8585576108637076,0.8849660055096419,2.984114020371431,-0.0058690268732339845,2014
|
||||
2465,0.86287949087831,0.8346033630831645,-3.387971945223023,0.1741985535476418,2015
|
||||
2440,0.9893521953097881,0.9588108852459016,-3.1853320121677298,0.06164644338816894,2016
|
||||
2405,0.6483027913003414,0.606651659043659,-6.865741094707012,-0.3827607195275795,2017
|
||||
2208,0.716304832228868,0.6826646195652174,-4.927780303756735,-0.30219708772441267,2018
|
||||
2520,0.7276522650483845,0.693340365079365,-4.948781536048585,-0.9380918084727767,2019
|
||||
2351,1.0262956818683626,0.9582834198213527,-7.097301345325269,-0.054525480586665065,2020
|
||||
2378,0.5921425747388034,0.566631362489487,-4.502259129680586,-0.6404632411590949,2021
|
||||
2074,0.9417510265311481,1.0620946142719383,11.330778456426438,-0.41893129794772066,2022
|
||||
2185,0.6089553673717031,0.5467215652173913,-11.383088964044408,-0.23632995343602725,2023
|
||||
2317,0.7212702214350022,0.6373334527406128,-13.169992620574188,-0.24062608198432445,2024
|
||||
2335,0.7021795509288201,0.7111189250535332,1.2570856729822166,0.1140083899835217,2025
|
||||
1264,0.7718037117074369,0.6608526424050633,-16.78907856047691,-0.5414077013318492,2026
|
||||
|
@@ -0,0 +1,21 @@
|
||||
n,base_rate,acc_baseline,auc,acc,test_year
|
||||
468,0.6175213675213675,0.6175213675213675,0.641878177495119,0.6260683760683761,2007
|
||||
511,0.6516634050880626,0.6516634050880626,0.5756149407834801,0.6594911937377691,2008
|
||||
590,0.7440677966101695,0.7440677966101695,0.5982138816394877,0.7372881355932204,2009
|
||||
548,0.7408759124087592,0.7408759124087592,0.6361617983764658,0.7299270072992701,2010
|
||||
649,0.5855161787365177,0.5855161787365177,0.6710135002934846,0.6718027734976888,2011
|
||||
563,0.8206039076376554,0.8206039076376554,0.6997342591401998,0.7708703374777975,2012
|
||||
574,0.6933797909407665,0.6933797909407665,0.6654865235267245,0.6428571428571429,2013
|
||||
1089,0.6464646464646465,0.6464646464646465,0.6203659976387249,0.6721763085399449,2014
|
||||
2465,0.7829614604462475,0.7829614604462475,0.6354597840298292,0.7517241379310344,2015
|
||||
2440,0.7487704918032787,0.7487704918032787,0.6421575586789066,0.6905737704918032,2016
|
||||
2405,0.7600831600831601,0.7600831600831601,0.6408060252797805,0.755925155925156,2017
|
||||
2208,0.7336956521739131,0.7336956521739131,0.6192271352985639,0.7133152173913043,2018
|
||||
2520,0.8067460317460318,0.8067460317460318,0.6465879719737271,0.7746031746031746,2019
|
||||
2351,0.7243726074011059,0.7243726074011059,0.6688414780017834,0.7209698000850702,2020
|
||||
2378,0.7863751051303617,0.7863751051303617,0.6841361741547013,0.7901597981497056,2021
|
||||
2074,0.6769527483124397,0.6769527483124397,0.5842326827401454,0.6557377049180327,2022
|
||||
2185,0.6668192219679634,0.6668192219679634,0.6764596076538425,0.6906178489702517,2023
|
||||
2317,0.6551575312904618,0.6551575312904618,0.6896672553471812,0.6870953819594303,2024
|
||||
2335,0.7430406852248393,0.7430406852248393,0.7314169068203651,0.7644539614561028,2025
|
||||
1210,0.7826446280991736,0.7826446280991736,0.6295204789188191,0.7545454545454545,2026
|
||||
|
@@ -0,0 +1,21 @@
|
||||
n,mae,mae_baseline,skill_%,r2,test_year
|
||||
338,3.465379140810698,3.4142011834319526,-1.498973101734502,0.12927353007997744,2007
|
||||
400,4.090858376700547,3.9425,-3.7630533088280904,0.04579965148367826,2008
|
||||
540,4.0205317754813965,3.8740740740740742,-3.780456919691866,-0.12200728544542727,2009
|
||||
470,3.1693714434220257,3.421276595744681,7.362899352714425,0.2246464741787182,2010
|
||||
495,4.314538334266083,4.3232323232323235,0.20109927749014656,-0.055325713812715005,2011
|
||||
515,3.2237999945625653,2.9029126213592233,-11.053979745800735,-0.06562655938335582,2012
|
||||
485,3.733393114617742,4.072164948453608,8.319207058754174,0.19434702822584105,2013
|
||||
879,3.924266870235131,4.180887372013652,6.137943430294424,0.022748161878883844,2014
|
||||
2243,3.398709049328624,3.3410610789121713,-1.7254389864438613,0.07268045675088919,2015
|
||||
2243,3.6650255510608725,3.7588051716451183,2.494931668490852,0.06864404910302002,2016
|
||||
2206,3.65145803728882,3.8028105167724386,3.9800163278205125,0.10526816265720629,2017
|
||||
1923,3.577364530993464,3.4607384295371815,-3.3699773568810043,0.07894792932730887,2018
|
||||
2354,3.356457496216363,3.373406966864911,0.502443696092203,0.015425103761181336,2019
|
||||
2116,3.8333614130177756,3.972117202268431,3.4932450928540915,0.049572458729656765,2020
|
||||
2190,3.3981138450501156,3.4990867579908675,2.8856933229837733,0.12873115565641913,2021
|
||||
1715,3.799187552337051,3.816909620991254,0.4643041207142984,0.0433313417575899,2022
|
||||
1810,4.043300720188006,3.8784530386740332,-4.250346204277644,0.06762624567274822,2023
|
||||
1997,3.6962952238115996,4.476214321482224,17.423631704309617,0.2427214165484335,2024
|
||||
2094,3.369937232661497,3.7597898758357213,10.369000823152874,0.2265055214709174,2025
|
||||
1118,3.3428725437687103,3.4042933810375673,1.804216922401003,0.09336407603657426,2026
|
||||
|
@@ -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,116 @@
|
||||
"""Validazione esterna: addestra SOLO su EURUSD, testa su altri simboli mai visti.
|
||||
|
||||
Due livelli:
|
||||
A) Pattern: gli incroci robusti di EURUSD (extra-rendimento a 10g) hanno lo
|
||||
stesso segno/forza sugli altri simboli? (replica del pattern)
|
||||
B) Modello: il classificatore addestrato su EURUSD generalizza? AUC e
|
||||
precisione sul decile piu' forte su ogni simbolo esterno.
|
||||
|
||||
Nessun dato dei simboli di test entra nell'addestramento (zero inquinamento).
|
||||
La baseline di ciascun simbolo (definizione del target) e' calcolata sui dati
|
||||
di quel simbolo: serve solo a definire la ground-truth, non i pesi del modello.
|
||||
|
||||
Uso:
|
||||
python external_validation.py [config.yaml] SYM_TRAIN SYM_TEST1 SYM_TEST2 ...
|
||||
(default: train=EURUSD, test=USDJPY USDCHF GBPUSD)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os, sys
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import train as T
|
||||
from data import load
|
||||
from labeling import RegimeBaseline
|
||||
from features import select_xy
|
||||
from model import make_model
|
||||
from evaluate import metrics
|
||||
|
||||
RESULTS = os.path.normpath(os.path.join(HERE, "..", "results"))
|
||||
ROBUST = ["MA365xMA7", "MA365xMA182", "MA365xMA121", "MA121xMA7",
|
||||
"MA121xMA3", "PRICExMA121", "MA182xMA30", "MA182xMA121"]
|
||||
|
||||
|
||||
def load_sym(sym, cfg):
|
||||
cr, ba = load(sym, cfg["year_min"])
|
||||
cr = cr.dropna(subset=[f"cret_{cfg['horizon']}"]).reset_index(drop=True)
|
||||
return cr, ba
|
||||
|
||||
|
||||
def main():
|
||||
cfg_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "config.yaml")
|
||||
cfg = T.load_cfg(cfg_path)
|
||||
args = sys.argv[2:]
|
||||
sym_tr = args[0] if args else "EURUSD"
|
||||
sym_te = args[1:] if len(args) > 1 else ["USDJPY", "USDCHF", "GBPUSD"]
|
||||
num, cat, h = cfg["features_numeric"], cfg["features_categorical"], cfg["horizon"]
|
||||
|
||||
cr_tr, ba_tr = load_sym(sym_tr, cfg)
|
||||
|
||||
# ---------- A) replica dei pattern ----------
|
||||
def pair_excess(cr, ba):
|
||||
rb = RegimeBaseline(cfg["regime"], h).fit(ba)
|
||||
ex = rb.excess(cr)
|
||||
out = {}
|
||||
for (p, d), idx in cr.groupby(["pair", "dir"]).groups.items():
|
||||
out[(p, int(d))] = ex[cr.index.get_indexer(idx)].mean()
|
||||
return out
|
||||
|
||||
base_ex = pair_excess(cr_tr, ba_tr)
|
||||
print(f"=== A) Replica pattern robusti: extra-rendimento {h}g (%), segno vs {sym_tr} ===")
|
||||
print(f"{'pattern':16s} {sym_tr:>9s}", end="")
|
||||
sym_ex = {}
|
||||
for s in sym_te:
|
||||
c, b = load_sym(s, cfg)
|
||||
sym_ex[s] = pair_excess(c, b)
|
||||
print(f" {s:>9s}", end="")
|
||||
print(" concordi")
|
||||
agree_counts = []
|
||||
for p in ROBUST:
|
||||
for d in (1, -1):
|
||||
key = (p, d)
|
||||
if key not in base_ex:
|
||||
continue
|
||||
row = f"{p+('+' if d>0 else '-'):16s} {base_ex[key]*1:+8.3f}"
|
||||
signs = []
|
||||
for s in sym_te:
|
||||
v = sym_ex[s].get(key, np.nan)
|
||||
row += f" {v:+8.3f}" if not np.isnan(v) else f" {'n/a':>8s}"
|
||||
if not np.isnan(v):
|
||||
signs.append(np.sign(v) == np.sign(base_ex[key]))
|
||||
conc = f"{sum(signs)}/{len(signs)}" if signs else "-"
|
||||
agree_counts.append((sum(signs), len(signs)))
|
||||
print(row + f" {conc}")
|
||||
tot_a = sum(a for a, _ in agree_counts); tot_n = sum(n for _, n in agree_counts)
|
||||
print(f"\nConcordanza di segno totale: {tot_a}/{tot_n} ({tot_a/max(tot_n,1)*100:.0f}%)")
|
||||
|
||||
# ---------- B) modello EURUSD -> simboli esterni ----------
|
||||
print(f"\n=== B) Modello addestrato su {sym_tr}, testato esternamente ===")
|
||||
rb_tr = RegimeBaseline(cfg["regime"], h).fit(ba_tr)
|
||||
y_tr = rb_tr.label(cr_tr)
|
||||
mdl = make_model(cfg, num, cat)
|
||||
mdl.fit(select_xy(cr_tr, num, cat), y_tr)
|
||||
|
||||
rows = []
|
||||
# riferimento in-sample (stesso simbolo, solo per confronto)
|
||||
p_in = mdl.predict_proba(select_xy(cr_tr, num, cat))[:, 1]
|
||||
rows.append({"symbol": sym_tr + " (in-sample)", **metrics(y_tr, p_in)})
|
||||
for s in sym_te:
|
||||
c, b = load_sym(s, cfg)
|
||||
rb = RegimeBaseline(cfg["regime"], h).fit(b)
|
||||
y = rb.label(c)
|
||||
p = mdl.predict_proba(select_xy(c, num, cat))[:, 1]
|
||||
rows.append({"symbol": s, **metrics(y, p)})
|
||||
res = pd.DataFrame(rows)
|
||||
for _, r in res.iterrows():
|
||||
print(f" {r['symbol']:22s} n={int(r['n']):5d} AUC={r['auc']:.3f} "
|
||||
f"acc={r['acc']:.3f} prec@10%={r['prec_top_decile']:.3f} "
|
||||
f"(base {r['base_rate']:.3f}, lift {r['lift_top_decile']:.2f}x)")
|
||||
os.makedirs(RESULTS, exist_ok=True)
|
||||
res.to_csv(os.path.join(RESULTS, "external_validation.csv"), index=False)
|
||||
print(f"\nsalvato: {os.path.join(RESULTS,'external_validation.csv')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,47 @@
|
||||
"""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,
|
||||
HistGradientBoostingRegressor)
|
||||
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)])
|
||||
|
||||
|
||||
def make_regressor(cfg: dict, num_cols: list[str], cat_cols: list[str]) -> Pipeline:
|
||||
"""Regressore HistGradientBoosting per i target continui di mean-reversion."""
|
||||
pre = build_preprocessor(num_cols, cat_cols)
|
||||
p = cfg["model"]["hist_gbdt"]
|
||||
reg = HistGradientBoostingRegressor(
|
||||
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"],
|
||||
)
|
||||
return Pipeline([("pre", pre), ("reg", reg)])
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Target di mean-reversion (rientro alla Mediana dopo un incrocio).
|
||||
|
||||
A differenza del target direzionale, questi esiti sono diretti e NON richiedono
|
||||
una baseline di regime: sono proprieta' osservate dell'evento.
|
||||
|
||||
Tre target (vedi README sez. Mean-reversion):
|
||||
- REV_N (classificazione): rientra alla Mediana entro N giorni? (rev_N)
|
||||
- SPEED (regressione) : in quanti giorni rientra (bars_to_revert);
|
||||
definito solo per gli eventi che rientrano entro 20g
|
||||
- DISC (regressione) : massimo allontanamento dalla Mediana prima del
|
||||
rientro, in % (disc_max_pct)
|
||||
|
||||
Nota sulla "banalita'": l'incrocio PRICExMED ha per costruzione dist~0 e rientro
|
||||
immediato. Con `drop_trivial` si possono escludere gli eventi gia' praticamente
|
||||
sulla Mediana (|dist_med_pct| < trivial_eps) per studiare il rientro "vero".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def make_targets(df: pd.DataFrame, horizon: int = 10,
|
||||
drop_trivial: bool = False, trivial_eps: float = 0.05):
|
||||
"""Ritorna un dict di colonne target + una maschera 'valid' per ciascun task.
|
||||
|
||||
Output:
|
||||
{
|
||||
"rev": (y, mask), # classificazione 0/1
|
||||
"speed": (y, mask), # regressione (giorni), solo eventi rientrati
|
||||
"disc": (y, mask), # regressione (% allontanamento)
|
||||
}
|
||||
"""
|
||||
n = len(df)
|
||||
base_mask = np.ones(n, bool)
|
||||
if drop_trivial:
|
||||
base_mask &= df["dist_med_pct"].abs().values >= trivial_eps
|
||||
|
||||
out = {}
|
||||
|
||||
# REV_N: classificazione
|
||||
yrev = df[f"rev_{horizon}"].astype(float).values
|
||||
mrev = base_mask & ~np.isnan(yrev)
|
||||
out["rev"] = (np.nan_to_num(yrev).astype(int), mrev)
|
||||
|
||||
# SPEED: giorni al rientro, solo per chi rientra entro 20g (bars_to_revert valorizzato)
|
||||
btr = pd.to_numeric(df["bars_to_revert"], errors="coerce").values
|
||||
mspeed = base_mask & ~np.isnan(btr)
|
||||
out["speed"] = (btr, mspeed)
|
||||
|
||||
# DISC: massimo allontanamento (in valore assoluto, %)
|
||||
disc = pd.to_numeric(df["disc_max_pct"], errors="coerce").abs().values
|
||||
mdisc = base_mask & ~np.isnan(disc)
|
||||
out["disc"] = (disc, mdisc)
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Self-test del modello di mean-reversion.
|
||||
|
||||
M1 feature senza esiti futuri
|
||||
M2 reproducibilita' (LOSO rev AUC identico tra due run)
|
||||
M3 controllo negativo: con label rev mescolate l'AUC deve ~0.5
|
||||
"""
|
||||
import os, sys
|
||||
import numpy as np
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
from mr_train import load_cfg, load_pool, leave_one_symbol_out
|
||||
from mr_labeling import make_targets
|
||||
from features import select_xy
|
||||
from model import make_model
|
||||
from sklearn.metrics import roc_auc_score
|
||||
|
||||
CFG = os.path.join(HERE, "..", "config_mr.yaml")
|
||||
ok = True
|
||||
|
||||
def check(name, cond):
|
||||
global ok
|
||||
print(("PASS" if cond else "FAIL"), "-", name); ok = ok and cond
|
||||
|
||||
cfg = load_cfg(CFG)
|
||||
num, cat = cfg["features_numeric"], cfg["features_categorical"]
|
||||
|
||||
# M1
|
||||
future = ("ret_", "mfe_", "mae_", "dir_", "rev_", "cret_", "bars_to_revert", "disc_max")
|
||||
feats = num + cat
|
||||
check("M1 feature senza esiti futuri",
|
||||
not any(f.startswith(future) for f in feats))
|
||||
|
||||
df = load_pool(cfg)
|
||||
|
||||
# M2 reproducibilita'
|
||||
a = leave_one_symbol_out(cfg, df, num, cat)["rev"].auc.mean()
|
||||
b = leave_one_symbol_out(cfg, df, num, cat)["rev"].auc.mean()
|
||||
check("M2 reproducibilita' LOSO rev AUC", abs(a - b) < 1e-9)
|
||||
|
||||
# M3 controllo negativo
|
||||
y, mask = make_targets(df, cfg["horizon"], cfg["drop_trivial"], cfg["trivial_eps"])["rev"]
|
||||
d = df[mask].reset_index(drop=True); y = y[mask]
|
||||
tr = d.time.dt.year.values < 2018
|
||||
m = make_model(cfg, num, cat); m.fit(select_xy(d[tr], num, cat), y[tr])
|
||||
p = m.predict_proba(select_xy(d[~tr], num, cat))[:, 1]
|
||||
yte = y[~tr].copy(); np.random.default_rng(0).shuffle(yte)
|
||||
auc_shuf = roc_auc_score(yte, p)
|
||||
check("M3 controllo negativo AUC~0.5 (0.45-0.55)", 0.45 <= auc_shuf <= 0.55)
|
||||
|
||||
print(f"\nLOSO rev AUC medio reale = {a:.3f}")
|
||||
print("=== SELF-TEST MR:", "TUTTO OK" if ok else "CI SONO FAIL", "===")
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Fase 3 — modello di MEAN-REVERSION (rientro alla Mediana).
|
||||
|
||||
Pooling pulito sui major FX. Tre target (vedi mr_labeling):
|
||||
- rev : classificatore "rientra entro `horizon` giorni si/no"
|
||||
- speed : regressione su quanti giorni impiega a rientrare (eventi rientrati)
|
||||
- disc : regressione sul massimo allontanamento (% ) prima del rientro
|
||||
|
||||
Due schemi di validazione, entrambi onesti:
|
||||
1) WALK-FORWARD pooled: train tutti i simboli fino all'anno T, test anno T+1.
|
||||
(generalizzazione nel tempo)
|
||||
2) LEAVE-ONE-SYMBOL-OUT: train su 3 simboli, test sul 4o mai visto.
|
||||
(generalizzazione tra strumenti — la prova piu' severa)
|
||||
|
||||
Confronto sempre contro una baseline banale:
|
||||
- clf : prevedere sempre la classe maggioritaria / la base rate
|
||||
- reg : prevedere la mediana del train (MAE di riferimento)
|
||||
|
||||
Uso:
|
||||
python mr_train.py [config_mr.yaml]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os, sys, json
|
||||
import numpy as np, pandas as pd
|
||||
import yaml
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
from data import load
|
||||
from features import select_xy
|
||||
from model import make_model, make_regressor
|
||||
from mr_labeling import make_targets
|
||||
from sklearn.metrics import roc_auc_score, accuracy_score, mean_absolute_error, r2_score
|
||||
|
||||
RESULTS = os.path.normpath(os.path.join(HERE, "..", "results"))
|
||||
|
||||
|
||||
def load_cfg(path):
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def load_pool(cfg):
|
||||
parts = []
|
||||
for s in cfg["symbols"]:
|
||||
cr, _ = load(s, cfg["year_min"])
|
||||
cr = cr.copy()
|
||||
cr["abs_dist"] = cr["dist_med_pct"].abs()
|
||||
parts.append(cr)
|
||||
df = pd.concat(parts, ignore_index=True).sort_values("time").reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
# ---------------- metriche ----------------
|
||||
def clf_metrics(ytr, yte, ptr, pte):
|
||||
base = max(yte.mean(), 1 - yte.mean()) # accuratezza della classe maggioritaria
|
||||
out = {"n": int(len(yte)), "base_rate": float(yte.mean()),
|
||||
"acc_baseline": float(base)}
|
||||
if len(np.unique(yte)) < 2:
|
||||
out.update(auc=np.nan, acc=np.nan); return out
|
||||
out["auc"] = float(roc_auc_score(yte, pte))
|
||||
out["acc"] = float(accuracy_score(yte, (pte >= 0.5).astype(int)))
|
||||
return out
|
||||
|
||||
|
||||
def reg_metrics(ytr, yte, pred):
|
||||
base_pred = np.median(ytr)
|
||||
mae_base = mean_absolute_error(yte, np.full_like(yte, base_pred, dtype=float))
|
||||
mae = mean_absolute_error(yte, pred)
|
||||
return {"n": int(len(yte)), "mae": float(mae), "mae_baseline": float(mae_base),
|
||||
"skill_%": float((1 - mae / mae_base) * 100) if mae_base > 0 else np.nan,
|
||||
"r2": float(r2_score(yte, pred)) if len(np.unique(yte)) > 1 else np.nan}
|
||||
|
||||
|
||||
def fit_predict(cfg, task, tr, te, num, cat):
|
||||
"""Addestra il modello giusto per il task e ritorna (ytr,yte,pred_or_proba)."""
|
||||
horizon = cfg["horizon"]
|
||||
t_tr = make_targets(tr, horizon, cfg["drop_trivial"], cfg["trivial_eps"])[task]
|
||||
t_te = make_targets(te, horizon, cfg["drop_trivial"], cfg["trivial_eps"])[task]
|
||||
ytr, mtr = t_tr; yte, mte = t_te
|
||||
Xtr, Xte = tr[mtr], te[mte]
|
||||
ytr, yte = ytr[mtr], yte[mte]
|
||||
if len(Xtr) < 50 or len(Xte) < 20:
|
||||
return None
|
||||
if task == "rev":
|
||||
m = make_model(cfg, num, cat)
|
||||
m.fit(select_xy(Xtr, num, cat), ytr)
|
||||
return ("clf", ytr, yte,
|
||||
m.predict_proba(select_xy(Xtr, num, cat))[:, 1],
|
||||
m.predict_proba(select_xy(Xte, num, cat))[:, 1])
|
||||
else:
|
||||
m = make_regressor(cfg, num, cat)
|
||||
m.fit(select_xy(Xtr, num, cat), ytr)
|
||||
return ("reg", ytr, yte, None, m.predict(select_xy(Xte, num, cat)))
|
||||
|
||||
|
||||
def walk_forward(cfg, df, num, cat):
|
||||
years = sorted(df.time.dt.year.unique())
|
||||
t = years[0] + cfg["walkforward"]["min_train_years"] - 1
|
||||
rows = {k: [] for k in ("rev", "speed", "disc")}
|
||||
while t < years[-1]:
|
||||
tr = df[df.time.dt.year <= t]
|
||||
te = df[df.time.dt.year == t + 1]
|
||||
if len(te):
|
||||
for task in rows:
|
||||
r = fit_predict(cfg, task, tr, te, num, cat)
|
||||
if r is None:
|
||||
continue
|
||||
kind, ytr, yte, ptr, pte = r
|
||||
m = (clf_metrics(ytr, yte, ptr, pte) if kind == "clf"
|
||||
else reg_metrics(ytr, yte, pte))
|
||||
m["test_year"] = t + 1
|
||||
rows[task].append(m)
|
||||
t += cfg["walkforward"]["step_years"]
|
||||
return {k: pd.DataFrame(v) for k, v in rows.items()}
|
||||
|
||||
|
||||
def leave_one_symbol_out(cfg, df, num, cat):
|
||||
rows = {k: [] for k in ("rev", "speed", "disc")}
|
||||
for s in cfg["symbols"]:
|
||||
tr = df[df.symbol != s]
|
||||
te = df[df.symbol == s]
|
||||
for task in rows:
|
||||
r = fit_predict(cfg, task, tr, te, num, cat)
|
||||
if r is None:
|
||||
continue
|
||||
kind, ytr, yte, ptr, pte = r
|
||||
m = (clf_metrics(ytr, yte, ptr, pte) if kind == "clf"
|
||||
else reg_metrics(ytr, yte, pte))
|
||||
m["test_symbol"] = s
|
||||
rows[task].append(m)
|
||||
return {k: pd.DataFrame(v) for k, v in rows.items()}
|
||||
|
||||
|
||||
def run(cfg_path):
|
||||
cfg = load_cfg(cfg_path)
|
||||
np.random.seed(cfg["seed"])
|
||||
num, cat = cfg["features_numeric"], cfg["features_categorical"]
|
||||
df = load_pool(cfg)
|
||||
print(f"pool: {len(df)} incroci su {cfg['symbols']} "
|
||||
f"(drop_trivial={cfg['drop_trivial']})\n")
|
||||
|
||||
print("=== 1) WALK-FORWARD pooled (generalizzazione nel tempo) ===")
|
||||
wf = walk_forward(cfg, df, num, cat)
|
||||
print(f"[rev] AUC medio={wf['rev'].auc.mean():.3f} acc={wf['rev'].acc.mean():.3f}"
|
||||
f" (acc baseline maggioritaria={wf['rev'].acc_baseline.mean():.3f})")
|
||||
print(f"[speed] MAE={wf['speed'].mae.mean():.2f}g baseline={wf['speed'].mae_baseline.mean():.2f}g"
|
||||
f" skill={wf['speed']['skill_%'].mean():+.1f}%")
|
||||
print(f"[disc] MAE={wf['disc'].mae.mean():.3f}% baseline={wf['disc'].mae_baseline.mean():.3f}%"
|
||||
f" skill={wf['disc']['skill_%'].mean():+.1f}%")
|
||||
|
||||
print("\n=== 2) LEAVE-ONE-SYMBOL-OUT (generalizzazione tra strumenti) ===")
|
||||
loso = leave_one_symbol_out(cfg, df, num, cat)
|
||||
for _, r in loso["rev"].iterrows():
|
||||
print(f"[rev] test {r['test_symbol']:7s} AUC={r['auc']:.3f} acc={r['acc']:.3f}"
|
||||
f" (base {r['base_rate']:.3f})")
|
||||
for _, r in loso["speed"].iterrows():
|
||||
print(f"[speed] test {r['test_symbol']:7s} MAE={r['mae']:.2f}g base={r['mae_baseline']:.2f}g"
|
||||
f" skill={r['skill_%']:+.1f}%")
|
||||
for _, r in loso["disc"].iterrows():
|
||||
print(f"[disc] test {r['test_symbol']:7s} MAE={r['mae']:.3f}% base={r['mae_baseline']:.3f}%"
|
||||
f" skill={r['skill_%']:+.1f}%")
|
||||
|
||||
# ---- modelli finali su tutto il pool ----
|
||||
os.makedirs(RESULTS, exist_ok=True)
|
||||
saved = {}
|
||||
for task in ("rev", "speed", "disc"):
|
||||
y, mask = make_targets(df, cfg["horizon"], cfg["drop_trivial"], cfg["trivial_eps"])[task]
|
||||
X = select_xy(df[mask], num, cat)
|
||||
mdl = make_model(cfg, num, cat) if task == "rev" else make_regressor(cfg, num, cat)
|
||||
mdl.fit(X, y[mask])
|
||||
try:
|
||||
import joblib
|
||||
path = os.path.join(RESULTS, f"mr_model_{task}.joblib")
|
||||
joblib.dump(mdl, path); saved[task] = path
|
||||
except Exception as e:
|
||||
print("non serializzato", task, e)
|
||||
|
||||
for task in ("rev", "speed", "disc"):
|
||||
wf[task].to_csv(os.path.join(RESULTS, f"mr_wf_{task}.csv"), index=False)
|
||||
loso[task].to_csv(os.path.join(RESULTS, f"mr_loso_{task}.csv"), index=False)
|
||||
with open(os.path.join(RESULTS, "mr_summary.json"), "w") as f:
|
||||
json.dump({
|
||||
"wf": {k: v.mean(numeric_only=True).to_dict() for k, v in wf.items()},
|
||||
"loso": {k: v.mean(numeric_only=True).to_dict() for k, v in loso.items()},
|
||||
}, f, indent=2)
|
||||
print(f"\nsalvati risultati e modelli in {RESULTS}")
|
||||
return wf, loso
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cfg_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "config_mr.yaml")
|
||||
run(cfg_path)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Self-test della pipeline Fase 3 (controlli di correttezza, non un training serio).
|
||||
|
||||
Esegue:
|
||||
T1 reproducibilita': due run identiche danno stesse metriche OOS
|
||||
T2 controllo negativo anti-leakage: con label OOS mescolate l'AUC deve ~0.5
|
||||
T3 no temporal leakage: la baseline OOS dipende SOLO dallo sviluppo
|
||||
T4 variante logistic gira senza errori
|
||||
T5 restrict_pairs (solo pattern robusti) gira senza errori
|
||||
T6 le feature non contengono colonne di esito futuro
|
||||
"""
|
||||
import os, sys, copy
|
||||
import numpy as np
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import train as T
|
||||
from data import load
|
||||
from labeling import RegimeBaseline
|
||||
from splits import split_oos
|
||||
|
||||
CFG = os.path.join(HERE, "..", "config.yaml")
|
||||
ok = True
|
||||
|
||||
def check(name, cond):
|
||||
global ok
|
||||
print(("PASS" if cond else "FAIL"), "-", name)
|
||||
ok = ok and cond
|
||||
|
||||
# T6: nessuna feature e' un esito futuro
|
||||
cfg = T.load_cfg(CFG)
|
||||
future_like = ("ret_", "mfe_", "mae_", "dir_", "rev_", "cret_", "bars_to_revert", "disc_max")
|
||||
feats = cfg["features_numeric"] + cfg["features_categorical"]
|
||||
check("T6 feature senza esiti futuri",
|
||||
not any(f.startswith(future_like) or f in ("bars_to_revert",) for f in feats))
|
||||
|
||||
# T1: reproducibilita'
|
||||
folds1, oos1 = T.run(CFG)
|
||||
folds2, oos2 = T.run(CFG)
|
||||
check("T1 reproducibilita' OOS AUC", abs(oos1["auc"] - oos2["auc"]) < 1e-9)
|
||||
|
||||
# T3: baseline OOS non dipende dai dati OOS (no temporal leakage)
|
||||
crosses, bars = load(cfg["symbol"], cfg["year_min"])
|
||||
crosses = crosses.dropna(subset=[f"cret_{cfg['horizon']}"]).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"])
|
||||
rb_dev = RegimeBaseline(cfg["regime"], cfg["horizon"]).fit(dev_ba)
|
||||
lbl_a = rb_dev.label(oos_cr)
|
||||
# se cambio i dati OOS, l'edges/baseline (fittati su dev) NON cambiano
|
||||
rb_dev2 = RegimeBaseline(cfg["regime"], cfg["horizon"]).fit(dev_ba)
|
||||
lbl_b = rb_dev2.label(oos_cr)
|
||||
check("T3 baseline OOS deterministica e da solo-sviluppo", np.array_equal(lbl_a, lbl_b)
|
||||
and np.allclose(rb_dev.edges_["cl"], rb_dev2.edges_["cl"]))
|
||||
|
||||
# T2: controllo negativo (label mescolate -> AUC ~0.5)
|
||||
from evaluate import metrics
|
||||
from features import select_xy
|
||||
from model import make_model
|
||||
rng = np.random.default_rng(0)
|
||||
y_dev = rb_dev.label(dev_cr)
|
||||
final = make_model(cfg, cfg["features_numeric"], cfg["features_categorical"])
|
||||
final.fit(select_xy(dev_cr, cfg["features_numeric"], cfg["features_categorical"]), y_dev)
|
||||
p_oos = final.predict_proba(select_xy(oos_cr, cfg["features_numeric"], cfg["features_categorical"]))[:, 1]
|
||||
y_oos = rb_dev.label(oos_cr)
|
||||
y_shuf = y_oos.copy(); rng.shuffle(y_shuf)
|
||||
m_shuf = metrics(y_shuf, p_oos)
|
||||
check("T2 controllo negativo AUC~0.5 (0.45-0.55)", 0.45 <= m_shuf["auc"] <= 0.55)
|
||||
|
||||
# T4: variante logistic
|
||||
cfg_log = copy.deepcopy(cfg); cfg_log["model"]["kind"] = "logistic"
|
||||
ml = make_model(cfg_log, cfg["features_numeric"], cfg["features_categorical"])
|
||||
ml.fit(select_xy(dev_cr, cfg["features_numeric"], cfg["features_categorical"]), y_dev)
|
||||
pl = ml.predict_proba(select_xy(oos_cr, cfg["features_numeric"], cfg["features_categorical"]))[:, 1]
|
||||
check("T4 logistic produce probabilita' valide", np.all((pl >= 0) & (pl <= 1)))
|
||||
|
||||
# T5: restrict_pairs
|
||||
robust = ["MA365xMA7", "MA121xMA7", "MA121xMA3", "PRICExMA121", "MA182xMA30"]
|
||||
sub = dev_cr[dev_cr.pair.isin(robust)]
|
||||
check("T5 restrict_pairs ha campioni", len(sub) > 100)
|
||||
|
||||
print("\n=== RISULTATO SELF-TEST:", "TUTTO OK" if ok else "CI SONO FAIL", "===")
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -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