In questa lezione del corso “Strategie event-driven per il trading automatico” descriviamo come implementare ed effettuare il backtest di una strategia intraday mean-reverting con il trading system event-driven TQTradingSystem.
La strategia è basata su una classica idea operativa: quella delle “coppie di trading”. Il Pairs Trading consiste nel costruire uno “spread” tra una coppia di asset, andando long su uno e short sull’altro, secondo diversi rapporti di esposizione possibili.
In questa lezione descriviamo come implementare il rapporto tra le posizioni long e short in vari modi. Ad esempio usando tecniche di cointegrazione statistica applicate alle serie temporali. La tecnica consiste nel calcolare l’hedge ratio (rapporto di copertura) tra gli asset attraverso una regressione lineare a rotazione.
Grazie a questo approccio, costruiamo uno spread che normalizziamo in uno z-score, utile per generare segnali di trading operativi. Generiamo i segnali quando lo z-score supera una soglia prefissata, assumendo che lo spread tenda a ritornare verso la propria media nel tempo.
Strutturiamo la strategia partendo da asset che mostrano comportamenti e andamenti simili, favorendo la natura mean-reverting del differenziale di prezzo. Ci aspettiamo che eventi locali influenzino separatamente i due asset (come capitalizzazioni diverse o ribilanciamenti), ma che nel lungo termine restino cointegrati.
Backtest di una Strategia Intraday
Per ottenere uno Sharpe Ratio più elevato, sviluppiamo strategie intraday ad alta frequenza che sfruttano la rapida variazione dei prezzi nel breve periodo. Il backtest di una strategia intraday presenta alcune criticità da affrontare.
Il primo problema da affrontare riguarda la disponibilità di dati di qualità, dato che i dati intraday affidabili non sono solitamente disponibili gratuitamente. Possiamo uilizzare DTN IQFeed per acquisire le barre intraday al minuto. Di conseguenza, per ottenere i dati richiesti da questa strategia, dobbiamo disporre di un account attivo su DTN, che fornisce l’accesso al feed.
Il secondo problema riguarda il tempo necessario per eseguire il backtest di un strategia intraday, soprattutto utilizzando il sistema event-driven come spiegato nel corso “Trading System Event Driven con Python“. Quando testiamo un portafoglio diversificato su dati al minuto per più anni e ottimizziamo i parametri, le simulazioni possono richiedere ore o addirittura giorni.
Dobbiamo quindi tenere conto di questi tempi durante il processo di ricerca e ottimizzazione della strategia che vogliamo implementare.
Il terzo problema riguarda la completa automazione dell’esecuzione nel trading live, poiché ci muoviamo nell’ambito delle strategie ad alta frequenza. Per garantire performance affidabili, dobbiamo costruire un ambiente di esecuzione altamente stabile, privo di errori e pronto a operare in condizioni critiche.
Con questa strategia estendiamo il concetto di strategia multiday, adattandolo all’uso di dati intraday su barre OHLCV al minuto. Rispetto ai dati OHLCV giornalieri, lavoriamo con una granularità maggiore, mantenendo però delle regole operative semplici e ben definite:
- Identifichiamo una coppia di titoli le cui serie temporali mostrano un comportamento statisticamente riconducibile al mean-reverting; usiamo i ticker AREX e WLL.
- Calcoliamo la serie residua della coppia eseguendo una regressione lineare a rotazione su una finestra temporale, ottimizzando il parametro con OLS.
- Costruiamo uno z-score a rotazione della serie residua, utilizzandolo per definire soglie operative di ingresso e uscita per i segnali di trading.
- Entriamo long o short quando superiamo la soglia superiore e siamo fuori dal mercato; usciamo quando scendiamo sotto la soglia inferiore e siamo già esposti.
Potremmo inoltre usare il test Cointegrated Augmented Dickey-Fuller (CADF) per ottenere un parametro di copertura ancora più preciso ed efficace. Questa possibile estensione migliorerebbe l’accuratezza della strategia, rendendo più robusta la selezione delle coppie da utilizzare nel trading.
Implementazione in Python
Dopo l’impostazione dell’ambiente, iniziamo importando le librerie necessarie per eseguire correttamente il backtest di una strategia intraday.
Per questa strategia utilizziamo matplotlib e pandas, strumenti essenziali per la gestione dei dati finanziari. Applichiamo il metodo rolling_apply per calcolare dinamicamente lo z-score su una finestra a rotazione definita dall’utente.
Importiamo statsmodels per calcolare la regressione lineare tramite OLS, utile per stimare il rapporto di copertura fra gli asset. Modifichiamo leggermente DataHandler e Portfolio per eseguire operazioni minute-by-minute usando i dati provenienti da DTN IQFeed.
Copiamo il codice da portfolio.py e data.py nei nuovi file hft_portfolio.py e hft_data.py, modificando le sezioni come illustrato di seguito
# intraday_mr.py
import datetime
import numpy as np
import pandas as pd
import statsmodels.api as sm
from strategy.strategy import Strategy
from event.event import SignalEvent
from backtest.backtest import Backtest
from data.hft_data import HistoricCSVDataHandlerHFT
from portfolio.hft_portfolio import PortfolioHFT
from execution.execution import SimulatedExecutionHandler
Inizializzazione della Strategia
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):
"""
Utilizza i minimi quadrati ordinari (OLS) per eseguire una regressione lineare
continua in modo da determinare il rapporto di hedge tra una coppia di azioni.
Lo z-score delle serie temporali dei residui viene quindi calcolato in modo
continuo e se supera un intervallo di soglie (predefinito a [0,5, 3,0]), viene
generata una coppia di segnali long / short (per la soglia alta) o vengono
generate coppie di segnali di uscita (per la soglia bassa).
"""
def __init__(self, bars, events, ols_window=100,zscore_low=0.5, zscore_high=3.0):
"""
Initializza la strategia di arbitraggio stastistico.
Parametri:
bars - L'oggetto DataHandler che fornisce i dati di mercato
events - L'oggetto Event Queue.
"""
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
Generazione dei Segnali
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:
- Long sul mercato e sotto la più alta soglia negativa dello zscore
- Long il mercato e all’interno del valore assoluto della soglia più alta dello zscore
- Short sul mercato e sopra la maggiore soglia positiva dello z-score
- 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):
"""
Calcola le effettive coppie di segnali x, y da inviare al generatore di segnali.
Parametri
zscore_last - Il punteggio dello z-score su cui eseguire il test
"""
y_signal = None
x_signal = None
p0 = self.pair[0]
p1 = self.pair[1]
dt = self.datetime
hr = abs(self.hedge_ratio)
# Se siamo long sul mercato e al di sotto del
# negativo della soglia alta dello zscore
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)
# Se siamo long sul mercato e tra il
# valore assoluto della soglia bassa dello zscore
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)
# Se siamo short sul mercato e oltre
# la soglia alta dello z-score
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)
# Se siamo short sul mercato e tra il
# valore assoluto della soglia bassa dello z-score
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):
"""
Genera una nuova serie di segnali basati sulla strategia di
ritorno verso la media (mean reversion).
Calcola il rapporto di hedge tra la coppia di ticker.
Usiamo OLS per questo, anche se dovremmo idealmente usare il CADF.
"""
# Otteniamo l'ultima finestra di valori per ogni
# componente della coppia di ticker
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:
# Verificare che tutti i periodi di finestra siano disponibili
if len(y) >= self.ols_window and len(x) >= self.ols_window:
# Calcola l'attuale rapporto di hedge utilizzando OLS
self.hedge_ratio = sm.OLS(y, x).fit().params[0]
# Calcola l'attuale z-score dei residui
spread = y - self.hedge_ratio * x
zscore_last = ((spread - spread.mean()) / spread.std())[-1]
# Calcula i segnali e il aggiunge alla coda degli eventi
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)
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):
"""
Calcula il SignalEvents basato sui dati di mercato.
"""
if event.type == ’MARKET’:
self.calculate_signals_for_pairs()
Esecuzione del Backtest
La funzione __main__ collega insieme i componenti al fine di eseguire il backtest di una strategia intraday. 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' # DA MODIFICARE
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()
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', 'adj_close', 'volume'
]
names=[
'datetime', 'open', 'high',
'low', 'close', 'volume', 'oi'
]
In questo modo si garantisce la compatibilità tra il nuovo formato di DTN IQFeed e il sistema di backtesting.
Gestione del Portafoglio
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:
# Approssimazione al valore reale
market_value = self.current_positions[s] * \
self.bars.get_latest_bar_value(s, "adj_close")
dh[s] = market_value
dh[’total’] += market_value
for s in self.symbol_list:
# Approssimazione al valore reale
market_value = self.current_positions[s] * \
self.bars.get_latest_bar_value(s, "close")
dh[s] = market_value
dh[’total’] += market_value
update_holdings_from_fill. Si deve cambiare il seguente codice:
# Aggiornamento della lista delle holdings con le nuove quantità
fill_cost = self.bars.get_latest_bar_value(
fill.symbol, "adj_close"
)
# Aggiornamento della lista delle holdings con le nuove quantità
fill_cost = self.bars.get_latest_bar_value(
fill.symbol, "close"
)
Le Performance
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)
sharpe_ratio = create_sharpe_ratio(returns, periods=252*6.5*60)
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 il backtest di una strategia intraday 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
Per valutare le performance del backtest di una strategia intraday possiamo 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
)
# Visualizza tre grafici: curva di Equity,
# rendimenti, drawdown
fig = plt.figure()
# Imposta il bianco come colore di sfondo
fig.patch.set_facecolor('white')
# Visualizza la curva di equity
ax1 = fig.add_subplot(311, ylabel='Portfolio value, % ')
data['equity_curve'].plot(ax=ax1, color="blue", lw=2.)
plt.grid(True)
# Visualizza i rendimenti
ax2 = fig.add_subplot(312, ylabel='Period returns, % ')
data['returns'].plot(ax=ax2, color="black", lw=2.)
plt.grid(True)
# Visualizza i drawdown
ax3 = fig.add_subplot(313, ylabel='Drawdowns, % ')
data['drawdown'].plot(ax=ax3, color="red", lw=2.)
plt.grid(True)
# Stampa i grafici
plt.show()
Utilizzando l’output CSV del backtesting della precedente strategia si ottengono i seguenti grafici:
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.”
