Strategia di Mean Reversion per il Pairs Trading Intraday

Sommario

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


        
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):
    """
    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)
        
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):
    """
    Calcula il SignalEvents basato sui dati di mercato.
    """
    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' # 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()
        
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', 'adj_close', 'volume'
      ]
        
Deve essere sostituito con il seguente:
            
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. 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
        
Per farlo diventare come segue:
            
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
        
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:
            
# Aggiornamento della lista delle holdings con le nuove quantità
    fill_cost = self.bars.get_latest_bar_value(
        fill.symbol, "adj_close"
    )
        
come segue:
            
# Aggiornamento della lista delle holdings con le nuove quantità
    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
    )

    # 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:
Fig - Curva di Equity, Ritorni Giornalieri e Drawdown per la strategia mean-reversion intraday.

Benvenuto su DataTrading!

Sono Gianluca, ingegnere software e data scientist. Sono appassionato di coding, finanza e trading. Leggi la mia storia.

Ho creato DataTrading per aiutare le altre persone ad utilizzare nuovi approcci e nuovi strumenti, ed applicarli correttamente al mondo del trading.

DataTrading vuole essere un punto di ritrovo per scambiare esperienze, opinioni ed idee.

SCRIVIMI SU TELEGRAM

Per informazioni, suggerimenti, collaborazioni...

Torna in alto
Scroll to Top