Ottimizzare una Strategia di Pairs-trading Intraday

Ottimizzare una Strategia di Trading

Nella precedente lezione abbiamo descritto la selezione del modello e l’ottimizzazione dei parametri statistici sottostanti che (potrebbe) costituire la base di una strategia di trading. Tuttavia, un modello predittivo e una strategia algoritmica funzionante e redditizia sono due entità ben diverse. In questa lezione descriviamo come ottimizzare una strategia di pairs-trading intraday. Vediamo come ottimizzare i parametri che hanno un effetto diretto sulla redditività e sulle metriche del rischio.

Per raggiungere questo obiettivo usiamo il motore di backtesting descritto nel corso “Trading System Event Driven con Python“. Prendiamo in considerazione una particolare strategia che prevede tre parametri ed effettuiamo un’analisi. L’analisi prevede lo studio sullo spazio formato dal prodotto cartesiano dei parametri, utilizzando il metodo della grid search. In dettaglio vogliamo massimizzare alcune specifiche metriche, come il Sharpe Ratio, o minimizzarne altre, come il drawdown massimo.

Strategia Intraday di Pairs Trading sull’azionario

Per ottimizzare un strategia di trading prendiamo in considerazione è la strategia “Mean Reverting di Pairs trading Intraday” utilizzando le AREX e WLL del settore energetico. 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
  • 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.

Ottimizzare una Strategia di Pairs-Trading

Dato che il software di backtesting basato sugli eventi è piuttosto oneroso per il carico di lavoro sulla CPU, limitiamo l’intervallo dei valori dei 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 calcoliamo un prodotto cartesiano di tutte e tre gli intervalli e effettuiamo 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
..
				
			

Dobbiamo 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.

Intervalli delle 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 modifichiamo 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.

Esecuzione delle Simulazioni

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 il backtester non è stato parallelizzato. 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.90, 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.90, 2.82, 14.06, 35127
50, 3.0, 1.0, 324.00, 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.10, 2.93, 8.49, 23920
50, 4.0, 1.0, 222.94, 20.74, 3.50, 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.00, 15.71, 89118
100, 2.0, 1.5, 317.53, 25.83, 2.93, 14.56, 80624
100, 3.0, 0.5, 320.10, 25.95, 3.06, 13.35, 66012
100, 3.0, 1.0, 307.18, 25.32, 3.20, 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.70, 15400
100, 4.0, 1.5, 224.43, 20.83, 3.33, 7.73, 18584
200, 2.0, 0.5, 461.50, 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.90, 12.52, 51143
200, 4.0, 1.0, 223.63, 20.78, 2.93, 9.61, 40075
200, 4.0, 1.5, 203.60, 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 Sharpe Ratio

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()
				
			

Heatmap dei Drawdown

Il grafico del drawdown massimo è quasi identico ad eccezione dell’utilizzo di una heatmap con ombreggiatura rossa e della 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.

Conclusione

In questa lezione abbiamo visto come i parametri di una strategia di pairs-trading intraday influenzino in modo significativo la sua performance, evidenziando l’importanza di un processo sistematico di ottimizzazione. Attraverso l’uso di un motore di backtesting e l’approccio grid search, è stato possibile valutare in maniera dettagliata l’impatto di ogni combinazione parametrica su metriche fondamentali come il Sharpe Ratio e il drawdown massimo.

L’analisi e la successiva visualizzazione dei risultati ci hanno permesso di identificare le configurazioni più efficienti in termini di rischio-rendimento. Ottimizzare una strategia di pairs-trading non è un’attività puramente tecnica, ma un passaggio cruciale per trasformare un’idea teorica in un sistema robusto e profittevole.

Il codice completo presentato in questa lezione, basato sul sistema di trading event-driven TQTradingSystem, è disponibile nel seguente repository GitHub: https://github.com/tradingquant-it/TQTradingSystem.”

Torna in alto