Prerequisiti

Questo modulo richiede conoscenza di pandas, numpy e dei concetti base di ML (train/test split, overfitting, cross-validation). Se non li hai, completa prima i moduli precedenti.

Feature Engineering

Il feature engineering è il processo di trasformare i dati grezzi di mercato (prezzi, volumi, ordini) in "features", le caratteristiche che catturano in modo efficace i pattern predittivi.
Il feature engineering è la fase più importante del Machine Learning applicato al trading. Nel trading algoritmico, l'80% del successo viene dal feature engineering, solo il 20% dalla scelta del modello. La qualità delle feature, che è dunque qualsiasi grandezza derivata che può aiutare il modello a prevedere movimenti futuri, determina quasi completamente la qualità del modello.

Un buon feature engineering:

  • cattura la struttura temporale dei mercati,
  • riduce la dimensionalità del problema,
  • rende il problema più lineare per il modello,
  • incorpora conoscenza di dominio fondamentale,
  • previene l'overfitting, che si ha quando il miglioramento delle prestazioni del modello sui dati di allenamento non implica un miglioramento delle prestazioni sui dati nuovi, attraverso la creazione di features robuste.

 

La chiave è creare features che siano:

  • predittive, correlate al target futuro;
  • robuste, ovvero stabili nel tempo;
  • interpretabili, cioè comprensibili per debugging;
  • diversificate che catturano diversi aspetti del mercato.

Il tempo dedicato al feature engineering per trasformare i dati open-high-low-close-volume (OHLCV) grezzi in input significativi per un classificatore è l'investimento a più alto ritorno nello sviluppo di modelli di trading quantitativi.

In:
import yfinance as yf import pandas as pd import numpy as np class TradingFeatureEngineer: def __init__(self, lookback_periods=[5, 10, 20, 60]): self.lookback_periods = lookback_periods def create_features(self, df): """Crea un set completo di features""" features = pd.DataFrame(index=df.index) # 1. Rendimenti prices = df['Close'] for period in self.lookback_periods: features[f'return_{period}'] = prices.pct_change(period) # 2. Volatilità returns = prices.pct_change() for period in self.lookback_periods: features[f'volatility_{period}'] = returns.rolling(period).std() # 3. Volume features volume = df['Volume'] features['volume_ratio'] = volume/volume.rolling(20).mean() # 4. Mean reversion for period in self.lookback_periods: rolling_mean = prices.rolling(period).mean() rolling_std = prices.rolling(period).std() features[f'zscore_{period}'] = (prices - rolling_mean) / rolling_std # 5. Momento for period in self.lookback_periods: features[f'momentum_{period}'] = prices /prices.shift(period) - 1 # 6. Interazioni features['ret_vol'] = features['return_5'] * features['volatility_20'] # 7. Target: rendimento futuro features['target'] = (prices.shift(-5) > prices).astype(int) # 8. Rimuovi NaN features = features.dropna() feature_cols = [f'return_{period}' for period in self.lookback_periods]+\ [f'volatility_{period}' for period in self.lookback_periods]+\ [f'zscore_{period}' for period in self.lookback_periods]+\ [f'momentum_{period}' for period in self.lookback_periods]+\ ['volume_ratio', 'ret_vol'] return features[feature_cols], features['target'] df = yf.download("AAPL", start="2015-01-01", end="2024-01-01", auto_adjust=True) TFE = TradingFeatureEngineer features, target = TFE().create_features(df) print(f"Dataset: {len(features)} righe, {len(features.columns)} feature") print(f"Classe 1 (su): {target.mean()*100:.1f}%")
Out:
[*********************100%***********************] 1 of 1 completedDataset: 2204 righe, 18 feature Classe 1 (su): 58.2%

Classificazione con scikit-learn

Usiamo un Random Forest, algoritmo di machine learning di uso comune che combina i risultati di più alberi decisionali per raggiungere un unico risultato, robusto, resistente all'overfitting, e fornisce l'importanza delle feature. La validazione usa TimeSeriesSplit per rispettare l'ordine temporale dei dati (mai usare K-Fold standard su serie temporali).

In:
import numpy as np from sklearn.ensemble import RandomForestClassifier from sklearn.preprocessing import StandardScaler from sklearn.metrics import classification_report from sklearn.model_selection import TimeSeriesSplit # features e target del blocco precedente X = features.values y = target.values # ── Split temporale: train 80%, test 20% ── split = int(len(X) * 0.8) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:] # Normalizzazione (fit SOLO su train per evitare data leakage) scaler = StandardScaler() X_train_s = scaler.fit_transform(X_train) X_test_s = scaler.transform(X_test) # ── Modello ── rf = RandomForestClassifier( n_estimators=200, max_depth=8, min_samples_leaf=20, random_state=42, n_jobs=-1 ) rf.fit(X_train_s, y_train) y_pred = rf.predict(X_test_s) y_prob = rf.predict_proba(X_test_s)[:, 1] print(classification_report(y_test, y_pred)) # ── Feature importance ── importances = sorted(zip(feature_cols, rf.feature_importances_), key=lambda x: -x[1]) for feat, imp in importances[:5]: print(f" {feat:<18} {imp*100:.1f}%") # ── Strategia basata sulla probabilità ── # Entra solo quando il modello è molto fiducioso threshold = 0.60 segnali_forti = (y_prob > threshold).sum() print(f"\nSegnali con prob > {threshold:.0%}: {segnali_forti} / {len(y_test)}")
Out:
precision recall f1-score support 0 0.58 0.22 0.32 204 1 0.56 0.86 0.68 237 accuracy 0.56 441 macro avg 0.57 0.54 0.50 441 weighted avg 0.57 0.56 0.51 441 volatility_60 8.5% volatility_20 7.3% momentum_10 6.9% volatility_10 6.9% zscore_60 6.5% Segnali con prob > 60%: 153 / 441
Errore critico — Data Leakage

Non usare mai train_test_split(shuffle=True) su dati finanziari. Le serie temporali devono essere sempre divise in ordine cronologico. Il futuro non può influenzare il passato. Usa sempre TimeSeriesSplit per la cross-validation.

Reti LSTM con Keras

Le Long Short-Term Memory (LSTM) sono reti neurali ricorrenti (RNN) progettate per sequenze temporali. A differenza dei modelli classici, prendono in input una finestra di T timestep consecutivi, catturando dipendenze temporali a lungo termine.

In:
import numpy as np from sklearn.preprocessing import MinMaxScaler from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping # ── Preparazione dati per LSTM ── WINDOW = 20 # finestra di 20 giorni def crea_sequenze(X, y, window): """Trasforma array 2D in sequenze 3D per LSTM [samples, timesteps, features].""" Xs, ys = [], [] for i in range(window, len(X)): Xs.append(X[i-window:i]) ys.append(y[i]) return np.array(Xs), np.array(ys) scaler = MinMaxScaler() X_scale = scaler.fit_transform(X) # X da feature engineering Xs, ys = crea_sequenze(X_scale, y, WINDOW) split = int(len(Xs) * 0.8) X_train, X_val = Xs[:split], Xs[split:] y_train, y_val = ys[:split], ys[split:] print(f"Shape X_train: {X_train.shape}") # (N, 20, 11) # ── Architettura LSTM ── model = Sequential([ LSTM(64, return_sequences=True, input_shape=(WINDOW, X_train.shape[2])), Dropout(0.2), LSTM(32, return_sequences=False), Dropout(0.2), Dense(16, activation="relu"), Dense(1, activation="sigmoid") ]) model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"]) # ── Training con early stopping ── es = EarlyStopping(monitor="val_loss", patience=15, restore_best_weights=True) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=32, callbacks=[es], verbose=1 ) loss, acc = model.evaluate(X_val, y_val, verbose=0) print(f"Validation accuracy: {acc*100:.2f}%")
Out:
Shape X_train: (1747, 20, 18) /usr/local/lib/python3.12/dist-packages/keras/src/layers/rnn/rnn.py:199: UserWarning: Do not pass an `input_shape`/`input_dim` argument to a layer. When using Sequential models, prefer using an `Input(shape)` object as the first layer in the model instead. super().__init__(**kwargs) Epoch 1/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 8s 36ms/step - accuracy: 0.5907 - loss: 0.6791 - val_accuracy: 0.5423 - val_loss: 0.7006 Epoch 2/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 1s 26ms/step - accuracy: 0.5913 - loss: 0.6736 - val_accuracy: 0.5423 - val_loss: 0.6840 Epoch 3/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 1s 26ms/step - accuracy: 0.5913 - loss: 0.6775 - val_accuracy: 0.5423 - val_loss: 0.6887 Epoch 4/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 1s 26ms/step - accuracy: 0.5913 - loss: 0.6737 - val_accuracy: 0.5423 - val_loss: 0.6807 Epoch 5/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 27ms/step - accuracy: 0.5902 - loss: 0.6737 - val_accuracy: 0.5423 - val_loss: 0.6859 Epoch 6/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 1s 26ms/step - accuracy: 0.5907 - loss: 0.6689 - val_accuracy: 0.5423 - val_loss: 0.6755 Epoch 7/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 34ms/step - accuracy: 0.5959 - loss: 0.6689 - val_accuracy: 0.5423 - val_loss: 0.6822 Epoch 8/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 31ms/step - accuracy: 0.5919 - loss: 0.6742 - val_accuracy: 0.5423 - val_loss: 0.6832 Epoch 9/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 1s 25ms/step - accuracy: 0.5930 - loss: 0.6686 - val_accuracy: 0.5423 - val_loss: 0.6777 Epoch 10/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 3s 26ms/step - accuracy: 0.5930 - loss: 0.6689 - val_accuracy: 0.5423 - val_loss: 0.6773 Epoch 11/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 1s 25ms/step - accuracy: 0.5913 - loss: 0.6665 - val_accuracy: 0.5423 - val_loss: 0.6764 Epoch 12/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 1s 26ms/step - accuracy: 0.5913 - loss: 0.6681 - val_accuracy: 0.5423 - val_loss: 0.6717 Epoch 13/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 1s 26ms/step - accuracy: 0.5942 - loss: 0.6646 - val_accuracy: 0.5423 - val_loss: 0.7108 Epoch 14/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 33ms/step - accuracy: 0.5936 - loss: 0.6635 - val_accuracy: 0.5446 - val_loss: 0.6847 Epoch 15/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 43ms/step - accuracy: 0.5919 - loss: 0.6601 - val_accuracy: 0.5675 - val_loss: 0.6660 Epoch 16/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 27ms/step - accuracy: 0.6068 - loss: 0.6588 - val_accuracy: 0.5904 - val_loss: 0.6761 Epoch 17/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 3s 26ms/step - accuracy: 0.6062 - loss: 0.6633 - val_accuracy: 0.5561 - val_loss: 0.6717 Epoch 18/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 28ms/step - accuracy: 0.6005 - loss: 0.6595 - val_accuracy: 0.5538 - val_loss: 0.6738 Epoch 19/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 26ms/step - accuracy: 0.6056 - loss: 0.6574 - val_accuracy: 0.5469 - val_loss: 0.6734 Epoch 20/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 27ms/step - accuracy: 0.6073 - loss: 0.6574 - val_accuracy: 0.5721 - val_loss: 0.6836 Epoch 21/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 42ms/step - accuracy: 0.6079 - loss: 0.6522 - val_accuracy: 0.5606 - val_loss: 0.6924 Epoch 22/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 37ms/step - accuracy: 0.6039 - loss: 0.6595 - val_accuracy: 0.5698 - val_loss: 0.6744 Epoch 23/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 27ms/step - accuracy: 0.6228 - loss: 0.6524 - val_accuracy: 0.5561 - val_loss: 0.6777 Epoch 24/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 28ms/step - accuracy: 0.6079 - loss: 0.6514 - val_accuracy: 0.5423 - val_loss: 0.6757 Epoch 25/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 3s 29ms/step - accuracy: 0.6096 - loss: 0.6502 - val_accuracy: 0.5698 - val_loss: 0.6867 Epoch 26/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 27ms/step - accuracy: 0.6205 - loss: 0.6489 - val_accuracy: 0.5400 - val_loss: 0.7000 Epoch 27/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 28ms/step - accuracy: 0.6256 - loss: 0.6445 - val_accuracy: 0.5057 - val_loss: 0.7242 Epoch 28/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 43ms/step - accuracy: 0.6239 - loss: 0.6424 - val_accuracy: 0.5881 - val_loss: 0.6760 Epoch 29/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 34ms/step - accuracy: 0.6279 - loss: 0.6420 - val_accuracy: 0.5423 - val_loss: 0.7340 Epoch 30/100 55/55 ━━━━━━━━━━━━━━━━━━━━ 2s 28ms/step - accuracy: 0.6222 - loss: 0.6470 - val_accuracy: 0.5835 - val_loss: 0.6793 Validation accuracy: 56.75%
Accuracy vs profittabilità

Un'accuracy del 55% su un classificatore direzionale può essere sufficiente per una strategia profittevole — se le previsioni corrette coincidono con movimenti più grandi delle previsioni errate. L'accuracy da sola non misura il valore del modello.

Pipeline completa: da dati a segnali

Integriamo tutto in una pipeline che produce segnali operativi giornalieri da passare al backtest (modulo 4) o al live trading (modulo 6).

In:
import yfinance as yf import pandas as pd import numpy as np from sklearn.ensemble import GradientBoostingClassifier from sklearn.preprocessing import StandardScaler import joblib class MLTradingPipeline: def __init__(self, ticker, lookback=5): self.ticker = ticker self.lookback = lookback self.scaler = StandardScaler() self.model = GradientBoostingClassifier( n_estimators=150, max_depth=4, learning_rate=0.05 ) def crea_features(self, df): """Crea un set completo di features""" features = pd.DataFrame(index=df.index) if isinstance(df.columns, pd.MultiIndex): prices = df.Close.iloc[:, 0] # Select the first column of the 'Close' MultiIndex else: prices = df.Close # 1. Rendimenti for period in [1,3,5,10,20]: features[f'return_{period}'] = prices.pct_change(period) # 2. Volatilità returns = prices.pct_change() for period in [3,5,10,20]: features[f'volatility_{period}'] = returns.rolling(period).std() # 3. Volume features volume = df.Volume features['volume_ratio'] = volume/volume.rolling(20).mean() # 4. Mean reversion for period in [5,10,20]: rolling_mean = prices.rolling(period).mean() rolling_std = prices.rolling(period).std() features[f'zscore_{period}'] = (prices - rolling_mean) / rolling_std # 5. Momento for period in [5,20,50]: features[f'momentum_{period}'] = prices /prices.shift(period) - 1 # 6. Interazioni features['ret_vol'] = features['return_5'] * features['volatility_20'] # 7. d = prices.diff(); g = d.clip(lower=0).ewm(alpha=1/14).mean() l = (-d.clip(upper=0)).ewm(alpha=1/14).mean() features["rsi"] = 100*g/(l+g) return features.dropna() def fit(self, df): # Flatten MultiIndex columns before creating features if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.droplevel(1) feat = self.crea_features(df) y = (df.Close.pct_change(self.lookback,fill_method=None).shift(-self.lookback) > 0).astype(int) X = feat.reindex(y.index).dropna() y = y[X.index].dropna() # This line expects y to be a Series, not a DataFrame X = X.loc[y.index] Xs = self.scaler.fit_transform(X) self.model.fit(Xs, y) self.feature_cols = X.columns.tolist() print(f"✓ Modello addestrato su {len(X)} campioni") def segnale_oggi(self, df): # Flatten MultiIndex columns for the input df as well if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.droplevel(1) feat = self.crea_features(df).iloc[[-1]] prob = self.model.predict_proba(self.scaler.transform(feat))[0,1] return {"data": df.index[-1].date(), "prob_rialzo": round(prob,3), "segnale": "BUY" if prob > 0.6 else ("SELL" if prob < 0.4 else "HOLD")} # Utilizzo pipeline = MLTradingPipeline("AAPL") df = yf.download("AAPL", start="2018-01-01", auto_adjust=True) pipeline.fit(df.iloc[:-252]) # train su tutto tranne ultimo anno print(pipeline.segnale_oggi(df))
Out:
[*********************100%***********************] 1 of 1 completed ✓ Modello addestrato su 1765 campioni {'data': datetime.date(2026, 3, 24), 'prob_rialzo': np.float64(0.408), 'segnale': 'HOLD'}
ESERCIZIOConfronto modelli su più ticker

Testa e confronta tre classificatori (Random Forest, Gradient Boosting, SVM) su 3 diversi ticker (AAPL, SPY, BTC-USD) con TimeSeriesSplit a 5 fold. Per ciascun modello:

  • Calcola accuracy, precision, recall e F1 out-of-sample
  • Integra le previsioni nel backtesting vectorized del modulo 4
  • Confronta le metriche di performance della strategia ML vs strategia EMA cross
  • Visualizza la feature importance dei due modelli tree-based