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.

Strategia di Mean Reversion per il Pairs Trading Intraday

In questo articolo si vuole descrivere la nostra prima strategia di trading intraday. Si basa su una classica idea di trading, quella delle “coppie di trading”. 

La strategia crea generalmente uno “spread” tra la coppia di asset considerati, andando long su una e short sull’altra. Il rapporto tra long e short può essere definito in molti modi, ad esempio utilizzando tecniche di cointegrazione statistica sulle serie temporali. In questo esempio si calcola il “hedge ratio” (rapporto di copertura) tra gli asset tramite una regressione lineare a rotazione. Questo permette
di creare un “spread” che viene normalizzato in uno z-score. I segnali di trading sono generati quando lo z-score supera una determinata soglia, nella convinzione che lo spread tornerà verso la media.

La logica della strategia prevede che gli asset considerati siano approssimativamente caratterizzate dallo stesso comportamento o andamento. L’idea base consiste nel considerare che lo spread dei prezzi ha un comportamento mean-reverting, dal momento che eventi “locali” (nel tempo) possono influenzare separatamente i singoli asset (come differenze di capitalizzazione, date di ribilanciamento o operazioni di blocco) ma nel lungo termine le serie di prezzi tendono ad essere cointegrate.

La Strategia

Al fine di ottenere Sharpe Ratio più alti, è necessario adottare strategie intraday ad alta frequenza. Il primo importante problema è ottenere dati significativi, dato che i dati intraday di alta qualità non sono solitamente gratuiti. Come descritto negli articoli precedetti, si utilizza il DTN IQFeed per acquisire le barre intraday al minuto e quindi si avrà bisogno di un account DTN per ottenere i dati richiesti per questa strategia. Il secondo problema consiste nel fatto che le simulazioni di backtesting impiegano molto più tempo, specialmente con il modello event-driven, descritto in questa serie di articoli. Se si vuol effettuare il backtesting di un portafolio diversificato con dati al minuto un numero significativo di anni passati, e quindi eseguire qualsiasi ottimizzazione dei parametri, ci si rende rapidamente conto che le simulazioni possono richiedere ore o persino giorni, se effettatu su modermo PC desktop. Questo dovrà essere preso in considerazione nel durante il processo di ricerca e studio della strategia. Il terzo problema è la completa automazione dell’esecuzione nel live trading poiché ci si sta avvicinando al trading ad alta frequenza e quindi il sistema di esecuzione dove essere altamente performante. Questo significa che l’ambiente e il codice di esecuzione devono essere altamente affidabili e privi di errori, altrimenti potrebbe verificarsi significative perdite. Questa strategia espande la precedente strategia multiday al fine di utilizzare i dati intraday. In particolare, si usa barre OHLCV al minuto, a differenza di dati OHLCV giornalieri. Le regole per la strategia sono semplici:
  1. Identificare una coppia di titoli azionari le cui serie temporali hanno statisticamente un comportamento riconducile al mean-reverting. In questo caso, si considerano i due titoli azionari statunitensi con i ticker AREX e WLL.
  2. Creare le serie temporali residue della coppia eseguendo una regressione lineare a rotazione, per una specifica finestra di ricerca, tramite l’algoritmo dei minimi quadrati ordinari (OLS). Questo periodo di ricerca è un parametro da ottimizzare.
  3. Creare un z-score a rotazione delle serie temporali residue per lo stesso periodo di ricerca e utilizzarlo per determinare le soglie di ingresso / uscita per i segnali di trading.
  4. Se la soglia superiore viene superata quando non si è sul mercato, allora si ENTRA a mercato (long o short dipende dalla direzione di rottura della soglia viene). Se invece viene superata la soglia inferiore quando si ha una posizione a mercato, allora si ESCE dal mercato. Anche le soglie superiore e inferiore sono parametri da ottimizzare.
In effetti si potrebbe usare il test Cointegrated Augmented Dickey-Fuller (CADF) per identificare un parametro di copertura ancora più accurato. Questo potrebbe essere un’interessate evoluzione di questa strategia.

Implementazione in Python

Come per tutti i tutorial con Python e Pandas, è necessario aver impostato un ambiente di backtesting con Python, come descritto in questo tutorial. Una volta impostato, il primo passo è importare le necessarie librerie Python. Per questo backtest sono richiesti matplotlib e pandas. In particolare si utilizza metodo rolling_apply, al fine di applicare il calcolo dello z-score ad una finestra di ricerca a rotazione. Si importa statsmodels perché fornisce un mezzo per calcolare l’algoritmo dei minimi quadrati ordinari (OLS) per la regressione lineare, al fine di  ottenere il rapporto di copertura per la costruzione dei residui. Si prevede inoltre un DataHandler e un Portfolio leggermente modificati per effettuare operazioni di trading al minuto sui dati DTN IQFeed. Per creare questi file si può semplicemente copiare tutto il codice di portfolio.py e data.py rispettivamente nei nuovi file hft_portfolio.py e hft_data.py e quindi modificare le sezioni necessarie, che illustrato di seguito.
# intraday_mr.py

import datetime
import numpy as np
import pandas as pd
import statsmodels.api as sm

from strategy import Strategy
from event import SignalEvent
from backtest import Backtest

from hft_data import HistoricCSVDataHandlerHFT
from hft_portfolio import PortfolioHFT

from execution import SimulatedExecutionHandler
Con il seguente codice si crea la classe IntradayOLSMRStrategy, derivata dalla classe base astratta di Strategy. Il metodo __init__ del costruttore richiede l’accesso al provider di dati storici, alla coda degli eventi, a una soglia zscore_low e a una soglia zscore_high, utilizzate per determinare quando la serie residua tra le due coppie è di tipo mean-reverting. Inoltre, si specifica la finestra di ricerca OLS (impostata su 100), che è un parametro soggetto a potenziale ottimizzazione. All’inizio della simulazione non si è long o short sul mercato, quindi si imposta sia self.long_market che self.short_market uguale a False:
# intraday_mr.py

class IntradayOLSMRStrategy(Strategy):
    """
    Uses ordinary least squares (OLS) to perform a rolling linear
    regression to determine the hedge ratio between a pair of equities.
    The z-score of the residuals time series is then calculated in a
    rolling fashion and if it exceeds an interval of thresholds
    (defaulting to [0.5, 3.0]) then a long/short signal pair are generated
    (for the high threshold) or an exit signal pair are generated (for the
    low threshold).
    """

    def __init__(
        self, bars, events, ols_window=100,
        zscore_low=0.5, zscore_high=3.0
    ):
        """
        Initialises the stat arb strategy.
        Parameters:
        bars - The DataHandler object that provides bar information
        events - The Event Queue object.
        """

        self.bars = bars
        self.symbol_list = self.bars.symbol_list
        self.events = events
        self.ols_window = ols_window
        self.zscore_low = zscore_low
        self.zscore_high = zscore_high
        self.pair = (’AREX’, ’WLL’)
        self.datetime = datetime.datetime.utcnow()
        self.long_market = False
        self.short_market = False
Il seguente metodo, calculate_xy_signals, prende lo zscore corrente (dal calcolo rolling eseguito di seguito) e determina se è necessario generare nuovi segnali di trading. Questi segnali vengono resi disponibili in output. Ci sono quattro stati potenziali a cui si può essere interessati:
  1. Long sul mercato e sotto la più alta soglia negativa dello zscore
  2. Long il mercato e all’interno del valore assoluto della soglia più alta dello zscore
  3. Short sul mercato e sopra la maggiore soglia positiva dello z-score
  4. Short sul mercato e all’interno tra il valore assoluto del valore assoluto della soglia inferiore più bassa dello zscore.
In tutti i casi è necessario generare due segnali, uno per la prima componente della coppia (AREX) e uno per la seconda componente della coppia (WLL). Se nessuna di queste condizioni viene soddisfatta, si restituisce una coppia di valori None:
# intraday_mr.py

def calculate_xy_signals(self, zscore_last):
    """
    Calculates the actual x, y signal pairings
    to be sent to the signal generator.
    Parameters
    zscore_last - The current zscore to test against
    """

    y_signal = None
    x_signal = None
    p0 = self.pair[0]
    p1 = self.pair[1]
    dt = self.datetime
    hr = abs(self.hedge_ratio)

    # If we’re long the market and below the
    # negative of the high zscore threshold
    if zscore_last <= -self.zscore_high and not self.long_market:
        self.long_market = True
        y_signal = SignalEvent(1, p0, dt, ’LONG’, 1.0)
        x_signal = SignalEvent(1, p1, dt, ’SHORT’, hr)

    # If we’re long the market and between the
    # absolute value of the low zscore threshold
    if abs(zscore_last) <= self.zscore_low and self.long_market:
        self.long_market = False
        y_signal = SignalEvent(1, p0, dt, ’EXIT’, 1.0)
        x_signal = SignalEvent(1, p1, dt, ’EXIT’, 1.0)

    # If we’re short the market and above
    # the high zscore threshold
    if zscore_last >= self.zscore_high and not self.short_market:
        self.short_market = True
        y_signal = SignalEvent(1, p0, dt, ’SHORT’, 1.0)
        x_signal = SignalEvent(1, p1, dt, ’LONG’, hr)

    # If we’re short the market and between the
    # absolute value of the low zscore threshold
    if abs(zscore_last) <= self.zscore_low and self.short_market:
        self.short_market = False
        y_signal = SignalEvent(1, p0, dt, ’EXIT’, 1.0)
        x_signal = SignalEvent(1, p1, dt, ’EXIT’, 1.0)

    return y_signal, x_signal
Il seguente metodo, calculate_signals_for_pairs acquisisce l’ultimo set di barre per ogni componente della coppia (in questo caso 100 barre) e le utilizza per costruire una regressione lineare basata su minimi quadrati ordinari. Ciò consente l’identificazione del rapporto di copertura, necessario per la costruzione delle serie temporali residue. Una volta ricavato il rapporto di copertura, si crea lo spread delle serie di residui. Il passo successivo consiste nel calcolare l’ultimo z-score dalle serie residue sottraendo la loro media e dividendo per la loro deviazione standard nel periodo di ricerca. Infine, y_signal e x_signal sono calcolati sulla base di questo z-score. Se i segnali non sono entrambi None, le istanze SignalEvent vengono inviate alla coda degli eventi:
# intraday_mr.py

def calculate_signals_for_pairs(self):
    """
    Generates a new set of signals based on the mean reversion
    strategy.
    Calculates the hedge ratio between the pair of tickers.
    We use OLS for this, althought we should ideall use CADF.
    """

    # Obtain the latest window of values for each
    # component of the pair of tickers
    y = self.bars.get_latest_bars_values(
        self.pair[0], "close", N=self.ols_window
    )
    x = self.bars.get_latest_bars_values(
        self.pair[1], "close", N=self.ols_window
    )

    if y is not None and x is not None:
        # Check that all window periods are available
        if len(y) >= self.ols_window and len(x) >= self.ols_window:
            # Calculate the current hedge ratio using OLS
            self.hedge_ratio = sm.OLS(y, x).fit().params[0]
            
            # Calculate the current z-score of the residuals
            spread = y - self.hedge_ratio * x
            zscore_last = ((spread - spread.mean())/spread.std())[-1]

            # Calculate signals and add to events queue
            y_signal, x_signal = self.calculate_xy_signals(zscore_last)
            if y_signal is not None and x_signal is not None
                self.events.put(y_signal)
                self.events.put(x_signal)
Il metodo finale, calculate_signals è sovrascritto dalla classe base e viene utilizzato per verificare se un evento ricevuto dalla coda è in realtà un MarketEvent, nel qual caso viene eseguito il calcolo dei nuovi segnali:
# intraday_mr.py

def calculate_signals(self, event):
    """
    Calculate the SignalEvents based on market data.
    """

    if event.type == ’MARKET’:
        self.calculate_signals_for_pairs()
La funzione __main__ collega insieme i componenti al fine di eseguire il backtesting di una strategia. Si specifica dove sono archiviati i dati al minuto dei ticker, utilizzando il formato dei simboli IQFeed DTN. Necessariamente i file sono stati modificati in modo tale che iniziano e finiscono sullo stesso minuto. Per questa particolare coppia di AREX e WLL, la data comune di inizio è l’8 novembre 2007 alle 10:41:00. Infine, si costruisce l’oggetto backtest e si inizia a simulare il trading:
# intraday_mr.py

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)

    backtest = Backtest(
        csv_dir, symbol_list, initial_capital, heartbeat,start_date, 
        HistoricCSVDataHandlerHFT, SimulatedExecutionHandler,
        PortfolioHFT, IntradayOLSMRStrategy
    )
    backtest.simulate_trading()
Tuttavia, prima di poter eseguire questo codice, è necessario apportare alcune modifiche al gestore dati e all’oggetto portfolio. In particolare, è necessario creare nuovi file hft_data.py e hft_portfolio.py che sono rispettivamente copie di data.py e portfolio.py. In hft_data.py si deve rinominare HistoricCSVDataHandler in HistoricCSVDataHandlerHFT e sostituire l’elenco dei nomi nel metodo _open_convert_csv_files. Il vecchio codice è:
names=[
       ’datetime’, ’open’, ’high’,
       ’low’, ’close’, ’volume’, ’adj_close’
      ]
Deve essere sostituito con il seguente:
names=[
       ’datetime’, ’open’, ’low’,
       ’high’, ’close’, ’volume’, ’oi’
      ]
In questo modo si garantisce la compatibilità tra il nuovo formato di DTN IQFeed e il sistema di backtesting. L’altro cambiamento consiste nel rinominare Portfolio in PortfolioHFT all’interno di hft_portfolio.py. Si deve quindi modificare alcune righe per tenere conto della frequenza minima dei dati DTN. In particolare, all’interno del metodo update_timeindex, è necessario modificare il seguente codice:
for s in self.symbol_list:
    # Approximation to the real value
    market_value = self.current_positions[s] * \
        self.bars.get_latest_bar_value(s, "adj_close")
    dh[s] = market_value
    dh[’total’] += market_value
Per farlo diventare come segue:
for s in self.symbol_list:
    # Approximation to the real value
    market_value = self.current_positions[s] * \
        self.bars.get_latest_bar_value(s, "close")
    dh[s] = market_value
    dh[’total’] += market_value
Questo assicura di considerare il prezzo di chiusura, piuttosto che il prezzo di adj_close. Quest’ultimo è utilizzato da Yahoo Finance, mentre il primo è di DTN IQFeed. Si deve inoltre prevedere un aggiustamento simile in update_holdings_from_fill. Si deve cambiare il seguente codice:
# Update holdings list with new quantities
    fill_cost = self.bars.get_latest_bar_value(
        fill.symbol, "adj_close"
    )
come segue:
# Update holdings list with new quantities
    fill_cost = self.bars.get_latest_bar_value(
        fill.symbol, "close"
    )
La modifica finale si effettua nel metodo output_summary_stats, nella parte inferiore del file. Si deve modificare il metodo di calcolato del Sharpe Ratio per tenere conto del trading con barre al minuto. La seguente riga:
sharpe_ratio = create_sharpe_ratio(returns)
Viene modificata come:
sharpe_ratio = create_sharpe_ratio(returns, periods=252*6.5*60)
Dopo aver mandato in esecuzione il file intraday_mr.py si ottiene il seguente output (troncato) dalla simulazione di backtest:
..
..
375072
375073
Creating summary stats...
Creating equity curve...
                    AREX WLL cash commission total returns \
datetime
2014-03-11 15:53:00 2098 -6802 120604.3 9721.4 115900.3 -0.000052
2014-03-11 15:54:00 2101 -6799 120604.3 9721.4 115906.3 0.000052
2014-03-11 15:55:00 2100 -6802 120604.3 9721.4 115902.3 -0.000035
2014-03-11 15:56:00 2097 -6810 120604.3 9721.4 115891.3 -0.000095
2014-03-11 15:57:00 2098 -6801 120604.3 9721.4 115901.3 0.000086
2014-03-11 15:58:00 2098 -6800 120604.3 9721.4 115902.3 0.000009
2014-03-11 15:59:00 2099 -6800 120604.3 9721.4 115903.3 0.000009
2014-03-11 16:00:00 2100 -6801 120604.3 9721.4 115903.3 0.000000
2014-03-11 16:01:00 2100 -6801 120604.3 9721.4 115903.3 0.000000
2014-03-11 16:01:00 2100 -6801 120604.3 9721.4 115903.3 0.000000

                    equity_curve drawdown
datetime
2014-03-11 15:53:00 1.159003 0.003933
2014-03-11 15:54:00 1.159063 0.003873
2014-03-11 15:55:00 1.159023 0.003913
2014-03-11 15:56:00 1.158913 0.004023
2014-03-11 15:57:00 1.159013 0.003923
2014-03-11 15:58:00 1.159023 0.003913
2014-03-11 15:59:00 1.159033 0.003903
2014-03-11 16:00:00 1.159033 0.003903
2014-03-11 16:01:00 1.159033 0.003903
2014-03-11 16:01:00 1.159033 0.003903

[(’Total Return’, ’15.90%’),
 (’Sharpe Ratio’, ’1.89’),
 (’Max Drawdown’, ’3.03%’),
 (’Drawdown Duration’, ’120718’)]
Signals: 7594
Orders: 7478
Fills: 7478
Si nota facilmente che la strategia si comporta bene durante il periodo sotto esame. Ha un rendimento totale di poco inferiore al 16%. Il Sharpe Ratio ragionevole (se confrontato con una tipica strategia giornaliera), ma data la natura ad alta frequenza della strategia ci si dovrebbe aspettare di più. La migliore caratteristica di questa strategia è il basso drawdown massimo(circa il 3%). Questo suggerisce di poter applicare una leva maggiore per ottenere più profitto.

Visualizzazione grafica delle Performance

Si può facilmente visualizzare il grafico dei rendimenti risposto all’intervallo di ricerca (numero di barre) e tutte le altre misure delle performance usando lo script plot_performance.py. Tale codice può essere utilizzato come base per creare grafici personalizzati delle prestazioni.

È necessario eseguirlo nella stessa directory del file di output dal backtest, ovvero dove risiede equity.csv. Il codice è il seguente:

# plot_performance.py

import os.path
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd

if __name__ == "__main__":
    data = pd.io.parsers.read_csv(
        "equity.csv", header=0,
        parse_dates=True, index_col=0
    ).sort()

    # Plot three charts: Equity curve,
    # period returns, drawdowns
    fig = plt.figure()
    
    # Set the outer colour to white
    fig.patch.set_facecolor(’white’)

    # Plot the equity curve
    ax1 = fig.add_subplot(311, ylabel=’Portfolio value, %’)
    data[’equity_curve’].plot(ax=ax1, color="blue", lw=2.)
    plt.grid(True)

    # Plot the returns
    ax2 = fig.add_subplot(312, ylabel=’Period returns, %’)
   data[’returns’].plot(ax=ax2, color="black", lw=2.)
    plt.grid(True)

    # Plot the returns
    ax3 = fig.add_subplot(313, ylabel=’Drawdowns, %’)
    data[’drawdown’].plot(ax=ax3, color="red", lw=2.)
    plt.grid(True)

    # Plot the figure
    plt.show()
Utilizzando l’output CSV del backtesting della precedente strategia si ottengono i seguenti grafici:
Fig - Curva di Equity, Ritorni Giornalieri e Drawdown per la strategia mean-reversion intraday.

Strategia di Forecasting sul S&P500, backtesting con Python e Pandas

Recentemente su DataTrading abbiamo introdotto il machine learning, il forecasting e la progettazione e l’implementazione del backtesting di una strategia. In questo articolo si combinano tutti questi strumenti al fine di testare un algoritmo di previsione finanziaria per l’indice azionario statunitense S&P500 tramite lo strumento l’ETF (SPY).

Questo articolo si basa per la maggior parte sul software che abbiamo già sviluppato negli articoli menzionati sopra, incluso un motore di backtesting orientato agli oggetti e il generatore di segnali. La  programmazione orientata agli oggetti permette Permette di estendere una classe, facendole ereditare le proprietà di un’altra classe e ridefinendone altri (overriding). L’ereditarietà ci permette di vedere le relazioni di parentela tra classi che ereditano dalla stessa superclasse, come un albero radicato.

Inoltre le librerie Python come matplotlib, pandas e scikit-learn riducono la necessità di scrivere codice da zero o effettuare nuove implementazioni di algoritmi già noti e ampiamente testati.

La Strategia di Forecasting

Questa strategia di previsione si basa su una tecnica di apprendimento automatico nota come Analisi Discriminante Quadratica, che è strettamente correlata all’Analisi Discriminante Lineare. Entrambi questi modelli sono descritti nell’articolo relativo alla previsione delle serie temporali finanziarie.

Il forecaster utilizza i dati dei deu giorni precedenti come un set di fattori per prevedere la direzione odierna del mercato azionario. Se la probabilità che il giorno sia “Up” è maggiore del 50%, la strategia acquista 500 shares dell’ETF SPY e vende a fine della giornata, mentre se la probabilità di un giorno “Down” è maggiore del 50%, la strategia vende 500 azioni dell’ETF SPY e poi riacquista alla chiusura. Quindi è un semplice esempio di una strategia di trading intraday.

Nota che questa non è una strategia di trading particolarmente realistica! È improbabile che riusciremo mai a raggiungere un prezzo di apertura o di chiusura a causa di molti fattori quali l’eccessiva volatilità di apertura, il routing degli ordini da parte dell’intermediario e le potenziali criticità di liquidità durante l’apertura / chiusura. Inoltre non abbiamo incluso i costi di transazione. Questi sarebbero probabilmente una percentuale sostanziale dei rendimenti in quanto ci sono operazioni di apertura e chiusura posizioni ogni giorno. Pertanto, il nostro forecaster deve essere relativamente preciso nel prevedere i rendimenti giornalieri, altrimenti i costi di transazione mangeranno tutti i nostri profitti.

Implementazione

Come per gli altri esempi relativi a Python/Pandas, si usano le seguenti librerie:

  • Python
  • NumPy
  • Pandas
  • Matplotlib
  • Scikit-learn

L’implementazione di snp_forecast.py prevede di utilizzare backtest.py, descritto in questo tutorial. Inoltre è necessario importare forecast.py (che contiene principalmente la funzione create_lagged_series), implementato in questo precedente tutorial. Il primo passo è importare i necessari moduli ed oggetti:

# snp_forecast.py

import datetime
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import sklearn

from pandas.io.data import DataReader
from sklearn.qda import QDA

from backtest import Strategy, Portfolio
from forecast import create_lagged_series

Una volta incluse tutte le librerie e i moduli pertinenti, è tempo di creare una sottoclasse della classe base astratta Strategy, come abbiamo fatto in precedenti tutorial. SNPForecastingStrategy è progettata per implementare un Analizzatore Discriminante Quadratico per l’indice azionario S&P500, come metodo per predire il suo valore futuro. L’addestramento del modello viene eseguito tramite il metodo fit_model, mentre i segnali effettivi vengono generati dal metodo generate_signals. Questo corrisponde all’interfaccia di una classe Strategy.

I dettagli su come funziona un analizzatore discriminante quadratico, così come la seguente l’implementazione in Python, sono descritti in dettaglio nel precedente articolo relativo alla previsione delle serie temporali finanziarie. I commenti nel seguente codice sorgente descrivono ampiamente le funzionalità del programma:

# snp_forecast.py

class SNPForecastingStrategy(Strategy):
    """    
    Requires:
    symbol - A stock symbol on which to form a strategy on.
    bars - A DataFrame of bars for the above symbol."""

    def __init__(self, symbol, bars):
        self.symbol = symbol
        self.bars = bars
        self.create_periods()
        self.fit_model()

    def create_periods(self):
        """Create training/test periods."""
        self.start_train = datetime.datetime(2001,1,10)
        self.start_test = datetime.datetime(2005,1,1)
        self.end_period = datetime.datetime(2005,12,31)

    def fit_model(self):
        """Fits a Quadratic Discriminant Analyser to the
        US stock market index (^GPSC in Yahoo)."""
        # Create a lagged series of the S&P500 US stock market index
        snpret = create_lagged_series(self.symbol, self.start_train, 
                                      self.end_period, 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 training and test sets
        X_train = X[X.index < self.start_test]
        y_train = y[y.index < self.start_test]

        # Create the predicting factors for use 
        # in direction forecasting
        self.predictors = X[X.index >= self.start_test]

        # Create the Quadratic Discriminant Analysis model
        # and the forecasting strategy
        self.model = QDA()
        self.model.fit(X_train, y_train)

    def generate_signals(self):
        """Returns the DataFrame of symbols containing the signals
        to go long, short or hold (1, -1 or 0)."""
        signals = pd.DataFrame(index=self.bars.index)
        signals['signal'] = 0.0       

        # Predict the subsequent period with the QDA model
        signals['signal'] = self.model.predict(self.predictors)

        # Remove the first five signal entries to eliminate
        # NaN issues with the signals DataFrame
        signals['signal'][0:5] = 0.0
        signals['positions'] = signals['signal'].diff() 

        return signals
Ora che il motore di previsione è in grado di produrre i segnali, è necessario creare MarketIntradayPortfolio. Questo oggetto si differenzia dall’esempio fornito nell’articolo “Backtesting di una Strategia di Moving Average Crossover in Python con Pandas” in quanto svolge operazioni su base intraday. Il portafoglio è progettato per “andare long” (acquistare) 500 azioni di SPY al prezzo di apertura se il segnale indica che si verificherà un giorno UP e poi vendere alla chiusura. Viceversa, il portafoglio è progettato per “andare short” (vendere) 500 azioni di SPY se il segnale indica che si verificherà un giorno DOWN e successivamente chiudere la posizione (riacquistare) al prezzo di chiusura. Per raggiungere questo obiettivo, è necessario calcolare ogni giorno la differenza tra i prezzi di apertura del mercato aperto e i prezzi di chiusura del mercato, determinando il calcolo del profitto giornaliero sulle 500 azioni acquistate o vendute. Questo comporta quindi la costruzione di una curva equity formata dalla somma cumulata dei profitti/perditi per ogni giorno. Ha anche il vantaggio di poter facilmente calcolare le statistiche relative ai profitti / perdite di ogni giorno. Di seguito il codice per la classe MarketIntradayPortfolio:
# snp_forecast.py

class MarketIntradayPortfolio(Portfolio):
    """Buys or sells 500 shares of an asset at the opening price of
    every bar, depending upon the direction of the forecast, closing 
    out the trade at the close of the bar.

    Requires:
    symbol - A stock symbol which forms the basis of the portfolio.
    bars - A DataFrame of bars for a symbol set.
    signals - A pandas DataFrame of signals (1, 0, -1) for each symbol.
    initial_capital - The amount in cash at the start of the portfolio."""

    def __init__(self, symbol, bars, signals, initial_capital=100000.0):
        self.symbol = symbol        
        self.bars = bars
        self.signals = signals
        self.initial_capital = float(initial_capital)
        self.positions = self.generate_positions()
        
    def generate_positions(self):
        """Generate the positions DataFrame, based on the signals
        provided by the 'signals' DataFrame."""
        positions = pd.DataFrame(index=self.signals.index).fillna(0.0)

        # Long or short 500 shares of SPY based on 
        # directional signal every day
        positions[self.symbol] = 500*self.signals['signal']
        return positions
                    
    def backtest_portfolio(self):
        """Backtest the portfolio and return a DataFrame containing
        the equity curve and the percentage returns."""

        # Set the portfolio object to have the same time period
        # as the positions DataFrame
        portfolio = pd.DataFrame(index=self.positions.index)
        pos_diff = self.positions.diff()

        # Work out the intraday profit of the difference
        # in open and closing prices and then determine
        # the daily profit by longing if an up day is predicted
        # and shorting if a down day is predicted        
        portfolio['price_diff'] = self.bars['Close']-self.bars['Open']
        portfolio['price_diff'][0:5] = 0.0
        portfolio['profit'] = self.positions[self.symbol] * portfolio['price_diff']

        # Generate the equity curve and percentage returns
        portfolio['total'] = self.initial_capital + portfolio['profit'].cumsum()
        portfolio['returns'] = portfolio['total'].pct_change()
        return portfolio
Il passaggio finale consiste nel legare gli oggetti Strategy e Portfolio tramite una funzione __main__. La funzione ottiene i dati per lo strumento SPY e quindi crea la strategia per la generazione del segnale sull’indice S&P500. Questa viene effettuato tramite il simbolo ^ GSPC. Inoltre si crea un’instanza di un MarketIntradayPortfolio con un capitale iniziale di 100.000 USD (come nei tutorial precedenti). Infine, si calcolano i rendimenti e si traccia la curva di equity. Da notare che il codice richiesto in questa fase è abbastanza ridotto perché gran parte del lavoro viene svolto dalle sottoclassi di Strategy e Portfolio. Ciò rende estremamente semplice creare nuove strategie di trading e testarle rapidamente per l’utilizzo nella “strategy pipeline”.
if __name__ == "__main__":
    start_test = datetime.datetime(2005,1,1)
    end_period = datetime.datetime(2005,12,31)

    # Obtain the bars for SPY ETF which tracks the S&P500 index    
    bars = DataReader("SPY", "yahoo", start_test, end_period)
    
    # Create the S&P500 forecasting strategy
    snpf = SNPForecastingStrategy("^GSPC", bars)
    signals = snpf.generate_signals()

    # Create the portfolio based on the forecaster
    portfolio = MarketIntradayPortfolio("SPY", bars, signals,              
                                        initial_capital=100000.0)
    returns = portfolio.backtest_portfolio()

    # Plot results
    fig = plt.figure()
    fig.patch.set_facecolor('white')

    # Plot the price of the SPY ETF
    ax1 = fig.add_subplot(211,  ylabel='SPY ETF price in $')
    bars['Close'].plot(ax=ax1, color='r', lw=2.)

    # Plot the equity curve
    ax2 = fig.add_subplot(212, ylabel='Portfolio value in $')
    returns['total'].plot(ax=ax2, lw=2.)

    fig.show()
Di seguito è riportato l’output del programma. Nel periodo sotto esame, il mercato azionario ha guadagnato il 4% (ipotizzando una strategia di “buy and hold” di investimento), e anche lo stesso l’algoritmo ha generato un rendimento del 4%. Si noti che i costi di transazione (come le commissioni) non sono stati considerati in questo sistema di backtesting. Dal momento che la strategia effettua due operazioni ogni giorno, è probabile che tali commissioni riducano significativamente i rendimenti.
Performance della Strategia di Forecasting sull'S&P500 dal 01-01-2005 al 31-12-2006
Negli articoli successivi vedremo come migliorare questo algoritmo implementando costi di transazione più realistici, utilizzando motori di previsione avanzati, e fornire strumenti per l’ottimizzazione del portafoglio.

Ambiente di Backtesting Even-Driven con Python – Parte VIII

È passato un po ‘di tempo da quando abbiamo la serie di articoli realtivi ad un ambiente di backtesting basato sugli eventi, che abbiamo iniziato a discutere in questo articolo. Nella Parte VI è stato descritto come implementare un modello di ExecutionHandler funzionante per una simulazione storica di backtesting. In questo si vuole implementare il gestore dell’API di Interactive Brokers in modo da poter utilizzare l’ExecutionHandler per il live trading. In precedenza abbiamo visto come come scaricare Trader Workstation e creare un account demo di Interactive Brokers e su come creare un’interfaccia di base verso l’API IB usando IbPy. Questo articolo descrivere come collegare l’interfaccia IbPy all’interno di un sistema event-driven, in modo tale che, quando accoppiato con un feed di dati di mercato real-time, costituirà la base per un sistema di esecuzione automatizzato. L’idea alla base della classe IBExecutionHandler consiste nel ricevere istanze OrderEvent dalla coda degli eventi ed eseguirli direttamente verso l’API di ordine di Interactive Brokers utilizzando la libreria IbPy. La classe gestirà anche i messaggi “Server Response” inviati in rispota dalla stessa API. In questa fase, l’unica azione intrapresa sarà creare le corrispondenti istanze FillEvent corrispondenti che verranno quindi ritrasferite nella coda degli eventi. La stessa classe può essere facilmente resa più complessa, includendo una logica di ottimizzazione dell’esecuzione e una sofisticata gestione degli errori. Tuttavia, in questa fase è opportuno mantenerla relativamente semplice in modo che si possa capire le principali funzionalità ed estenderle nella direzione che si adatta al tuo particolare stile di trading.

Implementazione in Python

Come sempre, il primo passo è creare il file Python e importare le librerie necessarie. Il file si chiama ib_execution.py e risiede nella stessa directory degli altri file event-driven. Importiamo le necessarie librerie per la gestione della data / ora, gli oggetti IbPy e i specifici oggetti Event gestiti da IBExecutionHandler:
# ib_execution.py

import datetime
import time

from ib.ext.Contract import Contract
from ib.ext.Order import Order
from ib.opt import ibConnection, message

from event import FillEvent, OrderEvent
from execution import ExecutionHandler
A questo punto è necessario definire la classe IBExecutionHandler. Innanzitutto il costruttore __init__ richiede in input la coda degli eventi. Prevende inoltre la specifica di order_routing, che viene impostata a “SMART” come default. Nel caso l’exchange abbia specifici requisiti, questi possono essere specificati in questo costruttore. Inoltre la currency predefinita è stata impostata sui Dollari USA. All’interno del metodo si crea un dizionario fill_dict, necessario per l’utilizzo nella generazione delle istanze di FillEvent. Si prevede anche un oggetto di connessione tws_conn per archiviare le informazioni di connessione verso l’API di Interactive Brokers. Inoltre si crea un order_id iniziale, che tiene traccia di tutti gli ordini successivi per evitare duplicati. Infine si registra il gestore dei messaggi (che sarà definito dettagliatamente più avanti):
# ib_execution.py

class IBExecutionHandler(ExecutionHandler):
    """
    Handles order execution via the Interactive Brokers
    API, for use against accounts when trading live
    directly.
    """

    def __init__(self, events, 
                 order_routing="SMART", 
                 currency="USD"):
        """
        Initialises the IBExecutionHandler instance.
        """
        self.events = events
        self.order_routing = order_routing
        self.currency = currency
        self.fill_dict = {}

        self.tws_conn = self.create_tws_connection()
        self.order_id = self.create_initial_order_id()
        self.register_handlers()

L’API di IB utilizza un sistema di eventi basato sui messaggi che consente alla nostra classe di rispondere in modo specifico a determinati messaggi, in analogia allo stesso ambiente di backtesing event-driven stesso. Non si include nessuna gestione degli errori reali (a fini di brevità), ad eccezione dell’output al terminale tramite il metodo _error_handler.

Il metodo _reply_handler, d’altra parte, viene utilizzato per determinare se è necessario creare un’istanza FillEvent. Il metodo verifica se è stato ricevuto un messaggio “openOrder” e controlla se è presente una voce fill_dict relativa a questo particolare orderId. In caso contrario, ne viene creata una.

Inoltre, se il metodo verifica la presenta di un messaggio “orderStatus” e nel caso quel particolare messaggio indichi che un ordine è stato eseguito, allora richiama la funzione create_fill per creare un FillEvent. Si invia anche un messaggio al terminale per scopi di logging / debug:

# ib_execution.py
    
    def _error_handler(self, msg):
        """
        Handles the capturing of error messages
        """
        # Currently no error handling.
        print "Server Error: %s" % msg

    def _reply_handler(self, msg):
        """
        Handles of server replies
        """
        # Handle open order orderId processing
        if msg.typeName == "openOrder" and \
            msg.orderId == self.order_id and \
            not self.fill_dict.has_key(msg.orderId):
            self.create_fill_dict_entry(msg)
        # Handle Fills
        if msg.typeName == "orderStatus" and \
            msg.status == "Filled" and \
            self.fill_dict[msg.orderId]["filled"] == False:
            self.create_fill(msg)      
        print "Server Response: %s, %s\n" % (msg.typeName, msg)
Il seguente metodo, create_tws_connection, crea una connessione all’API di IB usando l’oggetto ibConnection di IbPy. Utilizza la porta predefinita 7496 e un clientId predefinito a 10. Una volta creato l’oggetto, viene richiamato il metodo di connessione per eseguire la connessione:
# ib_execution.py
    
    def create_tws_connection(self):
        """
        Connect to the Trader Workstation (TWS) running on the
        usual port of 7496, with a clientId of 10.
        The clientId is chosen by us and we will need 
        separate IDs for both the execution connection and
        market data connection, if the latter is used elsewhere.
        """
        tws_conn = ibConnection()
        tws_conn.connect()
        return tws_conn
Per tenere traccia degli ordini separati (ai fini del tracciamento degli eseguiti) viene utilizzato il metodo create_initial_order_id. E’ stato impostato su “1”, ma un approccio più sofisticato prevede la gestione della query IB per conoscere ed utilizzare l’ultimo ID disponibile. Si può sempre reimpostare l’ID dell’ordine corrente dell’API tramite il pannello Trader Workstation –> Configurazione globale –> Impostazioni API:
# ib_execution.py
    
    def create_initial_order_id(self):
        """
        Creates the initial order ID used for Interactive
        Brokers to keep track of submitted orders.
        """
        # There is scope for more logic here, but we
        # will use "1" as the default for now.
        return 1
Il seguente metodo, register_handlers, registra semplicemente i metodi per la gestione degli errori e delle risposte, definiti in precedenza con la connessione TWS:
# ib_execution.py
    
    def register_handlers(self):
        """
        Register the error and server reply 
        message handling functions.
        """
        # Assign the error handling function defined above
        # to the TWS connection
        self.tws_conn.register(self._error_handler, 'Error')

        # Assign all of the server reply messages to the
        # reply_handler function defined above
        self.tws_conn.registerAll(self._reply_handler)
Come descritto nel precedente tutorial relativo all’uso di IbPy, si deve creare un’istanza di Contract ed associarla a un’istanza di Order, che verrà inviata all’API di IB. Il seguente metodo, create_contract, genera la prima componente di questa coppia. Si aspetta in input un simbolo ticker, un tipo di sicurezza (ad esempio, azioni o futures), un exchange primario e una valuta. Restituisce l’istanza di Contract:
# ib_execution.py
    
    def create_contract(self, symbol, sec_type, exch, prim_exch, curr):
        """
        Create a Contract object defining what will
        be purchased, at which exchange and in which currency.

        symbol - The ticker symbol for the contract
        sec_type - The security type for the contract ('STK' is 'stock')
        exch - The exchange to carry out the contract on
        prim_exch - The primary exchange to carry out the contract on
        curr - The currency in which to purchase the contract
        """
        contract = Contract()
        contract.m_symbol = symbol
        contract.m_secType = sec_type
        contract.m_exchange = exch
        contract.m_primaryExch = prim_exch
        contract.m_currency = curr
        return contract
Il metodo create_order genera la seconda componente della coppia, ovvero l’istanza di Order. Questo metodo prevede in input un tipo di ordine (ad es. market o limit), una quantità del bene da scambiare e una “posizione” (acquisto o vendita). Restituisce l’istanza di Order:
# ib_execution.py
    
    def create_order(self, order_type, quantity, action):
        """
        Create an Order object (Market/Limit) to go long/short.

        order_type - 'MKT', 'LMT' for Market or Limit orders
        quantity - Integral number of assets to order
        action - 'BUY' or 'SELL'
        """
        order = Order()
        order.m_orderType = order_type
        order.m_totalQuantity = quantity
        order.m_action = action
        return order
Per evitare la duplicazione delle istanze di FillEvent per un particolare ID ordine, si utilizza un dizionario chiamato fill_dict per memorizzare le chiavi che corrispondono a particolari ID ordine. Quando è stato generato un eseguito, la chiave “fill” di una voce per un particolare ID ordine è impostata su True. Nel caso si riceva un successivo messaggio “Server Response” da IB che dichiara che un ordine è stato eseguito (ed è un messaggio duplicato) non si creerà un nuovo eseguito. Il seguente metodo create_fill_dict_entry implementa questa logica:
# ib_execution.py
    
    def create_fill_dict_entry(self, msg):
        """
        Creates an entry in the Fill Dictionary that lists 
        orderIds and provides security information. This is
        needed for the event-driven behaviour of the IB
        server message behaviour.
        """
        self.fill_dict[msg.orderId] = {
            "symbol": msg.contract.m_symbol,
            "exchange": msg.contract.m_exchange,
            "direction": msg.order.m_action,
            "filled": False
        }
Il metodo create_fill si occupa di creare effettivamente l’istanza di FillEvent e la inserisce all’interno della coda degli eventi:
# ib_execution.py
    
    def create_fill(self, msg):
        """
        Handles the creation of the FillEvent that will be
        placed onto the events queue subsequent to an order
        being filled.
        """
        fd = self.fill_dict[msg.orderId]

        # Prepare the fill data
        symbol = fd["symbol"]
        exchange = fd["exchange"]
        filled = msg.filled
        direction = fd["direction"]
        fill_cost = msg.avgFillPrice

        # Create a fill event object
        fill = FillEvent(
            datetime.datetime.utcnow(), symbol, 
            exchange, filled, direction, fill_cost
        )

        # Make sure that multiple messages don't create
        # additional fills.
        self.fill_dict[msg.orderId]["filled"] = True

        # Place the fill event onto the event queue
        self.events.put(fill_event)
Dopo aver implementato tutti i metodi precedenti, resta solamente da sviluppare il metodo execute_order della classe base astratta ExecutionHandler. Questo metodo esegue effettivamente il posizionamento dell’ordine tramite l’API di IB. Si verifica innanzitutto che l’evento ricevuto con questo metodo sia realmente un OrderEvent e quindi prepara gli oggetti Contract e Order con i rispettivi parametri. Una volta che sono stati creati entrambi, il metodo placeOrder dell’oggetto di connessione viene richiamato con associato a order_ID. È estremamente importante chiamare il metodo time.sleep(1) per garantire che l’ordine sia effettivamente trasmesso ad IB. La rimozione di questa linea può causare comportamenti incoerenti dell’API, e perfino malfunzionamenti! Infine, si incrementa l’ID ordine al fine di evitare la duplicazione degli ordini:
# ib_execution.py
    
    def execute_order(self, event):
        """
        Creates the necessary InteractiveBrokers order object
        and submits it to IB via their API.

        The results are then queried in order to generate a
        corresponding Fill object, which is placed back on
        the event queue.

        Parameters:
        event - Contains an Event object with order information.
        """
        if event.type == 'ORDER':
            # Prepare the parameters for the asset order
            asset = event.symbol
            asset_type = "STK"
            order_type = event.order_type
            quantity = event.quantity
            direction = event.direction

            # Create the Interactive Brokers contract via the 
            # passed Order event
            ib_contract = self.create_contract(
                asset, asset_type, self.order_routing,
                self.order_routing, self.currency
            )

            # Create the Interactive Brokers order via the 
            # passed Order event
            ib_order = self.create_order(
                order_type, quantity, direction
            )

            # Use the connection to the send the order to IB
            self.tws_conn.placeOrder(
                self.order_id, ib_contract, ib_order
            )

            # NOTE: This following line is crucial.
            # It ensures the order goes through!
            time.sleep(1)

            # Increment the order ID for this session
            self.order_id += 1
Questa classe costituisce la base per gestione dell’esecuzione verso Interactive Brokers e può essere utilizzata al posto del gestore dell’esecuzione simulata, che è adatto solo per il backtesting. Prima che il gestore di IB possa essere utilizzato è necessario creare un gestore del feed dei dati di mercato in tempo reale che deve sostituire il gestore del feed dei dati storici utilizzato nel backtesting. Con questo approccio è possibile riutilizzare la maggior parte delle componenti di un sistema di backtesting per un sistema live, in modo da garantire che il codice “swap out” sia ridotto al minimo e quindi assicurare un comportamento simile, se non identico, tra i due sistemi.

Ambiente di Backtesting Even-Driven con Python – Parte VII

Nel precedente articolo della serie “Ambiente di Backtesting Event-Driven” è stato descritta la gerarchia della classe ExecutionHandler. In questo articolo si introduce l’implementazione delle metriche per misurare le prestazioni di una strategia usando la curva equity DataFrame precedentemente costruita nell’oggetto Portfolio.

Misurare le Performance

Abbiamo già descritto il Sharpe Ratio in un precedente articolo. In quell’articolo il Sharpe Ratio (annualizzato) viene calcolato tramite:

\(\begin{eqnarray*} S_A = \sqrt{N} \frac{\mathbb{E}(R_a – R_b)}{\sqrt{\text{Var} (R_a – R_b)}} \end{eqnarray*}\)

Dove \(R_a\) è il flusso dei rendimenti della curva equity e \(R_b\) è un indice di riferimento, come uno specifico tasso di interesse o un indice azionario. Il massimo drawdown e la durata del drawdown sono due ulteriori misure che gli investitori utilizzano per valutare il rischio in un portafoglio. Il primo rappresenta è più grande discesa, la correzione, da un precedente massimo relativo o massimo assoluto, della curva equity, mentre il secondo è definito come il numero di periodi di trading in cui si verifica. In questo articolo si implementa il Sharpe Ratio, il drawdown massimo e la durata del drawdown come misure delle prestazioni del portafoglio da utilizzare nella suite di Backtesting Event-Driven sviluppato in Python.

Implementazione

Il primo passo è creare un nuovo file performance.py, che memorizzi le funzioni per calcolare il Sharpe Ratio e le informazioni sul drawdown. Come per la maggior parte delle classi che prevedono elevati carichi computanzionali, abbiamo bisogno di importare NumPy e Pandas:
# performance.py

import numpy as np
import pandas as pd

Il Sharpe Ratio è una misura del rischio/rendimento (in realtà è una delle tante!) e prevede un singolo parametro, cioè il numero di periodi da considerare per il ridimensionamento al valore annualizzato.

Di solito questo valore è impostato su 252, ovvero il numero di giorni di negoziazione (mercati aperti) negli Stati Uniti in un anno. Tuttavia, ad esempio, se la strategia apre e chiude posizioni all’interno di un’ora, si deve regolare lo Sharpe per annualizzarlo correttamente. Pertanto, è necessario impostare il periods come 252 * 6.5 = 1638, ovvero il numero di ore di trading statunitensi in un anno. Se si effettua trading sul minuto, questo fattore deve essere impostato come 252 * 6.5 * 60 = 98280.

La funzione create_sharpe_ratio opera su un oggetto Serie di Pandas che abbiamo chiamato returns e calcola semplicemente il rapporto tra la media dei rendimenti percentuali del periodo e le deviazioni standard dei rendimenti percentuali ridimensionato in base al fattore periods:

# performance.py

def create_sharpe_ratio(returns, periods=252):
    """
    Create the Sharpe ratio for the strategy, based on a 
    benchmark of zero (i.e. no risk-free rate information).

    Parameters:
    returns - A pandas Series representing period percentage returns.
    periods - Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.
    """
    return np.sqrt(periods) * (np.mean(returns)) / np.std(returns)
Mentre il Sharpe Ratio indica il livello di rischio (definito dalla deviazione standard del patrimonio) per unità di rendimento, il “drawdown” è definito come la distanza tra un massimo relativo e un minimo relativo lungo una curva equity. La funzione create_drawdowns calcola sia il drawdown massimo che la durata massima di drawdown. Il primo è la discesa più elevata tra un massimo e minimo relativi, mentre il secondo è definito come il numero di periodi in cui questa discesa si verifica. E’ necessario prestare molta attenzione nell’interpretazione della durata del drawdown in quanto questo fattore identifica i periodi di trading e quindi non è direttamente traducibile in un’unità temporale come “giorni”. La funzione inizia creando due oggetti Serie di Pandas che rappresentano il drawdown e la durata di ogni “barra” di trading. Quindi viene stabilito l’attuale high water mark (HWM) determinando se la curva di equty supera tutti i picchi precedenti. Il drawdown è quindi semplicemente la differenza tra l’attuale HWM e la curva di equity. Se questo valore è negativo, la durata viene aumentata per ogni barra che si verifica fino al raggiungimento del prossimo HWM. La funzione restituisce quindi semplicemente il massimo di ciascuna delle due serie:
# performance.py

def create_drawdowns(equity_curve):
    """
    Calculate the largest peak-to-trough drawdown of the PnL curve
    as well as the duration of the drawdown. Requires that the 
    pnl_returns is a pandas Series.

    Parameters:
    pnl - A pandas Series representing period percentage returns.

    Returns:
    drawdown, duration - Highest peak-to-trough drawdown and duration.
    """

    # Calculate the cumulative returns curve 
    # and set up the High Water Mark
    # Then create the drawdown and duration series
    hwm = [0]
    eq_idx = equity_curve.index
    drawdown = pd.Series(index = eq_idx)
    duration = pd.Series(index = eq_idx)

    # Loop over the index range
    for t in range(1, len(eq_idx)):
        cur_hwm = max(hwm[t-1], equity_curve[t])
        hwm.append(cur_hwm)
        drawdown[t]= hwm[t] - equity_curve[t]
        duration[t]= 0 if drawdown[t] == 0 else duration[t-1] + 1
    return drawdown.max(), duration.max()
Al fine di utilizzare queste misure di performance, si ha bisogno di un metodo per calcolarle dopo che è stato effettuato un backtest, cioè quando è disponibile un’adeguata curva di equity! E’ necessario inoltre associare tale metodo a una particolare gerarchia di oggetti. Dato che le misure di rendimento sono calcolate a partire dal portafoglio, ha senso inserire i calcoli delle prestazioni all’interno di un metodo nella gerarchia della classe Portfolio, che è stata descritta in questo articolo. Il primo compito è aprire portfolio.py e importare le funzioni di performance:
# portfolio.py

..  # Other imports

from performance import create_sharpe_ratio, create_drawdowns
Poiché Portfolio è una classe base astratta, si deve associare un metodo a una delle sue classi derivate, che in questo caso corrisponde a NaivePortfolio. Quindi si crea un metodo chiamato output_summary_stats che elabora la curva equity del portafoglio per generare le informazioni relative allo Sharpe e drawdown. Il metodo è semplice. Utilizza semplicemente le due misure di performance e le applica direttamente al DataFrame Pandas relativo alla curva equity, restituendo le statistiche come una lista di tuple in un formato “user-friendly”:
# portfolio.py

..
..

class NaivePortfolio(object):

    ..
    ..

    def output_summary_stats(self):
        """
        Creates a list of summary statistics for the portfolio such
        as Sharpe Ratio and drawdown information.
        """
        total_return = self.equity_curve['equity_curve'][-1]
        returns = self.equity_curve['returns']
        pnl = self.equity_curve['equity_curve']

        sharpe_ratio = create_sharpe_ratio(returns)
        max_dd, dd_duration = create_drawdowns(pnl)

        stats = [("Total Return", "%0.2f%%" % ((total_return - 1.0) * 100.0)),
                 ("Sharpe Ratio", "%0.2f" % sharpe_ratio),
                 ("Max Drawdown", "%0.2f%%" % (max_dd * 100.0)),
                 ("Drawdown Duration", "%d" % dd_duration)]
        return stats
Chiaramente questa è un’analisi molto semplice delle prestazioni per un portfolio. Non prende in considerazione l’analisi a livello di singolo trade o altre misure del rapporto rischio / rendimento. Tuttavia è molto semplice da estendere, aggiungendo più metodi in performance.py e quindi incorporandoli in output_summary_stats come richiesto.

Ambiente di Backtesting Even-Driven con Python – Parte VI

In questo articolo continua lo sviluppo di un ambiente di backtesting basato sugli eventi, utilizzando Python. Nel precedente articolo è stata approfondita la gerarchia della classe Portfolio che permette di gestire le posizioni correnti, generare ordini di trading e tenere traccia dei profitti e delle perdite (PnL). Il porossimo passo è implementare l’esecuzione di questi ordini, creando una gerarchia di classi che rappresenta un meccanismo per la simulazione della gestione degli ordini e, infine, collegarsi ad un broker o ad altri intermediari di mercato. L’ExecutionHandler descritto in questo articolo è estremamente semplice, poiché esegue tutti gli ordini al prezzo corrente di mercato. Questo è altamente irrealistico, ma serve come una buona base di partenza da perfezionare successivamente. Come per le precedenti gerarchie di classi astratte di base, bisogna importare le proprietà e i decoratori necessari dalla libreria abc. Inoltre, è necessario importare FillEvent e OrderEvent:
# execution.py

import datetime
import Queue

from abc import ABCMeta, abstractmethod

from event import FillEvent, OrderEvent
La classe ExecutionHandler è simile alle precedenti classi astratte di base e ha solamente un metodo virtuale, execute_order:
# execution.py

class ExecutionHandler(object):
    """
    The ExecutionHandler abstract class handles the interaction
    between a set of order objects generated by a Portfolio and
    the ultimate set of Fill objects that actually occur in the
    market. 

    The handlers can be used to subclass simulated brokerages
    or live brokerages, with identical interfaces. This allows
    strategies to be backtested in a very similar manner to the
    live trading engine.
    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def execute_order(self, event):
        """
        Takes an Order event and executes it, producing
        a Fill event that gets placed onto the Events queue.

        Parameters:
        event - Contains an Event object with order information.
        """
        raise NotImplementedError("Should implement execute_order()")

Per testare le strategie, è necessario simulare il modo in cui un trade verrà eseguito. L’implementazione più semplice possibile consiste nell’ipotizzare che tutti gli ordini siano stati eseguiti al prezzo corrente di mercato per qualsiasi quantità. Questo è chiaramente estremamente irrealistico e gran parte del lavoro per aumentare il grado di realismo del backtesting consiste nel progettare dei modelli avanzati per simulare lo slippage e il market-impact.

Da notare che all’interno del metodo FillEvent viene passato un valore pari a None per l’attributo fill_cost (vedere la penultima riga in execute_order) come abbiamo descritto per il costo di esecuzione nell’oggetto NaivePortfolio descritto nell’articolo precedente. In un’implementazione più realistica, si utilizza il valore di dati di mercato “attuali” per ottenere un costo di esecuzione più realistico.

Ho inoltre utilizzato ARCA come exchange, anche se per i scopi di backtesting questo è puramente un segnaposto. In un ambiente di esecuzione dal vivo questo attributo diventa molto più importante:

# execution.py

class SimulatedExecutionHandler(ExecutionHandler):
    """
    The simulated execution handler simply converts all order
    objects into their equivalent fill objects automatically
    without latency, slippage or fill-ratio issues.

    This allows a straightforward "first go" test of any strategy,
    before implementation with a more sophisticated execution
    handler.
    """
    
    def __init__(self, events):
        """
        Initialises the handler, setting the event queues
        up internally.

        Parameters:
        events - The Queue of Event objects.
        """
        self.events = events

    def execute_order(self, event):
        """
        Simply converts Order objects into Fill objects naively,
        i.e. without any latency, slippage or fill ratio problems.

        Parameters:
        event - Contains an Event object with order information.
        """
        if event.type == 'ORDER':
            fill_event = FillEvent(datetime.datetime.utcnow(), event.symbol,
                                   'ARCA', event.quantity, event.direction, None)
            self.events.put(fill_event)

Questo conclude le gerarchie di classi necessarie per implementare un ambiente di backtesting basato sugli eventi.

Nel prossimo articolo si descriverà come calcolare un insieme di metriche sul rendimento per la strategia oggetto del backtesting.

Ambiente di Backtesting Even-Driven con Python – Parte V

Nel precedente articolo relativo al backtesting basato sugli eventi abbiamo descritto come costruire la gerarchia della classe Strategy. Le strategie, per come sono state definite, sono utilizzate per generare signals, che sono l’input di un oggetto portfolio al fine di decidere se inviare o meno gli orders. Inizialmente, è naturale creare una classe astratta di base (ABC) del Portfolio da cui si ereditano tutte le sottoclassi successive. Questo articolo descrive un oggetto NaivePortfolio che tiene traccia delle posizioni all’interno di un portafoglio e genera ordini di una quantità fissa di azioni in base ai segnali. Oggetti di portfolio avanzati includono strumenti di gestione del rischio più sofisticati e saranno oggetto di articoli successivi.

Monitoraggio della Posizione e Gestione degli Ordini

Il sistema di gestione degli ordini del portafoglio è probabilmente la componente più complessa di un ambiente backtesting basato sugli eventi. Questa componente ha il compito di tenere traccia di tutte le attuali posizioni aperte sul mercato e del valore di mercato di queste posizioni (note come “holdings”). Questa è semplicemente una stima del valore di liquidazione della posizione ed è derivata in parte dalla funzione di gestione dei dati del backtester. Oltre alle posizioni e alla gestione degli holdings, il portafoglio deve essere a conoscenza dei fattori di rischio e delle tecniche di dimensionamento delle posizioni al fine di ottimizzare gli ordini inviati ad un broker o verso altre forme di accesso al mercato. In analogia alla gerarchia della classe Event, un oggetto Portfolio deve essere in grado di gestire oggetti SignalEvent, generare oggetti OrderEvent e interpretare oggetti FillEvent per aggiornare le posizioni. Pertanto non sorprende che gli oggetti portfolio siano spesso il componente più importante dei sistemi event-driven, in termini di righe di codice (LOC).

Implementazione

Si crea un nuovo file portfolio.py e si importa le librerie necessarie. Queste sono le stesse della maggior parte delle altre implementazioni delle classe astratte di base. In particolare si importa la funzione floor dalla libreria math per generare dimensioni di ordine con valori interi, ed inoltre si importano gli oggetti FillEvent e OrderEvent poiché il Portfolio gestisce entrambi.
# portfolio.py

import datetime
import numpy as np
import pandas as pd
import Queue

from abc import ABCMeta, abstractmethod
from math import floor

from event import FillEvent, OrderEvent
A questo punto si crea una classe ABC per il Portfolio e si implementano due metodi virtuali update_signal e update_fill. Il primo elabora i nuovi segnali di trading che vengono prelevati dalla coda degli eventi, mentre il secondo gestisce gli ordini eseguiti e ricevuti dall’oggetto di gestione dell’esecuzione.
# portfolio.py

class Portfolio(object):
    """
    The Portfolio class handles the positions and market
    value of all instruments at a resolution of a "bar",
    i.e. secondly, minutely, 5-min, 30-min, 60 min or EOD.
    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def update_signal(self, event):
        """
        Acts on a SignalEvent to generate new orders 
        based on the portfolio logic.
        """
        raise NotImplementedError("Should implement update_signal()")

    @abstractmethod
    def update_fill(self, event):
        """
        Updates the portfolio current positions and holdings 
        from a FillEvent.
        """
        raise NotImplementedError("Should implement update_fill()")

L’argomento principale di questo articolo è la classe NaivePortfolio. Questa classe è progettata per gestire il dimensionamento delle posizioni e gli holdings correnti, ma esegue gli ordini di compravendita in modo “stupido”, semplicemente inviandoli direttamente al broker con una dimensione fissa e predeterminata, indipendentemente dalla liquidità detenuta. Queste sono tutte ipotesi irrealistiche, ma aiutano a delineare come funziona un sistema di gestione degli ordini di portafoglio (OMS) basato sugli eventi.

La NaivePortfolio richiede un valore del capitale iniziale, che ho impostato sul valore predefinito di 100.000 USD. Richiede anche una data di inizio.

Il portfolio contiene gli attributi all_positions e current_positions. Il primo memorizza un elenco di tutte le precedenti posizioni registrate ad uno specifico timestamp di un evento di dati di mercato. Una posizione è semplicemente la quantità dell’asset. Le posizioni negative indicano che l’asset è stato ridotto. Il secondo attributo memorizza un dizionario contenente le posizioni correnti per l’ultimo aggiornamento dei dati di mercato.

Oltre agli attributi delle posizioni, il portafoglio memorizza gli holdings, che descrivono il valore corrente di mercato delle posizioni detenute. Il “Valore corrente di mercato” indica, in questo caso, il prezzo di chiusura ottenuto dalla barra OLHCV corrente, che è chiaramente un’approssimazione, ma è abbastanza accettabile in questo momento. L’attributo all_holdings memorizza la lista storica di tutte gli holding dei simboli, mentre current_holdings memorizza il dizionario aggiornato di tutti i valori di holdings dei simboli.

# portfolio.py

class NaivePortfolio(Portfolio):
    """
    The NaivePortfolio object is designed to send orders to
    a brokerage object with a constant quantity size blindly,
    i.e. without any risk management or position sizing. It is
    used to test simpler strategies such as BuyAndHoldStrategy.
    """
    
    def __init__(self, bars, events, start_date, initial_capital=100000.0):
        """
        Initialises the portfolio with bars and an event queue. 
        Also includes a starting datetime index and initial capital 
        (USD unless otherwise stated).

        Parameters:
        bars - The DataHandler object with current market data.
        events - The Event Queue object.
        start_date - The start date (bar) of the portfolio.
        initial_capital - The starting capital in USD.
        """
        self.bars = bars
        self.events = events
        self.symbol_list = self.bars.symbol_list
        self.start_date = start_date
        self.initial_capital = initial_capital
        
        self.all_positions = self.construct_all_positions()
        self.current_positions = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )

        self.all_holdings = self.construct_all_holdings()
        self.current_holdings = self.construct_current_holdings()
Il seguente metodo, construct_all_positions, crea semplicemente un dizionario per ogni simbolo, e per ciascuno imposta il valore a zero e quindi aggiunge una chiave datetime, inserendo infine questo oggetto in un elenco. Usa una comprensione del dizionario, che è simile alla comprensione di una lista:
# portfolio.py

    def construct_all_positions(self):
        """
        Constructs the positions list using the start_date
        to determine when the time index will begin.
        """
        d = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )
        d['datetime'] = self.start_date
        return [d]
Il metodo construct_all_holdings è simile al precedente, ma aggiunge delle chiavi extra per memorizzare i contanti, le commissioni e il totale, che rappresentano rispettivamente la riserva di denaro nel conto dopo eventuali acquisti, la commissione cumulativa maturata e il totale del conto azionario inclusi i contanti e le posizioni aperte. Le posizioni short sono considerate negative. I contanti (cash) e il totale (total) sono entrambi inizializzati con il capitale iniziale:
# portfolio.py

    def construct_all_holdings(self):
        """
        Constructs the holdings list using the start_date
        to determine when the time index will begin.
        """
        d = dict( (k,v) for k, v in [(s, 0.0) for s in self.symbol_list] )
        d['datetime'] = self.start_date
        d['cash'] = self.initial_capital
        d['commission'] = 0.0
        d['total'] = self.initial_capital
        return [d]
Il metodo seguente, construct_current_holdings è quasi identico al metodo precedente, tranne per il fatto che non racchiude il dizionario in un elenco:
# portfolio.py

    def construct_all_holdings(self):
        """
        Constructs the holdings list using the start_date
        to determine when the time index will begin.
        """
        d = dict( (k,v) for k, v in [(s, 0.0) for s in self.symbol_list] )
        d['datetime'] = self.start_date
        d['cash'] = self.initial_capital
        d['commission'] = 0.0
        d['total'] = self.initial_capital
        return [d]
Ad ogni “battito” o impulso del sistema, cioè ogni volta che vengono richiesti nuovi dati di mercato dall’oggetto DataHandler, il portfolio deve aggiornare il valore corrente di mercato di tutte le posizioni detenute. In uno scenario di trading live queste informazioni possono essere scaricate e analizzate direttamente dal broker, ma per un’implementazione di backtesting è necessario calcolare manualmente questi valori. Sfortunatamente non esiste una cosa come il “valore corrente di mercato” a causa degli spread bid / ask e delle problematiche di liquidità. Quindi è necessario stimarlo moltiplicando la quantità del bene detenuta per un determinato “prezzo”. L’approccio utilizzato in questo esempio prevede di utilizzare il prezzo di chiusura dell’ultima barra ricevuta. Per una strategia intraday questo è relativamente realistico. Per una strategia quotidiana questo è meno realistico in quanto il prezzo di apertura può differire molto dal prezzo di chiusura. Il metodo update_timeindex gestisce il monitoraggio dei nuovi holdings. In particolare ricava i prezzi più recenti dal gestore dei dati di mercato e crea un nuovo dizionario di simboli per rappresentare le posizioni correnti, impostando le posizioni “nuove” uguali alle posizioni “correnti”. Questi vengono modificati solo quando si riceva un FillEvent, che viene successivamente gestito dal portfolio. Il metodo quindi aggiunge questo insieme di posizioni correnti alla lista all_positions. Successivamente, le posizioni vengono aggiornate in modo simile, con l’eccezione che il valore di mercato viene ricalcolato moltiplicando il conteggio delle posizioni correnti con il prezzo di chiusura dell’ultima barra (self.current_positions [s] * bars [s] [0] [ 5]). Infine, i nuovi holdings sono agggiunti a all_holdings:
# portfolio.py

    def update_timeindex(self, event):
        """
        Adds a new record to the positions matrix for the current 
        market data bar. This reflects the PREVIOUS bar, i.e. all
        current market data at this stage is known (OLHCVI).

        Makes use of a MarketEvent from the events queue.
        """
        bars = {}
        for sym in self.symbol_list:
            bars[sym] = self.bars.get_latest_bars(sym, N=1)

        # Update positions
        dp = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )
        dp['datetime'] = bars[self.symbol_list[0]][0][1]

        for s in self.symbol_list:
            dp[s] = self.current_positions[s]

        # Append the current positions
        self.all_positions.append(dp)

        # Update holdings
        dh = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )
        dh['datetime'] = bars[self.symbol_list[0]][0][1]
        dh['cash'] = self.current_holdings['cash']
        dh['commission'] = self.current_holdings['commission']
        dh['total'] = self.current_holdings['cash']

        for s in self.symbol_list:
            # Approximation to the real value
            market_value = self.current_positions[s] * bars[s][0][5]
            dh[s] = market_value
            dh['total'] += market_value

        # Append the current holdings
        self.all_holdings.append(dh)
Il metodo update_positions_from_fill determina se FillEvent è un Buy o un Sell e quindi aggiorna di conseguenza il dizionario current_positions aggiungendo / sottraendo la corretta quantità di asset:
# portfolio.py

    def update_positions_from_fill(self, fill):
        """
        Takes a FilltEvent object and updates the position matrix
        to reflect the new position.

        Parameters:
        fill - The FillEvent object to update the positions with.
        """
        # Check whether the fill is a buy or sell
        fill_dir = 0
        if fill.direction == 'BUY':
            fill_dir = 1
        if fill.direction == 'SELL':
            fill_dir = -1

        # Update positions list with new quantities
        self.current_positions[fill.symbol] += fill_dir*fill.quantity
Il corrispondente update_holdings_from_fill è simile al metodo precedente ma aggiorna i valori di holdings. Per simulare il costo di riempimento, il metodo seguente non utilizza il costo associato a FillEvent. Perchè questo approccio? In parole povere, in un ambiente di backtesting il costo di riempimento è in realtà sconosciuto e quindi deve essere stimato. Quindi il costo di riempimento è impostato sul “prezzo corrente di mercato” (il prezzo di chiusura dell’ultima barra). Le posizioni per un particolare simbolo vengono quindi impostate per essere uguali al costo di riempimento moltiplicato per la quantità del trade. Una volta che il costo di riempimento è noto, gli holdings correnti, i contanti e i valori totali possono essere aggiornati. Anche la commissione cumulativa viene aggiornata:
# portfolio.py

    def update_holdings_from_fill(self, fill):
        """
        Takes a FillEvent object and updates the holdings matrix
        to reflect the holdings value.

        Parameters:
        fill - The FillEvent object to update the holdings with.
        """
        # Check whether the fill is a buy or sell
        fill_dir = 0
        if fill.direction == 'BUY':
            fill_dir = 1
        if fill.direction == 'SELL':
            fill_dir = -1

        # Update holdings list with new quantities
        fill_cost = self.bars.get_latest_bars(fill.symbol)[0][5]  # Close price
        cost = fill_dir * fill_cost * fill.quantity
        self.current_holdings[fill.symbol] += cost
        self.current_holdings['commission'] += fill.commission
        self.current_holdings['cash'] -= (cost + fill.commission)
        self.current_holdings['total'] -= (cost + fill.commission)
Qui viene implementato il metodo virtuale update_fill della classe ABC Portfolio . Esegue semplicemente i due metodi precedenti, update_positions_from_fill e update_holdings_from_fill, che sono già stati discussi sopra:
 # portfolio.py

def update_fill(self, event):
        """
        Updates the portfolio current positions and holdings 
        from a FillEvent.
        """
        if event.type == 'FILL':
            self.update_positions_from_fill(event)
            self.update_holdings_from_fill(event)

L’oggetto Portfolio, oltre a gestire i FillEvents, deve anche occuparsi della generazione degli OrderEvents al ricevimento di uno o più SignalEvents. Il metodo generate_naive_order prende un segnale di long o short di un asset e invia un ordine per aprire una posizione per 100 shares di tale asset. Chiaramente 100 è un valore arbitrario. In un’implementazione realistica questo valore sarà determinato da una gestione del rischio o da un overlay di ridimensionamento della posizione. Tuttavia, questo è un NaivePortfolio e quindi “ingenuamente” invia tutti gli ordini direttamente dai segnali, senza un sistema di dimensionamento della posizione.

Il metodo gestisce il long, lo short e l’uscita di una posizione, in base alla quantità corrente e allo specifico simbolo. Infine vengono generati i corrispondenti oggetti OrderEvent:

# portfolio.py

    def generate_naive_order(self, signal):
        """
        Simply transacts an OrderEvent object as a constant quantity
        sizing of the signal object, without risk management or
        position sizing considerations.

        Parameters:
        signal - The SignalEvent signal information.
        """
        order = None

        symbol = signal.symbol
        direction = signal.signal_type
        strength = signal.strength

        mkt_quantity = floor(100 * strength)
        cur_quantity = self.current_positions[symbol]
        order_type = 'MKT'

        if direction == 'LONG' and cur_quantity == 0:
            order = OrderEvent(symbol, order_type, mkt_quantity, 'BUY')
        if direction == 'SHORT' and cur_quantity == 0:
            order = OrderEvent(symbol, order_type, mkt_quantity, 'SELL')   
    
        if direction == 'EXIT' and cur_quantity > 0:
            order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL')
        if direction == 'EXIT' and cur_quantity < 0:
            order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY')
        return order
Il metodo update_signal richiama semplicemente il metodo precedente e aggiunge l’ordine generato alla coda degli eventi:
# portfolio.py

    def update_signal(self, event):
        """
        Acts on a SignalEvent to generate new orders 
        based on the portfolio logic.
        """
        if event.type == 'SIGNAL':
            order_event = self.generate_naive_order(event)
            self.events.put(order_event)
L’ultimo metodo di NaivePortfolio prevede la generazione di una curva equity. Crea semplicemente un flusso dei rendimenti, utilizzato per i calcoli delle prestazioni e quindi normalizza la curva equity in base alla percentuale. La dimensione iniziale dell’account è pari a 1,0:
# portfolio.py

    def create_equity_curve_dataframe(self):
        """
        Creates a pandas DataFrame from the all_holdings
        list of dictionaries.
        """
        curve = pd.DataFrame(self.all_holdings)
        curve.set_index('datetime', inplace=True)
        curve['returns'] = curve['total'].pct_change()
        curve['equity_curve'] = (1.0+curve['returns']).cumprod()
        self.equity_curve = curve

L’oggetto Portfolio è la componente più complessa dell’intero sistema di backtesting basato sugli eventi. L’implementazione è complessa, quindi in questo articolo abbiamo semplificato alcuni aspetti tra cui la gestione delle posizioni. Le versioni successive prenderanno in considerazione la gestione del rischio e il dimensionamento delle posizioni, che porterà a un’idea molto più realistica delle prestazioni della strategia.

Nel prossimo articolo considereremo l’ultimo modulo di un sistema di backtesting event-driven, ovvero l’oggetto ExecutionHandler, che viene utilizzato per prelevare oggetti OrderEvent e creare oggetti FillEvent.

Ambiente di Backtesting Even-Driven con Python – Parte IV

In questa serie di articoli relativa all’implementazione di un ambiente di backtesting basato sugli eventi abbiamo già descritto la struttura degli event-loop, la gerarchia della classe Event e la componente per la gestione dei dati. In questo articolo si introduce la gerarchia della classe Strategy. Gli oggetti “strategia” prendono i dati di mercato come input e producono eventi di tipo Signal Trading come output. Un oggetto Strategy include tutti i calcoli sui dati di mercato che generano segnali advisory per l’oggetto Portfolio. In questa fase di sviluppo dell’ambiente di backtesting event-driven non introduciamo i concetto di indicatore o filtro, come quelli che sono usati nell’analisi tecnica classica. Questi sono tuttavia buoni candidati per la creazione di una gerarchia di classi, ma vanno oltre lo scopo di questo articolo. La gerarchia della classe Strategy è relativamente semplice poiché consiste in una classe base astratta con un singolo metodo puro virtuale per generare oggetti SignalEvent. Per creare la gerarchia della strategia è necessario importare NumPy, Pandas, l’oggetto Queue, i strumenti della classe base astratta e SignalEvent:
# strategy.py

import datetime
import numpy as np
import pandas as pd
import Queue

from abc import ABCMeta, abstractmethod

from event import SignalEvent
La classe base astratta Strategy definisce semplicemente il metodo virtuale calculate_signals. Questo metodo sarà usato nelle classi derivate per gestire la creazione di oggetti SignalEvent a seconda degli aggiornamenti dei dati di mercato:
# strategy.py

class Strategy(object):
    """
    Strategy is an abstract base class providing an interface for
    all subsequent (inherited) strategy handling objects.

    The goal of a (derived) Strategy object is to generate Signal
    objects for particular symbols based on the inputs of Bars 
    (OLHCVI) generated by a DataHandler object.

    This is designed to work both with historic and live data as
    the Strategy object is agnostic to the data source,
    since it obtains the bar tuples from a queue object.
    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def calculate_signals(self):
        """
        Provides the mechanisms to calculate the list of signals.
        """
        raise NotImplementedError("Should implement calculate_signals()")
Come mostrato nel codice precedente, la definizione della classe astratta Strategy è semplice. Un primo esempio di sottoclasse dell’oggetto Strategy è la creazione della classe BuyAndHoldStrategy, che implementa la classica strategia buy and hold. Questa strategia compra un asset ad una certo istante e lo conserva all’interno del portafoglio. Quindi viene generato un solo segnale per ogni asset. Il costruttore (__init__) prevede, come input, il gestore dei dati di mercato e l’oggetto della coda degli eventi Events:
# strategy.py

class BuyAndHoldStrategy(Strategy):
    """
    This is an extremely simple strategy that goes LONG all of the 
    symbols as soon as a bar is received. It will never exit a position.

    It is primarily used as a testing mechanism for the Strategy class
    as well as a benchmark upon which to compare other strategies.
    """

    def __init__(self, bars, events):
        """
        Initialises the buy and hold strategy.

        Parameters:
        bars - The DataHandler object that provides bar information
        events - The Event Queue object.
        """
        self.bars = bars
        self.symbol_list = self.bars.symbol_list
        self.events = events

        # Once buy & hold signal is given, these are set to True
        self.bought = self._calculate_initial_bought()
Nell’inizializzazione di BuyAndHoldStrategy, l’attributo bought viene instanziato con un dictionary (una struttura data nativa di Python) di chiavi per ogni simbolo, tutte impostate con False. Una volta che un asset è andato “long”, la relativa chiave viene impostata su True. In sostanza ciò consente alla Strategia di sapere su quali asset è “sul mercato” o meno:
# strategy.py

    def _calculate_initial_bought(self):
        """
        Adds keys to the bought dictionary for all symbols
        and sets them to False.
        """
        bought = {}
        for s in self.symbol_list:
            bought[s] = False
        return bought

Il metodo virtuale calculate_signals viene concretamente implementato in questa classe. Il metodo scorre su tutti i simboli nell’elenco dei simboli e recupera la barra OLHCV più recente dal gestore dei dati di mercato. Quindi controlla se quel simbolo è stato “comprato” (cioè se abbiamo una posizione aperta a mercato per questo simbolo o no) e, in caso negativo, crea un singolo oggetto SignalEvent. Quest’ultimo viene poi inserito nella coda degli eventi e il dizionario bought viene correttamente aggiornato con True per questo specifico simbolo:

# strategy.py

    def calculate_signals(self, event):
        """
        For "Buy and Hold" we generate a single signal per symbol
        and then no additional signals. This means we are 
        constantly long the market from the date of strategy
        initialisation.

        Parameters
        event - A MarketEvent object. 
        """
        if event.type == 'MARKET':
            for s in self.symbol_list:
                bars = self.bars.get_latest_bars(s, N=1)
                if bars is not None and bars != []:
                    if self.bought[s] == False:
                        # (Symbol, Datetime, Type = LONG, SHORT or EXIT)
                        signal = SignalEvent(bars[0][0], bars[0][1], 'LONG')
                        self.events.put(signal)
                        self.bought[s] = True

Questa semplice strategia è sufficiente per dimostrare la natura di una gerarchia basata su eventi. Negli articoli successivi considereremo strategie più sofisticate come il pairs trading.

Infine nel prossimo articolo considereremo come creare la gerarchia della classe Portfolio che tenga traccia delle nostre posizioni con un profitto e una perdita (“PnL”)

Ambiente di Backtesting Even-Driven con Python – Parte III

Nei due articoli precedenti della serie abbiamo introdotto i concetti base di un sistema di backtesting basato sugli eventi e la gerarchia di classi per l’oggetto Event. In questo articolo vediamo come vengono utilizzati i dati di mercato, sia in un contesto storico di backtesting sia per l’esecuzione del live trading.

Uno dei nostri obiettivi con un sistema di trading basato sugli eventi è di minimizzare la duplicazione del codice tra l’elemento di backtesting e l’elemento di esecuzione live. Idealmente, è ottimale utilizzare la stessa metodologia di generazione del segnale e le stesse componenti di gestione del portafoglio sia per i test storici che per trading reale. Affinché questo funzioni, l’oggetto Strategy, che genera i segnali, e l’oggetto Portfolio, che fornisce gli ordini basati su di essi, devono utilizzare un’identica interfaccia verso un feed di dati finanziari, sia per la versione di backtesting che per quella live.

Questo requisito motiva la necessità di una gerarchia di classi basata sull’oggetto DataHandler, che l’implementa un’interfaccia, disponibile a tutte le sottoclassi, per fornire i dati di mercato alle rimanenti componenti del sistema. In questo modo, si può intercambiare qualsiasi sottoclasse di “fornitura” di dati finanziari senza influenzare la strategia o il calcolo del portafoglio.

Esempi di sottoclassi specifiche possono includere HistoricCSVDataHandler, QuandlDataHandler, SecuritiesMasterDataHandler, InteractiveBrokersMarketFeedDataHandler ecc. In questo tutorial descriviamo solamente la creazione di un gestore CSV di dati storici, che caricherà da un CSV i dati intraday per le azioni nel formato Open-Low-High-Close- Volume-OpenInterest. Questo può quindi essere usato per alimentare con i dati “candela-per-candela” le classi Strategy e Portfolio per ogni heartbeat (o impulso) del sistema, evitando così i bias di look-ahead.

Il primo compito è importare le librerie necessarie. Nello specifico, si includono Pandas e gli strumenti astratti della classe base. Dato che DataHandler genera MarketEvents, si importa anche event.py come descritto nel tutorial precedente:

# data.py

import datetime
import os, os.path
import pandas as pd

from abc import ABCMeta, abstractmethod

from event import MarketEvent

La classe DataHandler è una classe base astratta (ABC), cioè è impossibile istanziare direttamente un’istanza. Possono essere istanziate solamente le sottoclassi. Con questo approccio la classe ABC fornisce un’interfaccia che tutte le successive sottoclassi di DataHandler devono rispettare, garantendo in tal modo la compatibilità con altre classi che comunicano con esse.

Facciamo uso della proprietà __metaclass__ per far sapere a Python che questa è una classe ABC. Inoltre usiamo il decoratore @abstractmethod per far sapere a Python che il metodo verrà sovrascritto dalle sottoclassi (questo è identico a un metodo virtuale puro di C++).

I due metodi fondamentali sono get_latest_bars e update_bars. Il primo restituisce le ultime barre N a partire dal timestamp dall’attuale “impulso”, necessarie per far eseguire le elaborazioni previste nelle classi Strategy. Il secondo metodo fornisce un meccanismo di “alimentazione a goccia” per posizionare le informazioni OLHCV su una nuova struttura dati in modo da evitare la distorsione lookahead. Si noti che verranno sollevate eccezioni se si verifica un tentativo di istanziazione della classe:

# data.py

class DataHandler(object):
    """
    DataHandler is an abstract base class providing an interface for
    all subsequent (inherited) data handlers (both live and historic).

    The goal of a (derived) DataHandler object is to output a generated
    set of bars (OLHCVI) for each symbol requested. 

    This will replicate how a live strategy would function as current
    market data would be sent "down the pipe". Thus a historic and live
    system will be treated identically by the rest of the backtesting suite.
    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def get_latest_bars(self, symbol, N=1):
        """
        Returns the last N bars from the latest_symbol list,
        or fewer if less bars are available.
        """
        raise NotImplementedError("Should implement get_latest_bars()")

    @abstractmethod
    def update_bars(self):
        """
        Pushes the latest bar to the latest symbol structure
        for all symbols in the symbol list.
        """
        raise NotImplementedError("Should implement update_bars()")
Dopo aver definito la classe DataHandler, il passo successivo è creare un gestore per i file CSV di dati storici. In particolare, HistoricCSVDataHandler prenderà più file CSV, uno per ciascun simbolo, e li convertirà in un DataFrame di Panda. Il gestore dati richiede alcuni parametri, ovvero una coda di eventi su cui inviare informazioni di MarketEvent, il percorso assoluto dei file CSV e un elenco di simboli. Di seguito l’inizializzazione della classe:
# data.py

class HistoricCSVDataHandler(DataHandler):
    """
    HistoricCSVDataHandler is designed to read CSV files for
    each requested symbol from disk and provide an interface
    to obtain the "latest" bar in a manner identical to a live
    trading interface. 
    """

    def __init__(self, events, csv_dir, symbol_list):
        """
        Initialises the historic data handler by requesting
        the location of the CSV files and a list of symbols.

        It will be assumed that all files are of the form
        'symbol.csv', where symbol is a string in the list.

        Parameters:
        events - The Event Queue.
        csv_dir - Absolute directory path to the CSV files.
        symbol_list - A list of symbol strings.
        """
        self.events = events
        self.csv_dir = csv_dir
        self.symbol_list = symbol_list

        self.symbol_data = {}
        self.latest_symbol_data = {}
        self.continue_backtest = True       

        self._open_convert_csv_files()
Questa funzione prevede quindi di aprire i file nel formato “SYMBOL.csv” dove il SYMBOL è il simbolo del ticker. Il formato dei file corrisponde a quello fornito da DTN IQFeed, ma si può facilmente modificare per gestire formati di dati aggiuntivi. L’apertura dei file è gestita dal seguente metodo _open_convert_csv_files. Uno dei vantaggi dell’uso della libreria Pandas come archivio all’interno di HistoricCSVDataHandler è la possibilità di unire gli indici di tutti i simboli tracciati. Ciò consente di correggere i punti di dati mancanti in avanti, indietro o interpolati all’interno di questi spazi, in modo tale che i ticker possano essere confrontati “candela-per-candela”. Questo è necessario, ad esempio, per strategie di mean-reverting. Si noti l’uso dei metodi union e reindex quando si combina gli indici di tutti i simboli:
# data.py

    def _open_convert_csv_files(self):
        """
        Opens the CSV files from the data directory, converting
        them into pandas DataFrames within a symbol dictionary.

        For this handler it will be assumed that the data is
        taken from DTN IQFeed. Thus its format will be respected.
        """
        comb_index = None
        for s in self.symbol_list:
            # Load the CSV file with no header information, indexed on date
            self.symbol_data[s] = pd.io.parsers.read_csv(
                                      os.path.join(self.csv_dir, '%s.csv' % s),
                                      header=0, index_col=0, 
                                      names=['datetime','open','low','high','close','volume','oi']
                                  )

            # Combine the index to pad forward values
            if comb_index is None:
                comb_index = self.symbol_data[s].index
            else:
                comb_index.union(self.symbol_data[s].index)

            # Set the latest symbol_data to None
            self.latest_symbol_data[s] = []

        # Reindex the dataframes
        for s in self.symbol_list:
            self.symbol_data[s] = self.symbol_data[s].reindex(index=comb_index, method='pad').iterrows()
Il metodo _get_new_bar crea un generatore python per fornire una versione formattata dei dati OLCHV. Questo significa che le successive chiamate al metodo genereranno una nuova barra fino al raggiungimento della fine dei dati del simbolo:
# data.py

    def _get_new_bar(self, symbol):
        """
        Returns the latest bar from the data feed as a tuple of 
        (sybmbol, datetime, open, low, high, close, volume).
        """
        for b in self.symbol_data[symbol]:
            yield tuple([symbol, datetime.datetime.strptime(b[0], '%Y-%m-%d %H:%M:%S'), 
                        b[1][0], b[1][1], b[1][2], b[1][3], b[1][4]])       
Il primo metodo astratto di DataHandler da implementare è get_latest_bars. Questo metodo fornisce semplicemente un elenco delle ultime barre N dalla struttura latest_symbol_data. L’impostazione N = 1 consente il recupero della barra corrente (racchiusa nell’elenco):
# data.py

    def get_latest_bars(self, symbol, N=1):
        """
        Returns the last N bars from the latest_symbol list,
        or N-k if less available.
        """
        try:
            bars_list = self.latest_symbol_data[symbol]
        except KeyError:
            print "That symbol is not available in the historical data set."
        else:
            return bars_list[-N:]
Il secondo metodo astratto, update_bars, genera semplicemente un MarketEvent che viene aggiunto alla coda, e aggiunge le ultime barre a latest_symbol_data:
# data.py

    def update_bars(self):
        """
        Pushes the latest bar to the latest_symbol_data structure
        for all symbols in the symbol list.
        """
        for s in self.symbol_list:
            try:
                bar = self._get_new_bar(s).next()
            except StopIteration:
                self.continue_backtest = False
            else:
                if bar is not None:
                    self.latest_symbol_data[s].append(bar)
        self.events.put(MarketEvent())

A questo punto abbiamo implementato un oggetto derivato da DataHandler, che viene utilizzato dai restanti componenti per tenere traccia dei dati di mercato. Gli oggetti Strategy, Portfolio ed ExecutionHandler richiedono gli attuali dati di mercato, quindi ha senso centralizzare questa gestione al fine di evitare la duplicazione del codice e di possibili bug.

Nel prossimo articolo vedremo la gerarchia della classe Strategy e descriviamo come una strategia può essere progettata per gestire più simboli, generando così più SignalEvents per l’oggetto Portfolio.