Introduzione all’Ottimizzazione di una Strategia – Parte II

Il precedente articolo si è concentrato sulla selezione del modello e sull’ottimizzazione del modello statistico sottostante che (potrebbe) costituire la base di una strategia di trading. Tuttavia, un modello predittivo e una strategia algoritmica funzionante e redditizia sono due entità diverse. Questo articolo rivolge l’attenzione all’ottimizzazione dei parametri che hanno un effetto diretto sulla redditività e sulle metriche del rischio.
Per raggiungere questo obiettivo si utilizza un software di backtesting event-driven, come descritto in un questa serie di articoli. Si prende inoltre in considerazione una particolare strategia che ha tre parametri ad essa associati, e si effettua uno studio sullo spazio formato dal prodotto cartesiano dei parametri, utilizzando il metodo della grid search. Quindi si tenta di massimizzare alcune specifiche metriche, come il Sharpe Ratio, o minimizzarne altre, come il drawdown massimo.

Strategia Intraday di Pairs Trading sull'azionario

La strategia che si prende in considerazione è la “Intraday Mean Reverting Equity Pairs Trade” utilizzando le AREX e WLL del settore energetico, come descritto in un precedente articolo. Questa strategia prevede tre parametri che si possono ottimizzare: il periodo di ricerca della regressione lineare, i residui delle soglie di z-score di entrata e i residui delle soglie di z-score di uscita.
Si considera un intervallo di valori per ogni parametro e quindi si effettua il backtesting della strategia per ciascuno di questi intervalli, ricavando il rendimento totale, il Sharpe Ratio e le caratteristiche di drawdown per ogni simulazione, e memorizza i risultati in un file CSV per ogni set di parametri. Questo permette di ricavare uno Sharpe ottimizzato o un drawdown massimo minimizzato per la strategia di trading oggetto di analisi.,

Aggiustamento dei Parametri

Dato che il software di backtesting basato sugli eventi è piuttosto oneroso per il carico di lavoro sulla CPU, si limita l’intervallo di parametri a solo tre valori per ogni parametro. Questo fornisce un totale di \( 3^{3} = 27\) separate simulazioni da eseguire. Gli intervalli dei parametri sono elencati di seguito:
  • OLS Lookback Window – \(  w_i \in \big\{50, 100, 200\big\}\)
  • Z-Score Entry Threshold – \( z_h \in \big\{2.0, 3.0, 4.0\big\}\)
  • Z-Score Exit Threshold – \( z_l \in \big\{0.5, 1.0, 1.5\big\}\)
Per eseguire l’insieme di simulazioni si calcola un prodotto cartesiano di tutte e tre gli intervalli e si esegue la simulazione per ciascuna combinazione di parametri (ciascun prodotto cartesiano). Il primo passo è modificare il file intraday_mr.py ed includere il metodo product dalla libreria itertools:
# intraday_mr.py
..
from itertools import product
..

E’ quindi necessario modificare il metodo principale __main__ per includere la generazione di un elenco di valori per tutti e tre i parametri, descritti in precedenza. Il primo passo consiste nel creare gli effettivi intervalli dei parametri per la finestra di ricerca OLS, la soglia di ingresso dello zscore e la soglia di uscita dello zscore. Ognuno di questi può assumere 3 diversi valori quindi la loro combinazione causa un totale di 27 simulazioni.

Una volta creati gli intervalli, si usa il metodo itertools.product per creare un prodotto cartesiano di tutti i valori, che sono poi inseriti all’interno di un dizionario al fine di garantire che i corretti argomenti siano passati all’oggetto Strategy. Infine, il backtest viene istanziato con la strat_params_list che contiene il dizionario di tutte le combinazioni dei parametri:

if __name__ == "__main__":
   csv_dir = ’/path/to/your/csv/file’ # CHANGE THIS!
   symbol_list = [’AREX’, ’WLL’]
   initial_capital = 100000.0
   heartbeat = 0.0
   start_date = datetime.datetime(2007, 11, 8, 10, 41, 0)
# Create the strategy parameter grid # using the itertools cartesian product generator strat_lookback = [50, 100, 200] strat_z_entry = [2.0, 3.0, 4.0] strat_z_exit = [0.5, 1.0, 1.5] strat_params_list = list(product( strat_lookback, strat_z_entry, strat_z_exit )) # Create a list of dictionaries with the correct # keyword/value pairs for the strategy parameters strat_params_dict_list = [ dict(ols_window=sp[0], zscore_high=sp[1], zscore_low=sp[2]) for sp in strat_params_list ]
# Carry out the set of backtests for all parameter combinations backtest = Backtest( csv_dir, symbol_list, initial_capital, heartbeat, start_date, HistoricCSVDataHandlerHFT, SimulatedExecutionHandler, PortfolioHFT, IntradayOLSMRStrategy, strat_params_list=strat_params_dict_list ) backtest.simulate_trading()
Il passaggio successivo consiste nel modificare l’oggetto Backtest in backtest.py per poter gestire più set di parametri. In particolare si modifica il metodo _generate_trading_instances per avere un argomento che rappresenti lo specifico parametro impostato sulla creazione di un nuovo oggetto Strategy:
# backtest.py
..

def _generate_trading_instances(self, strategy_params_dict): """ Generates the trading instance objects from their class types. """ print("Creating DataHandler, Strategy, Portfolio and ExecutionHandler for") print("strategy parameter list: %s..." % strategy_params_dict) self.data_handler = self.data_handler_cls( self.events, self.csv_dir, self.symbol_list, self.header_strings ) self.strategy = self.strategy_cls( self.data_handler, self.events, **strategy_params_dict ) self.portfolio = self.portfolio_cls( self.data_handler, self.events, self.start_date, self.num_strats, self.periods, self.initial_capital ) self.execution_handler = self.execution_handler_cls(self.events)
Questo metodo è richiamato all’interno di un ciclo iterativo della lista dei parametri della strategia, invece che in fase di costruzione dell’oggetto Backtest. Nonostante ricreare tutti i gestori di dati, la coda degli ‘eventi e oggetti portfolio per ogni set di parametri possa sembrare uno spreco, questo assicura che tutte le variabili siano state resettate ad ogni iterazione e quindi avere un “ambiente pulito” per ogni simulazione. Il prossimo passo consiste nel modificare il metodo simulate_trading per eseguire il loop su tutte le possibili combinazioni dei parametri della strategia. Il metodo crea un file CSV di output che viene utilizzato per memorizzare le combinazioni di parametri e le loro specifiche metriche di performance. Questo consente di tracciare l’andamento delle prestazioni tra i parametri. Il metodo effettua un loop su tutti i parametri della strategia e genera una nuova istanza di trading su ogni simulazione. Si esegue quindi il backtesting e si calcolano le statistiche. Questi dati sono raccolti in un file CSV. Al termine della simulazione, il file di output viene chiuso e memorizzato:
# backtest.py

..
def simulate_trading(self):
    """
    Simulates the backtest and outputs portfolio performance.
    """
    out = open("output/opt.csv", "w")
    spl = len(self.strat_params_list)
    for i, sp in enumerate(self.strat_params_list):
        print("Strategy %s out of %s..." % (i+1, spl))
        self._generate_trading_instances(sp)
        self._run_backtest()
        stats = self._output_performance()
        pprint.pprint(stats)
        tot_ret = float(stats[0][1].replace("%",""))
        cagr = float(stats[1][1].replace("%",""))
        sharpe = float(stats[2][1])
        max_dd = float(stats[3][1].replace("%",""))
        dd_dur = int(stats[4][1])
        out.write(
            "%s,%s,%s,%s,%s,%s,%s,%s\n" % (
                sp["ols_window"], sp["zscore_high"], sp["zscore_low"],
                tot_ret, cagr, sharpe, max_dd, dd_dur
                )
        )
        out.close()
Su un normale sistema desktop, questo processo richiede del tempo! Per 27 simulazioni su oltre 600.000 punti dati per simulazione sono state necessarie almeno 2 ore. In questa fase backtester non è stato parallelizzato, quindi la contemporanea esecuzione di più simulazioni in parallelo renderebbe il processo molto più veloce. L’output per l’attuale spazio dei parametri corrente è riportato di seguito. Le colonne corrispondo a OlS Lookback, ZScore High, ZScore Low, Total Return (%), CAGR (%), Sharpe, Max DD (%), DD Duration (minuti):
50,2.0,0.5,213.96,20.19,1.63,42.55,255568
50,2.0,1.0,264.9,23.13,2.18,27.83,160319
50,2.0,1.5,167.71,17.15,1.63,60.52,293207
50,3.0,0.5,298.64,24.9,2.82,14.06,35127
50,3.0,1.0,324.0,26.14,3.61,9.81,33533
50,3.0,1.5,294.91,24.71,3.71,8.04,31231
50,4.0,0.5,212.46,20.1,2.93,8.49,23920
50,4.0,1.0,222.94,20.74,3.5,8.21,28167
50,4.0,1.5,215.08,20.26,3.66,8.25,22462
100,2.0,0.5,378.56,28.62,2.54,22.72,74027
100,2.0,1.0,374.23,28.43,3.0,15.71,89118
100,2.0,1.5,317.53,25.83,2.93,14.56,80624
100,3.0,0.5,320.1,25.95,3.06,13.35,66012
100,3.0,1.0,307.18,25.32,3.2,11.57,32185
100,3.0,1.5,306.13,25.27,3.52,7.63,33930
100,4.0,0.5,231.48,21.25,2.82,7.44,29160
100,4.0,1.0,227.54,21.01,3.11,7.7,15400
100,4.0,1.5,224.43,20.83,3.33,7.73,18584
200,2.0,0.5,461.5,31.97,2.98,19.25,31024
200,2.0,1.0,461.99,31.99,3.64,10.53,64793
200,2.0,1.5,399.75,29.52,3.57,10.74,33463
200,3.0,0.5,333.36,26.58,3.07,19.24,56569
200,3.0,1.0,325.96,26.23,3.29,10.78,35045
200,3.0,1.5,284.12,24.15,3.21,9.87,34294
200,4.0,0.5,245.61,22.06,2.9,12.52,51143
200,4.0,1.0,223.63,20.78,2.93,9.61,40075
200,4.0,1.5,203.6,19.55,2.96,7.16,40078

Da notare che per la particolare combinazione dei parametri pari a wl = 50, zh  = 3.0 e zl = 1.5 si ha il miglior Sharpe Ratio, pari a S = 3.71. Per questo Sharpe Ratio si ha un total return del 294.91% e drawdown massimo del 8.04%. Il migliore total return è del 461.99%, a cui corrisponde pero un drawdown massimo del 10.53%, si verifica per il set di parametri con wl = 200, zh = 2.0 e zl = 1.0.

Visualizzazione

Come passo finale nell’ottimizzazione di una strategia, si può visualizzare le prestazioni del backtesting usando Matplotlib, che può essere estramemente utile quando si effettuano le iniziali studi su una strategia. Purtroppo lo scenario preso in considerazione ha uno spazio delle soluzioni tridimensionale quindi la visualizzazione delle prestazioni non è immediata! Tuttavia, si possono fare alcuni miglioramenti. In primo luogo, si può fissare il valore di un parametro e prendere una “sezione parametri” attraverso il resto del “cubo di dati”. Ad esempio, si può fissare la finestra di ricerca a 100 e poi vedere come la variazione delle soglie di entrata e di uscita dello z-score influenza il Sharpe Ratio o il drawdown massimo. Per raggiungere questo obiettivo si utilizza Matplotlib. Si acquisisce l’output CSV e si rimodella i dati in modo tale che si possa visualizzare i risultati.

Heatmap del Ratio / Drawdown

Fissato il periodo di ricerca a wl = 100 si genera una griglia 3×3 e una “heatmap” del Sharpe Ratio e drawdown massimo per la variazione delle soglie z-score. Nel seguente codice si acquisisce il file CSV di output. Il primo compito è filtrare i periodi di ricerca che ci interessano (50 e 200). Quindi si modifica i restanti dati sulle prestazioni in due matrici 3×3. Il primo rappresenta il Sharpe Ratio per ogni combinazione delle soglie dello z-score mentre il secondo rappresenta il drawdown massimo. Ecco il codice per creare la heatmap dello Sharpe Ratio. Per prima cosa si importa Matplotlib e NumPy. Quindi si definisce una funzione chiamata create_data_matrix che rimodella i dati dello Sharpe Ratio in una griglia 3×3. All’interno della funzione principale __main__ si apre il file CSV (assicurarsi di cambiare il percorso sul file system del sistema!) E si esclude qualsiasi record che non faccia riferimento a un periodo di ricerca di 100. Si crea quindi una heatmap con ombreggiatura blu e si applicano le corrette etichette di riga / colonna usando le soglie dello z-score. Successivamente si posiziona il valore effettivo del Sharpe Ratio sulla heatmap. Infine, si imposta tick, etichette, titolo e quindi si traccia la heatmap:
#!/usr/bin/python
# -*- coding: utf-8 -*-

# plot_sharpe.py

import matplotlib.pyplot as plt
import numpy as np

def create_data_matrix(csv_ref, col_index):
    data = np.zeros((3, 3))
    for i in range(0, 3):
        for j in range(0, 3):
            data[i][j] = float(csv_ref[i*3+j][col_index])
            return data


if __name__ == "__main__":
    # Open the CSV file and obtain only the lines
    # with a lookback value of 100
    csv_file = open("/path/to/opt.csv", "r").readlines()
    csv_ref = [
        c.strip().split(",")
        for c in csv_file if c[:3] == "100"
        ]
    data = create_data_matrix(csv_ref, 5)

    fig, ax = plt.subplots()
    heatmap = ax.pcolor(data, cmap=plt.cm.Blues)
    row_labels = [0.5, 1.0, 1.5]
    column_labels = [2.0, 3.0, 4.0]
    for y in range(data.shape[0]):
        for x in range(data.shape[1]):
            plt.text(x + 0.5, y + 0.5, ’%.2f’ % data[y, x],
                horizontalalignment=’center’,
                verticalalignment=’center’,
            )
    plt.colorbar(heatmap)

    ax.set_xticks(np.arange(data.shape[0])+0.5, minor=False)
    ax.set_yticks(np.arange(data.shape[1])+0.5, minor=False)
    ax.set_xticklabels(row_labels, minor=False)
    ax.set_yticklabels(column_labels, minor=False)

    plt.suptitle(’Sharpe Ratio Heatmap’, fontsize=18)
    plt.xlabel(’Z-Score Exit Threshold’, fontsize=14)
    plt.ylabel(’Z-Score Entry Threshold’, fontsize=14)
    plt.show()
Il grafico del drawdown massimo è quasi identica ad eccezione dell’utilizzo di una heatmap con ombreggiatura rossa e la modifica dell’indice di colonna nella funzione create_data_matrix per usare i dati percentuali del drawdown massimo.
#!/usr/bin/python
# -*- coding: utf-8 -*-

# plot_drawdown.py

import matplotlib.pyplot as plt
import numpy as np

def create_data_matrix(csv_ref, col_index):
    data = np.zeros((3, 3))
    for i in range(0, 3):
        for j in range(0, 3):
            data[i][j] = float(csv_ref[i*3+j][col_index])
            return data


if __name__ == "__main__":
    # Open the CSV file and obtain only the lines
    # with a lookback value of 100
    csv_file = open("/path/to/opt.csv", "r").readlines()
    csv_ref = [
        c.strip().split(",")
        for c in csv_file if c[:3] == "100"
        ]
    data = create_data_matrix(csv_ref, 6)

    fig, ax = plt.subplots()
    heatmap = ax.pcolor(data, cmap=plt.cm.Reds)
    row_labels = [0.5, 1.0, 1.5]
    column_labels = [2.0, 3.0, 4.0]
    for y in range(data.shape[0]):
        for x in range(data.shape[1]):
            plt.text(x + 0.5, y + 0.5, ’%.2f’ % data[y, x],
                horizontalalignment=’center’,
                verticalalignment=’center’,
            )
    plt.colorbar(heatmap)

    ax.set_xticks(np.arange(data.shape[0])+0.5, minor=False)
    ax.set_yticks(np.arange(data.shape[1])+0.5, minor=False)
    ax.set_xticklabels(row_labels, minor=False)
    ax.set_yticklabels(column_labels, minor=False)

    plt.suptitle(’Drawdown Heatmap’, fontsize=18)
    plt.xlabel(’Z-Score Exit Threshold’, fontsize=14)
    plt.ylabel(’Z-Score Entry Threshold’, fontsize=14)
    plt.show()
I grafici delle Heatmap per lo Sharpe Ratio e il Drawdown Massimo sono riportati di seguito
Fig - Heatmap dello Sharpe Ratio per le soglie dello z-score di entrate / uscita
Fig - Heatmap del Drawdown Massimo per le soglie dello z-score di entrate / uscita
Con wl = 100 le differenze tra il più piccolo e il più grande Sharpe Ratio più piccolo e il più grande, così come tra il più piccolo e il più grande drawdown massimo, sono molto evidenti. Il Sharpe Ratio è ottimizzato per le soglie di entrata e uscita più grandi, mentre il drawdown è ridotto al minimo nella stessa regione. Il Sharpe Ratio e il drawdown massimo sono peggiori quando entrambe le soglie di ingresso e di uscita sono basse.

Introduzione all’Ottimizzazione di una Strategia

Nei precedenti articoli è stato descritto come creare un modello predittivo (come il Suppor Vector Machine e il Classificatore Random Forest) e una strategia di trading basato su di esso. E’ stato evidenziato come tali modelli prevedano molti parametri.
Nel caso di un SVM si ha i parametri di “tuning” \(\gamma\) e C. In una strategia di crossover della media mobile si ha i parametri per le due finestre di ricerca della media mobile, usata come filtro.

In questo articolo si descrivono i metodi di ottimizzazione per migliorare le prestazioni delle strategie di trading, definendo i parametri in modo sistematico. A tal fine si usano approcci statistici per la selezione del modello, come la convalida incrociata e la ricerca della griglia.
La letteratura sulla selezione dei modelli e l’ottimizzazione dei parametri è vasta e la maggior parte dei metodi
sono oltre lo scopo di questo articolo. Si vuole introdurre l’argomento in modo che si possa esplorare autonomamente le tecniche più avanzate.

Ottimizzazione dei Parametri

In questa fase si usano uno o più parametri per poter far lavorare quasi tutte le strategie di trading e i modelli statistici sottostanti. Nelle strategie di momentum che utilizzano indicatori tecnici, come ad esempio le medie mobili (semplici o esponenziali), è necessario specificare una finestra di lookback, cioè il numero di periodi passati sui quali calcolare la media. Lo stesso vale per molte strategie di mean-reverting, che richiedono una finestra (rolling) di lookback per calcolare la regressione tra due serie temporali. In particolare i modelli di machine learning statistico, come la regressione logistica, lo SVM o il Random Forest, richiedono dei parametri per poter essere calcolati. Il più grande pericolo quando si considera l’ottimizzazione dei parametri è quello dell’overfitting del modello o della strategia di trading. Questo problema si verifica quando un modello viene addestrato su un “in-sample” set di dati ed è ottimizzato per funzionare bene su tali dati (con una specifica misura di performance), ma le prestazioni si riducono in modo sostanziale quanto sono applicati dati “out of sample”. Ad esempio, una strategia di trading potrebbe funzionare molto bene nel backtesting (dati in-sample) ma quando applicata al live trading può essere completamente inutile. Un’ulteriore preoccupazione dell’ottimizzazione dei parametri è che può diventare computazionale molto costosa. Con i moderni sistemi computazionali questo problema è meno critico rispetto a qualche anno fa, grazie ad una maggiore parallelizzazione e CPU più veloci. Tuttavia, l’ottimizzazione di più parametri può aumentare la complessità del calcolo di alcuni ordini di grandezza.

Quali parametri ottimizzare?

Un modello di trading algoritmico basato sulle statistiche ha spesso molti parametri e diverse misure di performance, dato che il sottostante algoritmo di apprendimento statistico ha il proprio set di parametri. Nel caso di una multipla regressione lineare o logistica, questi sono i coefficienti \(\beta_i\).
Nel caso di una Random Forest un parametro è il numero di alberi decisionali da utilizzare. Una volta applicati a un modello di trading, altri parametri sono le soglie di entrata e di uscita, come lo z-score di una particolare serie storica. Lo stesso z-score ha un’implicita finestra di rollback. Come è evidente il numero di parametri può essere piuttosto grande.
Oltre ai parametri ci sono numerosi strumenti per valutare le prestazioni di un modello statistico e la strategia di trading basata su di esso. Si sono definiti concetti come l’Hit Rate e la Matrice di Confusione. Inoltre ci sono diverse misure statistiche come il Mean Squared Error (MSE). Si tratta di misure di rendimento che sono ottimizzate a livello di modello statistico, tramite parametri rilevanti per il loro dominio.
La strategia di trading effettiva viene valutata su diversi criteri, come il tasso di crescita annuale composto (CAGR) e il massimo drawdown massimo. E’ necessario variare i criteri di entrata e di uscita, così come le altre soglie che non sono direttamente correlate al modello statistico.

Questo motiva la domanda su relativa a quale insieme di parametri ottimizzare e quando.
Nelle sezioni seguenti si descrive come ottimizzare sia parametri del modello statistico, nella fase iniziale di ricerca e sviluppo, sia i parametri associati alla strategia di trading utilizzando un sottostante modello statistico ottimizzato, su ciascuna delle rispettive misure delle prestazioni.

L'Ottimizzazione è Costosa

Con diversi parametri reali, l’ottimizzazione può diventare rapidamente costosa, dato che
ogni nuovo parametro aggiunge una dimensione spaziale. Se si considera l’esempio di una griglia di
ricerca
(discussa nel dettagli di seguito) e avere un singolo parametro \(\alpha\), quindi si potrebbe desiderare di variare \(\alpha\) all’interno dell’insieme \big\{0.1,0.2,0.3,0.4,0.5\big\}. Questo richiede 5 simulazioni.

Se si considera un parametro aggiuntivo, che può variare nell’intervallo \big\{0.2,0.4,0.6,0.8,1\big\} allora si dovrà effettuare \(5^{2} = 25\) simulazioni. Aggiungendo un altro parametro con 5 possibili valore si arriva a \(5^{3} = 125\) simulazioni. Se ogni parametro avesse 10 diversi valori da testare separatamente, si avrebbero \(10^{3} = 1000\). Come si può vedere, lo spazio di ricerca dei parametri può rapidamente richiedere attività di simulazione estremamente costose.

È chiaro che esiste un compromesso tra lo svolgere un’esaustiva ricerca dei parametri e mantenere ragionevole il tempo totale di simulazione. Nonostante il parallelismo, comprese le CPU multi-core e le unità di elaborazione grafica (GPU), ha attenuato questo  problema, è necessario prestare molta attenzione quando si introducono nuovi parametri. La riduzione dei parametri è anche un problema di efficacia del modello, come descritto in seguito.

Overfitting

L’overfitting è il processo di ottimizzazione di un parametro, o set di parametri, rispetto a un particolare set di dati in modo tale che una specifica misura di performance (o misura dell’errore) sia massimizzata (o minimizzata), ma quando applicata a un set di dati invisibile, la stessa misura di performance degrata vertiginosamente. Il concetto è strettamente correlato al concetto del bias-variance dilemma. Il dilemma del bias-variance riguarda il bilanciamento tra il bias e la varianza, dove il bias si riferisce alla differenza tra la stima di un parametro effettua dal modello e il vero valore del parametro nella “popolazione” reale, o assunzioni errate nel modello statistico, mentre la varianza si riferisce all’errore causato dalla sensibilità del modello a piccole fluttuazioni nel set di allenamento (nei dati in-sample). In tutti i modelli statistici, si tenta contemporaneamente di ridurre al minimo sia l’errore di bias che l’errore di varianza al fine di migliorare l’accuratezza del modello. Tale situazione può portare a un overfitting dei modelli, in quanto l’errore di allenamento può essere sostanzialmente ridotto introducendo modelli con maggiore flessibilità (variazione). Tuttavia, tali modelli possono offrire prestazioni estremamente scarse su dati nuovi (non inclusi nel campione) dato che sono essenzialmente “adatti” ai dati in-sample. Un esempio di modello con elevato bias e bassa varianza è quello della regressione lineare applicata a un set di dati non lineari. L’introduzione di nuovi punti non incide sull’inclinazione della regressione (supponendo che non siano troppo lontani dai restanti dati), ma poiché il problema è intrinsecamente non lineare, usando un modello lineare si verifica un errore sistematico nei risultati. Un esempio di un modello con basso bias ed elevata varianza è quello di un spline polinomiale applicato a un set di dati non lineare. Il parametro del modello (il grado del polinomio) potrebbe essere regolato per adattarsi con precisione a tale modello (cioè basso bias sui dati in-sample), ma l’aggiunta di nuovi punti causa quasi certamente una modifica al grado di polinomio affinchè il modello possa adattarsi ai nuovi dati. Ciò causa inoltre un modello con un’elevata varianza sui dati in-sample. Un tale modello presenta una scarsa prevedibilità o capacità inferenziale su dati sample. L’overfitting può anche manifestarsi sulla strategia di trading e non solo sul modello statistico. Ad esempio, si può ottimizzare il Sharpe ratio modificando i parametri relativi alla soglia di entrata e uscita. Nonostante questo approccio migliori la redditività del backtest (o minimizzare il rischio), molto probabilmente tale comportamento non può essere replicato quando la strategia è eseguita in live trading dato che tali ottimizzazioni sono state adattate al rumore nei dati storici. Di seguito si evidenziano le principali tecniche usate per ridurre al minimo l’overfitting. Tuttavia è indispensabile essere consapevoli del fatto che l’overfitting è un pericolo sempre presente sia nel trading algoritmico che, più in generale, nell’analisi statistica.

Selezione del Modello

In questa sezione si descrive come ottimizzare il modello statistico che sottostà a una strategia di trading. Nel campo della statistica e del machine learning tale approccio è noto come selezione del modello. In questo articolo non si vuole presentare una discussione esaustiva sulle varie tecniche di selezione dei modelli, ma si introduco solamente alcuni dei meccanismi di base, come Cross Validation e Grid Search, che funzionano bene per le strategie di trading.

Cross Validation

Il Cross Validation (o convalida incrociata) è una tecnica utilizzata per valutare come un modello statistico viene generalizzato a nuovi dati a cui non è stato esposto in precedenza. Tale tecnica viene solitamente utilizzata su modelli predittivi, come i già citati classificatori supervisionati utilizzati per prevedere il segno dei return dei giorni successivi ad una serie di prezzi patrimoniali. Fondamentalmente, l’obiettivo della convalida incrociata è di minimizzare l’errore sui dati di esempio senza produrre un modello “overfittato”. In questo paragrafo si descrive il training/test split e la k-fold cross-validation, e si utilizza gli strumenti all’interno di Scikit-Learn per eseguire automaticamente queste procedure su modelli statistici che sono già stati sviluppati.   Trading/Test Split L’esempio più semplice di cross validation è noto come trading/test split o convalida incrociata per 2 volte. Una volta assemblato un set di dati storici precedenti (ad esempio una serie temporale giornaliera di prezzi degli asset), viene suddiviso in due componenti. Il rapporto della divisione varia solitamente tra 0,5 e 0.8. In quest’ultimo caso ciò significa che l’80% dei dati viene utilizzato per il traning e il 20% viene utilizzato per i test. Tutte le statistiche di interesse, come il tasso di successo, la matrice di confusione o l’errore medio al quadrato sono calcolate sul set di test, che non è stato utilizzato all’interno del processo di training. Per eseguire questo processo in Python tramite la libreria Scikit-Learn si può usare il metodo sklearncross _validation train_test_split. Di seguito si amplia quanto già descritto nel precedente articolo. In particolare, si modifica forecast.py e si crea un nuovo file chiamato train_test_split.py:
#!/usr/bin/python
# -*- coding: utf-8 -*-
# train_test_split.py

import datetime
import sklearn

from sklearn.cross_validation import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.lda import LDA
from sklearn.metrics import confusion_matrix
from sklearn.qda import QDA
from sklearn.svm import LinearSVC, SVC
from forecast import create_lagged_series
Originariamente in forecast.py i dati sono stati suddivisi in base a una data specifica all’interno delle serie temporali:
# forecast.py
..
# The test data is split into two parts: Before and after 1st Jan 2005.
start_test = datetime.datetime(2005,1,1)
# Create training and test sets
X_train = X[X.index < start_test] X_test = X[X.index >= start_test]
y_train = y[y.index < start_test] y_test = y[y.index >= start_test]
..
Nle file train_test_split.py questo codice è sostituito con il metodo train_test_split di Scikit-Learn . Per completezza, il metodo principale è implementato come segue:
# train_test_split.py
if __name__ == "__main__": # Create a lagged series of the S&P500 US stock market index snpret = create_lagged_series( "^GSPC", datetime.datetime(2001,1,10), datetime.datetime(2005,12,31), lags=5 )
# Use the prior two days of returns as predictor # values, with direction as the response X = snpret[["Lag1","Lag2"]] y = snpret["Direction"]
# Train/test split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.8, random_state=42 )
# Create the (parametrised) models print("Hit Rates/Confusion Matrices:\n") models = [("LR", LogisticRegression()), ("LDA", LDA()), ("QDA", QDA()), ("LSVC", LinearSVC()), ("RSVM", SVC( C=1000000.0, cache_size=200, class_weight=None, coef0=0.0, degree=3, gamma=0.0001, kernel=’rbf’, max_iter=-1, probability=False, random_state=None, shrinking=True, tol=0.001, verbose=False) ), ("RF", RandomForestClassifier( n_estimators=1000, criterion=’gini’, max_depth=None, min_samples_split=2, min_samples_leaf=1, max_features=’auto’, bootstrap=True, oob_score=False, n_jobs=1, random_state=None, verbose=0) )]
# Iterate through the models for m in models:
# Train each of the models on the training set m[1].fit(X_train, y_train)
# Make an array of predictions on the test set pred = m[1].predict(X_test)
# Output the hit-rate and the confusion matrix for each model print("%s:\n%0.3f" % (m[0], m[1].score(X_test, y_test))) print("%s\n" % confusion_matrix(pred, y_test))
Da notare che è stato scelto un set di training come 80% dei dati, lasciando solo il 20% per i dati di test. Inoltre è stato specificato un random_state per randomizzare il campionamento all’interno del set dei dati. Ciò significa che i dati non sono suddivisi sequenzialmente in ordine cronologico, ma vengono invece campionati casualmente. I risultati della cross validation sul modello sono i seguenti (probabilmente sarà leggermente diverso a causa della natura della procedura di fitting):
Hit Rates/Confusion Matrices:

LR: 0.511
[[ 70	70]
 [ 419 441]]

LDA: 0.513
[[ 69	67]
 [420  444]]

QDA: 0.503
[[ 83	91]
 [406  420]]

LSVC: 0.513
[[ 69	67]
 [420  444]]

RSVM: 0.506
[[  8	13]
 [481  498]]

RF:
0.490
[[200 221]
 [289 290]]
Si può notare che i tassi di successo sono sostanzialmente inferiori a quelli rilevati nell’articolo precedentemente citato. Di conseguenza, è probabile che si possa concludere che la particolare scelta del rapporto allenamento / test porti a una visione eccessivamente ottimistica della capacità predittiva del classificatore. Il passaggio successivo consiste nell’aumentare il numero di volte in cui viene eseguita una cross validation al fine di ridurre al minimo l’eventuale overfitting. Per questo useremo la convalida incrociata k-fold. K-Fold Cross Validation Piuttosto che suddividere il set di dati in un singolo set di training e un singolo set di test, si può utilizzare una validazione incrociata k-fold per partizionare in modo casuale il set in k sottocampioni di dimensioni uguali. Per ogni iterazione (del totale k), uno dei sottocampioni viene mantenuto come un set di test, mentre i restanti k-1 sottocampioni formano un set di traning. Un modello statistico viene quindi addestrato su ciascuna delle k-fold e le sue prestazioni vengono valutate sulla specifica serie di test k-esima. Lo scopo di questo è di combinare i risultati di ciascun modello in un emsemble mediante la media dei risultati della previsione (o altro) per produrre una singola previsione. Il vantaggio principale dell’utilizzo della k-fold cross-validation è l’utilizzo di ogni predittore all’interno del set di dati originale sia per il training che per il test, almeno per una volta. Questo motiva la particolare attenzione su su come scegliere k, che ora è un altro parametro! Generalmente, si usa k = 10 ma si può anche eseguire un’altra analisi per scegliere un valore ottimale di k. Nel codice si usa il modulo cross_validation di Scikit-Learn per ottenere l’oggetto KFold relativo alla convalida incrociata k-fold. Si crea quindi un nuovo file chiamato k_fold_cross_val.py, che è una copia di train_test_split.py e modifica le importazioni aggiungendo la seguente riga:
#!/usr/bin/python
# -*- coding: utf-8 -*- # k_fold_cross_val.py

import datetime

import pandas as pd
import sklearn
from sklearn import cross_validation
from sklearn.metrics import confusion_matrix
from sklearn.svm import SVC

from create_lagged_series import create_lagged_series
Si deve quindi modificare la funzione principale __main__ rimuovendo il metodo train_test_split e sostituendolo con un’istanza di KFold. Sono necessari cinque parametri. Il primo è la lunghezza del set di dati, che in questo caso è parti a 1250 giorni. Il secondo valore è K che rappresenta il numero di folds, o sottoinsiemi, che in questo caso è 10. Il terzo valore è indices, impostato su False (quindi gli effettivi valori degli indici sono utilizzati per navigare sull’array durante iterazione. Il quarto e il quinto sono usati per randomizzare l’ordine dei campioni. Come in forecast.py e train_test_split.py si acquisisce la serie temporale S&P500. Si crea quindi una serie di vettori per i predittori (X) e le risposte (y). Quindi si utilizza l’oggetto KFold e lo effettua iterazioni su di esso. Durante ogni iterazione si crea un set di training e e un set di testing per ciascuno dei vettori X e Y. Questi vengono quindi inseriti in una SMV radiale con parametri identici ai file sopra citatiò. Infine vengono calcolati e restituiti l’Hit Rate e la Matrice di Confusione per ogni istanza di SVM.
# k_fold_cross_val.py

if  name 	== " main ":
    # Create a lagged series of the S&P500 US stock market index
    snpret = create_lagged_series(
            "^GSPC", datetime.datetime(2001,1,10), datetime.datetime(2005,12,31), lags=5
) # Use the prior two days of returns as predictor # values, with direction as the response X = snpret[["Lag1","Lag2"]] y = snpret["Direction"] # Create a k-fold cross validation object kf = cross_validation.KFold( len(snpret), n_folds=10, indices=False, shuffle=True, random_state=42 ) # Use the kf object to create index arrays that # state which elements have been retained for training
# and which elements have beenr retained for testing # for each k-element iteration for train_index, test_index in kf:
X_train = X.ix[X.index[train_index]]
X_test = X.ix[X.index[test_index]]
y_train = y.ix[y.index[train_index]]
y_test = y.ix[y.index[test_index]] # In this instance only use the # Radial Support Vector Machine (SVM)
print("Hit Rate/Confusion Matrix:") model = SVC( C=1000000.0, cache_size=200, class_weight=None, coef0=0.0, degree=3, gamma=0.0001, kernel=’rbf’,
shrinking=True, tol=0.001, verbose=False ) # Train the model on the retained training data model.fit(X_train, y_train) # Make an array of predictions on the test set pred = model.predict(X_test) # Output the hit-rate and the confusion matrix for each model print("%0.3f" % model.score(X_test, y_test)) print("%s\n" % confusion_matrix(pred, y_test))

L’output del codice è il seguente:

Hit Rate/Confusion Matrix:
0.528
[[11 10]
 [49 55]]

Hit Rate/Confusion Matrix:
0.400
[[ 2  5]
 [70 48]]

Hit Rate/Confusion Matrix:
0.528
[[ 8  8]
 [51 58]]

Hit Rate/Confusion Matrix:
0.536
[[ 6  3]
 [55 61]]

Hit Rate/Confusion Matrix:
0.512
[[ 7  5]
 [56 57]]

Hit Rate/Confusion Matrix:
0.480
[[11 11]
 [54 49]]

Hit Rate/Confusion Matrix:
0.608
[[12 13]
 [36 64]]

Hit Rate/Confusion Matrix:
0.440
[[ 8 17]
 [53 47]]

Hit Rate/Confusion Matrix:
0.560
[[10  9]
 [46 60]]

Hit Rate/Confusion Matrix:
0.528
[[ 9 11]
 [48 57]]
È chiaro che il tasso di successo e le matrici di confusione variano notevolmente tra i vari “folds”. Questo indicata la possibilità che il modello è soggetto ad overfitting su questo particolare set di dati. Per risolvere questa criticità si può usare un numero significativamente maggiore di dati, ad una frequenza più alta o per una maggiore durata. Per utilizzare questo modello in una strategia di trading sarebbe necessario combinare ciascuno di questi classificatori individualmente addestrati (vale a dire ciascuno degli oggetti K) in un insieme medio e quindi utilizzare quel modello combinato per la classificazione all’interno della strategia. Da notare che tecnicamente non è appropriato utilizzare semplici tecniche di convalida incrociata su dati ordinati (ad es. Serie temporali). Esistono meccanismi più sofisticati per analizzare l’autocorrelazione in questo modo, ma si è voluto evidenziare che questo approccio sono stati utilizzati per semplicità i dati delle serie temporali.

Grid Search

Finora è stato descritto come il k-fold cross-validation aiuta ad evitare l’overfitting nei dati eseguendo la convalida su ogni elemento del campione. Ora si vuole approfondire come ottimizzare gli iper-parametri di un particolare modello statistico. Tali parametri sono quelli non appresi direttamente dalla procedura di stima del modello. Ad esempio, C e γ per una SVM. Essenzialmente sono i parametri che si devono specificare quando si inizializza ogni modello statistico. Per questa procedura si può usare un approcio noto come grid search. L’idea base consiste nel prendere una serie di parametri e valutare le prestazioni del modello statistico su ciascun elemento del parametro all’interno dell’intervallo. Per ottenere si può creare un oggetto ​ParameterGrid tramite Scikit-Learn. Tale oggetto produce una lista di dizionari Python dove ciascuno contiene una combinazione di parametri da inserire in un modello statistico. Di seguito è riportato un frammento di esempio del codice che produce una griglia di parametri, per i parametri relativi a una SVM:
>>> from sklearn.grid_search import ParameterGrid
>>> param_grid = {’C’: [1, 10, 100, 1000], ’gamma’: [0.001, 0.0001]}
>>> list(ParameterGrid(param_grid))
[{’C’: 1, ’gamma’: 0.001},
{’C’: 1, ’gamma’: 0.0001},
{’C’: 10, ’gamma’: 0.001},
{’C’: 10, ’gamma’: 0.0001},
{’C’: 100, ’gamma’: 0.001},
{’C’: 100, ’gamma’: 0.0001},
{’C’: 1000, ’gamma’: 0.001},
{’C’: 1000, ’gamma’: 0.0001}]
Ora che si ha uno strumento adatto per generare un ParameterGrid, è necessario inserirlo in un modello statistico per cercare iterativamente un ottimale punteggio di prestazione. In questo caso si cerca di massimizzare il tasso di successo del classificatore. Il metodo GridSearchCV di Scikit-Learn consente di calcolare l’effettiva grid search. Infatti, non consente solo di calcolare una grid search standard ma consente allo stesso tempo di calcolare uno schema di convalida incrociata. Ora si crea un nuovo file, grid_search.py, che utilizza ancora una volta il create_lagged_series.py e una SVM per eseguire una grid search iperparametrica cross-validate. Per ottenere questo si deve importare le corrette librerie:
#!/usr/bin/python
# -*- coding: utf-8 -*-
# grid_search.py

import datetime
import sklearn
from sklearn import cross_validation
from sklearn.cross_validation import train_test_split
from sklearn.grid_search import GridSearchCV
from sklearn.metrics import classification_report
from sklearn.svm import SVC
from create_lagged_series import create_lagged_series
Come in precedenza per il k_fold_cross_val.py, si crea una serie ritardata e quindi utilizza come predittori i returns degli ultimi due giorni precedenti. Inizialmente si crea una suddivisione di training / test in modo tale che il 50% dei dati possono essere utilizzati per il traning e la cross-validation, mentre i dati rimanenti possono essere “tenuti fuori” per la valutazione. Successivamente si crea la lista tuned_parameters, che contiene un dizionario dei valori che si vogliono testare per ogni singolo parametro. Questo comporta la creazione di un prodotto cartesiano delle liste di tutti i parametri, cioè un elenco di coppie di ogni possibile combinazione dei parametri. Dopo ever creato l’elenco dei parametri, questo viene passato alla classe GridSearchCV, insieme al tipo di classificatore a cui si è interessati (ovvero una SVM radiale), con un valore k = 10 per il k-fold cross-validation. Infine, si forma il modello e si produce la miglior stima e i relativi punteggi di hit rate associato. In questo modo non solo si sono ottimizzati i parametri del modello tramite la validazione incrociata, ma si è anche ottimizzato gli iperparametri del modello tramite una grid search parametrizzata, tutto in una classe! Tale concisione del codice consente una significativa sperimentazione senza essere impantanati da un eccessivo “conflitto di dati”.
if __name__ == "__main__":
   # Create a lagged series of the S&P500 US stock market index
   snpret = create_lagged_series(
     "^GSPC", 
datetime.datetime(2001,1,10), datetime.datetime(2005,12,31), lags=5 )
# Use the prior two days of returns as predictor # values, with direction as the response X = snpret[["Lag1","Lag2"]] y = snpret["Direction"]
# Train/test split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.5, random_state=42 )
# Set the parameters by cross-validation tuned_parameters = [ {’kernel’: [’rbf’], ’gamma’: [1e-3, 1e-4], ’C’: [1, 10, 100, 1000]} ]
# Perform the grid search on the tuned parameters model = GridSearchCV(SVC(C=1), tuned_parameters, cv=10) model.fit(X_train, y_train)
print("Optimised parameters found on training set:") print(model.best_estimator_, "\n") print("Grid scores calculated on training set:") for params, mean_score, scores in model.grid_scores_: print("%0.3f for %r" % (mean_score, params))
L’output della procedura di grid search cross validation procedure è il seguente:
Optimised parameters found on training set:
   SVC(C=1, cache_size=200, class_weight=None, coef0=0.0, degree=3, gamma=0.001,
       kernel=’rbf’, max_iter=-1, probability=False, random_state=None,
       shrinking=True, tol=0.001, verbose=False)

Grid scores calculated on training set: 0.541 for {’kernel’: ’rbf’, ’C’: 1, ’gamma’: 0.001} 0.541 for {’kernel’: ’rbf’, ’C’: 1, ’gamma’: 0.0001} 0.541 for {’kernel’: ’rbf’, ’C’: 10, ’gamma’: 0.001} 0.541 for {’kernel’: ’rbf’, ’C’: 10, ’gamma’: 0.0001} 0.541 for {’kernel’: ’rbf’, ’C’: 100, ’gamma’: 0.001} 0.541 for {’kernel’: ’rbf’, ’C’: 100, ’gamma’: 0.0001} 0.538 for {’kernel’: ’rbf’, ’C’: 1000, ’gamma’: 0.001} 0.541 for {’kernel’: ’rbf’, ’C’: 1000, ’gamma’: 0.0001}
Come possiamo vedere, γ = 0.001 e C = 1 fornisce il miglior Hit Rate, sul set di convalida, per questa particolare SVM radiale. Questo modello potrebbe ora costituire la base di una strategia di trading basata sulle previsioni, come abbiamo descritto nel precedente articolo.