Strategia con il Filtro di Kalman per Pairs Trading tramite DataTrader

In questo articolo spieghiamo come implementiamo il backtest di una strategia con il Filtro di Kalman usando il framework Python per il trading basato su eventi DataTrader. La strategia si fonda sui concetti matematici dei modelli spaziali statali e dei filtri di Kalman. Applichiamo questi concetti utilizzando la libreria Python pykalman su una coppia di ETF per regolare dinamicamente un rapporto di copertura come base per una strategia di tipo mean-reverting.

La strategia che vogliamo implementare è stata introdotta da Ernest Chan (2012) [1] e testata da Aidan O’Mahony su Quantopian [2]. Utilizziamo il nostro framework open source DataTrader, sviluppato in Python, per implementare questa strategia. Con DataTrader gestiamo tracciamento delle posizioni, gestione del portafoglio e acquisizione dei dati, mentre ci concentriamo esclusivamente sul codice che genera i segnali di trading.

La strategia con il filtro di Kalman

Applichiamo una strategia di pairs-trading a una coppia di Exchange Traded Funds (ETF) che replicano i rendimenti delle obbligazioni del Tesoro USA di durata diversa. I due ETF sono:

  • TLT – ETF iShares 20+ Year Treasury Bond
  • IEI – iShares 3-7 anni Treasury Bond ETF

Il nostro obiettivo consiste nel costruire una strategia di ritorno alla media usando questa coppia di ETF come strumenti sottostanti.

Lo “spread” sintetico tra TLT e IEI rappresenta la serie storica su cui vogliamo operare, andando long o short in modo sistematico. Utilizziamo il filtro di Kalman per tracciare dinamicamente il rapporto di copertura tra i due ETF, mantenendo stazionario lo spread (e quindi il comportamento mean-reverting).

Per stabilire regole di trading efficaci dobbiamo capire quando lo spread si discosta eccessivamente dal suo valore atteso. Come definiamo questo “troppo lontano”? Potremmo usare soglie fisse, ma dovremmo determinarle empiricamente. Questo introdurrebbe un ulteriore parametro da ottimizzare, aumentando il rischio di overfitting nel modello.

Adottiamo un approccio privo di parametri predefiniti, basandoci su multipli della deviazione standard dello spread da usare come soglie operative. Per semplicità impostiamo il coefficiente di moltiplicazione pari a uno.

Entriamo long sullo spread se l’errore di previsione scende sotto la deviazione standard negativa. Al contrario entriamo short se supera quella positiva. Le regole di uscita coincidono con l’opposto delle rispettive regole di entrata.

Il rapporto di copertura dinamico è dato da un componente del vettore degli stati nascosti al tempo t, \(\theta_t\), che chiamiamo \(\theta^0_t\). Questo rappresenta la pendenza “beta” ben nota nella regressione lineare.

Le regole della strategia

Quando andiamo long sullo spread acquistiamo N unità di TLT e vendiamo \(\lfloor{ \theta^0_t N \rfloor}\) unità di IEI, dove \(\lfloor{ x \rfloor}\) è il più grande intero non superiore a x. Questo arrotondamento è necessario poiché dobbiamo trattare un numero intero di quote ETF. L’operazione short è semplicemente l’opposto, con N che determina la dimensione totale della posizione.

Indichiamo con \(e_t\) l’errore di previsione (o errore residuo) al tempo t, mentre \(Q_t\) rappresenta la varianza di questa previsione nello stesso istante.

Nel dettaglio, la strategia si articola nelle seguenti regole:

  1. \(e_t \lt -\sqrt{Q_t}\) – Entrata Long: acquistiamo N azioni di TLT e vendiamo \(\lfloor{ \theta^0_t N \rfloor}\) azioni di IEI
  2. \(e_t \ge -\sqrt{Q_t}\) – Uscita Long: chiudiamo tutte le posizioni long su TLT e IEI
  3. \(e_t \gt \sqrt{Q_t}\) – Entrata Short: vendiamo N azioni di TLT e acquistiamo \(\lfloor{ \theta^0_t N \rfloor}\) azioni di IEI
  4. \(e_t \le \sqrt{Q_t}\) – Uscita Short: chiudiamo tutte le posizioni short su TLT e IEI

Il filtro di Kalman ci permette di calcolare \(\theta_t\), \(e_t\) e \(Q_t\). \(\theta_t\) rappresenta la pendenza e l’intercetta della regressione lineare tra TLT e IEI al tempo t, stimate dinamicamente. L’errore \(e_t = y_t – \hat{y}_t\) misura la differenza tra il valore attuale di TLT e la sua previsione secondo il filtro. \(Q_t\) rappresenta la varianza della previsione, mentre \(\sqrt{Q_t}\) è la sua deviazione standard.

La strategia prevede le seguenti fasi operative:

  • Acquisiamo le barre giornaliere OHLCV per TLT e IEI.
  • Usiamo il filtro di Kalman in modo ricorsivo per stimare il prezzo odierno di TLT partendo dalle osservazioni di IEI del giorno precedente.
  • Calcoliamo la differenza tra la stima di Kalman e il valore effettivo di TLT, chiamata errore di previsione, per valutare la deviazione dello spread attuale dal valore atteso.
  • Andiamo long se il valore si discosta troppo verso il basso, oppure short se si discosta troppo verso l’alto rispetto alla previsione.
  • Chiudiamo le posizioni long e short quando il valore dello spread ritorna al suo valore previsto.

Scaricare i Dati

Per applicare questa strategia con il filtro di Kalman abbiamo bisogno dei dati OHLCV di prezzo per il periodo incluso nel backtest. In particolare dobbiamo scaricare i seguenti file:

  • TLT – Per il periodo dal 3 agosto 2009 al 1 agosto 2016 (link)
  • IEI – Per il periodo dal 3 agosto 2009 al 1 agosto 2016 (link).

Se vogliamo replicare i risultati, dobbiamo inserire questi dati nella directory indicata dal file di configurazione di DataTrader.

Implementazione Python con DataTrader

Poiché DataTrader gestisce il tracciamento delle posizioni, il portafoglio, i dati e gli ordini, scriviamo solo il codice relativo alla classe Strategy.

La classe Strategy comunica con il PortfolioHandler attraverso la coda degli eventi, usando oggetti SignalEvent. Dobbiamo anche importare la classe astratta base AbstractStrategy.

Ricordiamo che nella versione attuale di DataTrader dobbiamo importare la classe PriceParser. Questa classe ci consente di moltiplicare tutti i prezzi per un grande multiplo (\(10^8\)) per operare sempre con numeri interi nel tracciamento delle posizioni. In questo modo evitiamo problemi di arrotondamento in virgola mobile che si accumulano nei backtest estesi. Infine, dividiamo ogni risultato per PriceParser.PRICE_MULTIPLIER per ottenere il valore corretto.

				
					from math import floor

import numpy as np

from datatrader.price_parser import PriceParser
from datatrader.event import (SignalEvent, EventType)
from datatrader.strategy.base import AbstractStrategy
				
			

Per testare la strategia con il filtro di Kalman dobbiamo creare la classe KalmanPairsTradingStrategy. Il compito di questa classe è determinare quando creare oggetti SignalEvent in base ai messaggi ricevuti dai dati di mercato BarEvent, cioè barre OHLCV giornaliere di TLT e IEI da Yahoo Finance.

Ci sono molti modi diversi per organizzare questa classe. Per facilitare la descrizione del codice, abbiamo codificato tutti i parametri della classe. In particolare abbiamo corretto il valore di \(\delta=10^{-4}\) e di \(v_t=10^{-3}\). Questi rappresentano il rumore del sistema e la varianza del rumore delle misurazioni nel modello del filtro di Kalman. Questi potrebbero anche essere implementati come argomenti di una parola chiave nel costruttore __init__ della classe. Un tale approccio consentirebbe una semplice ottimizzazione dei parametri.

Inizializzazione della Strategia

Il primo compito è impostare i parametri timeinvested in modo che siano uguali a None, poiché verranno aggiornati man mano che i dati di mercato vengono accettati e vengono generati segnali  di trading. latest_prices è una doppia matrice che contiene gli ultimi prezzi di TLT e IEI, utilizzata per comodità all’interno della classe.

Il successivo set di parametri si riferiscono al filtro di Kalman e sono spiegati in modo approfondito nel corso dedicato ai modelli quantitativi delle serie temporali. Il set finale di parametri include days, utilizzato per tracciare quanti giorni sono trascorsi, nonché qtycur_hedge_qty, utilizzato per tracciare le quantità assolute di ETF da acquistare sia per il lato long che per quello short. In questo caso abbiamo impostato 2.000 unità su un patrimonio netto di 100.000 USD.

				
					
class KalmanPairsTradingStrategy(AbstractStrategy):
    """
    Requisiti:
    tickers - Lista dei simboli dei ticker
    events_queue - Manager del sistema della coda degli eventi
    short_window - numero di barre per la moving average di breve periodo
    long_window - numero di barre per la moving average di lungo periodo
    """
    def __init__(
        self, tickers, events_queue
    ):
        self.tickers = tickers
        self.events_queue = events_queue
        self.time = None
        self.latest_prices = np.array([-1.0, -1.0])
        self.invested = None

        self.delta = 1e-4
        self.wt = self.delta / (1 - self.delta) * np.eye(2)
        self.vt = 1e-3
        self.theta = np.zeros(2)
        self.P = np.zeros((2, 2))
        self.R = None
        self.C = None

        self.days = 0
        self.qty = 2000
        self.cur_hedge_qty = self.qty
				
			

Il metodo successivo _set_correct_time_and_price è un metodo “di appoggio” utilizzato per garantire che il filtro Kalman abbia tutte le corrette informazioni sui prezzi disponibili al momento giusto. Ciò è necessario perché in un sistema di backtest basato su eventi come DataTrader le informazioni sul mercato arrivano in sequenza.

Gestione degli eventi

Potremmo trovarci nello scenario dove in giornata K abbiamo ricevuto un prezzo per IEI, ma non TFT. Quindi dobbiamo aspettare fino a che entrambi gli eventi Market di TFT e IEI siano entranti nel ciclo di backtest, attraverso la coda degli eventi. Nel trading dal vivo questo non è un problema poiché questi eventi arriveranno quasi rispetto al periodo di trading di pochi giorni. Tuttavia, in un backtest basato sugli eventi, dobbiamo attendere l’arrivo di entrambi i prezzi prima di aggiornare i valori del filtro di Kalman.

Il codice controlla essenzialmente se l’evento successivo è relativo al giorno corrente. In tal caso, il prezzo corretto viene aggiunto all’elenco latest_price di TLT e IEI. Se abbiamo un nuovo giorno, si ripristina l’elenco latest_price e i prezzi corretti sono di nuovo aggiunti.

In futuro questo tipo di metodo di “pulizia” sarà probabilmente inserito nel codice base di DataTrader, riducendo la necessità di scrivere codice “boilerplate”, ma per ora dobbiamo prevederlo all’interno della nostra strategia.

				
					
    def _set_correct_time_and_price(self, event):
        """
        Impostazione del corretto prezzo e timestamp dell'evento
        estratto in ordine dalla coda degli eventi.
        """
        # Impostazione della prima istanza di time
        if self.time is None:
            self.time = event.time

        # Correzione degli ultimi prezzi, che dipendono dall'ordine in cui
        # arrivano gli eventi delle barre di mercato
        price = event.adj_close_price/PriceParser.PRICE_MULTIPLIER
        if event.time == self.time:
            if event.ticker == self.tickers[0]:
                self.latest_prices[0] = price
            else:
                self.latest_prices[1] = price
        else:
            self.time = event.time
            self.days += 1
            self.latest_prices = np.array([-1.0, -1.0])
            if event.ticker == self.tickers[0]:
                self.latest_prices[0] = price
            else:
                self.latest_prices[1] = price

				
			

Calcolo dei segnali

Il nucleo della strategia con il filtro di Kalman risiede nel metodo calculate_signals. Per prima cosa impostiamo i tempi e i prezzi corretti (come descritto in precedenza). Quindi controlliamo di avere entrambi i prezzi di TLT e IEI, a quel punto possiamo considerare nuovi segnali di trading.

y è impostato pari all’ultimo prezzo di IEI, mentre F è la matrice di osservazione contenente l’ultimo prezzo di TLT, nonché come variabile unitaria per rappresentare l’intercetta della regressione lineare. Il filtro  di Kalman viene successivamente aggiornato con questi ultimi prezzi. Infine calcoliamo l’errore di previsione \(e_t\) e la deviazione standard delle previsioni, \(\sqrt{Q_t}\). Esaminiamo questo codice passo dopo passo, poiché sembra un po’ complicato.

Il primo compito è calcolare il valore scalare y e la matrice di osservazione F, che contengono rispettivamente i prezzi di IEI e e TLT. Calcoliamo la matrice varianza-covarianza R o la impostiamo come una matrice di zeri nel caso non sia stata ancora inizializzata. Successivamente calcoliamo la nuova previsione per l’osservazione yhat così come l’errore di previsione et.

Calcoliamo quindi la varianza delle previsioni di osservazione Qt e la deviazione standard sqrt_Qt. Usiamo le regole di aggiornamento descritte dal modello dello spazio degli stati per ottenere la distribuzione a posteriori degli stati theta, che contiene il rapporto di copertura / pendenza tra i due prezzi:

				
					
    def calculate_signals(self, event):
        """
        Calculo dei segnali della stategia con il filtro di Kalman.
        """
        if event.type == EventType.BAR:
            self._set_correct_time_and_price(event)

            # Opera solo se abbiamo entrambe le osservazioni
            if all(self.latest_prices > -1.0):
                # Creare la matrice di osservazione degli ultimi prezzi di
                # TLT e il valore dell'intercetta nonché il
                # valore scalare dell'ultimo prezzo di IEI
                F = np.asarray([self.latest_prices[0], 1.0]).reshape((1, 2))
                y = self.latest_prices[1]

                # Il valore a priori degli stati \theta_t è una distribuzione
                # gaussiana multivariata con media a_t e varianza-covarianza R_t
                if self.R is not None:
                    self.R = self.C + self.wt
                else:
                    self.R = np.zeros((2, 2))

                # Calcola l'aggiornamento del filtro di Kalman
                # ----------------------------------
                # Calcola la previsione di una nuova osservazione
                # e il relativo errore di previsione
                yhat = F.dot(self.theta)
                et = y - yhat

                # Q_t è la varianza della previsione delle osservazioni
                # e sqrt{Q_t} è la deviazione standard delle previsioni
                Qt = F.dot(self.R).dot(F.T) + self.vt
                sqrt_Qt = np.sqrt(Qt)

                # Il valore a posteriori degli stati \theta_t ha una
                # distribuzione gaussiana multivariata con
                # media m_t e varianza-covarianza C_t
                At = self.R.dot(F.T) / Qt
                self.theta = self.theta + At.flatten() * et
                self.C = self.R - At * F.dot(self.R)

				
			

Infine generiamo i segnali di trading in base ai valori di \(e_t\) e \(\sqrt{Q_t}\). Per fare ciò dobbiamo verificare qual è lo stato delle posizioni – “long”, “short” o “nessuna”.

Da notare che regolare l’attuale quantità di copertura cur_hedge_qty quando andiamo long o short come come la pendenza \(\theta^0_t\) si adegua costantemente nel tempo:

				
					..
        # Opera solo se i giorni sono maggiori del
        # periodo di "riscaldamento"
        if self.days > 1:
            # Se non siamo a mercato...
            if self.invested is None:
                if et < -sqrt_Qt:
                    # Entrata Long
                    print("LONG: %s" % event.time)
                    self.cur_hedge_qty = int(floor(self.qty*self.theta[0]))
                    self.events_queue.put(SignalEvent(self.tickers[1], "BOT", self.qty))
                    self.events_queue.put(SignalEvent(self.tickers[0], "SLD", self.cur_hedge_qty))
                    self.invested = "long"
                elif et > sqrt_Qt:
                    # Entrata Short
                    print("SHORT: %s" % event.time)
                    self.cur_hedge_qty = int(floor(self.qty*self.theta[0]))
                    self.events_queue.put(SignalEvent(self.tickers[1], "SLD", self.qty))
                    self.events_queue.put(SignalEvent(self.tickers[0], "BOT", self.cur_hedge_qty))
                    self.invested = "short"
            # Se siamo a mercato...
            if self.invested is not None:
                if self.invested == "long" and et > -sqrt_Qt:
                    print("CLOSING LONG: %s" % event.time)
                    self.events_queue.put(SignalEvent(self.tickers[1], "SLD", self.qty))
                    self.events_queue.put(SignalEvent(self.tickers[0], "BOT", self.cur_hedge_qty))
                    self.invested = None
                elif self.invested == "short" and et < sqrt_Qt:
                    print("CLOSING SHORT: %s" % event.time)
                    self.events_queue.put(SignalEvent(self.tickers[1], "BOT", self.qty))
                    self.events_queue.put(SignalEvent(self.tickers[0], "SLD", self.cur_hedge_qty))
                    self.invested = None
                    
				
			

Esecuzione del Backtest

Questo è tutto il codice necessario per l’oggetto Strategy. Abbiamo anche bisogno di creare un file di backtest per incapsulare tutta la logica della strategia con il filtro di Kalman e la scelta delle classi da usare. Questa specifica versione è molto simile a quelle  presenti nella  directory examples e sostituisce il capitale di 500.000 USD con 100.000 USD.

Si sostituisce inoltre il FixedPositionSizer con il NaivePositionSizer. Quest’ultimo viene utilizzato per accettare “ingenuamente” le quantità assolute di quote ETF da negoziare così come suggerite nella classe KalmanPairsTradingStrategy. In un ambiente reale sarebbe necessario adeguarlo a seconda degli obiettivi di gestione del rischio del portafoglio.

Ecco il codice completo per kalman_datatrader_backtest.py:

				
					
import datetime

from datatrader import settings
from datatrader.compat import queue
from datatrader.price_parser import PriceParser
from datatrader.price_handler.yahoo_daily_csv_bar import YahooDailyCsvBarPriceHandler
from datatrader.strategy.base import Strategies  # DisplayStrategy
from datatrader.position_sizer.naive import NaivePositionSizer
from datatrader.risk_manager.example import ExampleRiskManager
from datatrader.portfolio_handler import PortfolioHandler
from datatrader.compliance.example import ExampleCompliance
from datatrader.execution_handler.ib_simulated import IBSimulatedExecutionHandler
from datatrader.statistics.tearsheet import TearsheetStatistics
from datatrader.trading_session import TradingSession

from kalman_datatrader_strategy import KalmanPairsTradingStrategy


def run(config, testing, tickers, filename):

    # Impostazione delle variabili necessarie per il backtest
    # Informazioni sul Backtest
    title = ['Kalman Filter Example on TLT and IEI: 100x300']
    initial_equity = 100000.00
    start_date = datetime.datetime(2009, 8, 1)
    end_date = datetime.datetime(2016, 8, 1)
    events_queue = queue.Queue()

    # Uso del Manager dei Prezzi di Yahoo Daily
    price_handler = YahooDailyCsvBarPriceHandler(config.CSV_DATA_DIR, events_queue,
                                 tickers, start_date=start_date, end_date=end_date)

    # Uso della strategia KalmanPairsTrading
    strategy = KalmanPairsTradingStrategy(tickers, events_queue)
    strategy = Strategies(strategy)

    # Uso di un Position Sizer standard
    position_sizer = NaivePositionSizer()

    # Uso di Manager di Risk di esempio
    risk_manager = ExampleRiskManager()

    # Use del Manager di Portfolio di default
    portfolio_handler = PortfolioHandler(
        PriceParser.parse(initial_equity), events_queue, price_handler,
        position_sizer, risk_manager
    )

    # Uso del componente ExampleCompliance
    compliance = ExampleCompliance(config)

    # Uso un Manager di Esecuzione che simula IB
    execution_handler = IBSimulatedExecutionHandler(
        events_queue, price_handler, compliance
    )

    # Uso delle statistiche di default
    statistics = TearsheetStatistics(
        config, portfolio_handler, title=""
    )

    # Settaggio del backtest
    backtest = TradingSession(
        config, strategy, tickers,
        initial_equity, start_date, end_date, events_queue,
        price_handler=price_handler,
        portfolio_handler=portfolio_handler,
        compliance=compliance,
        position_sizer=position_sizer,
        execution_handler=execution_handler,
        risk_manager=risk_manager,
        statistics=statistics,
        sentiment_handler=None,
        title=title, benchmark=None
    )
    results = backtest.start_trading(testing=testing)
    statistics.save(filename)
    return results


def main(config, testing, tickers, filename):
    tickers = tickers.split(",")
    config = settings.from_file(config, testing)
    run(config, testing, tickers, filename)


if __name__ == "__main__":
    config = './datatrader.yml'
    testing = False
    tickers = 'TLT,IEI'
    filename = None
    main(config, testing, tickers, filename)
				
			

Se DataTrader è installato correttamente e i dati sono stati scaricati da Yahoo Finance, il codice può essere eseguito tramite il seguente comando da terminale:

				
					$ python kalman_datatrader_backtest.py
				
			

Grazie ad alcuni miglioramenti, il codice è ottimizzato per lavorare con dati rapresentati come barre OHLCV ed esegue il backtesting in tempi più rapidi.

Risultati della strategia

Una delle funzionalità implementate in DataTrader è quella del “tearsheet” che permette la produzione di un report che raccoglie le statistiche e le performance della strategia.

Il tearsheet  è usato principalmente come report “one pager” di una strategia di trading. La classe TearsheetStatistics nella base di codice DataTrader replica molte delle statistiche  presenti in un tipico report sulle prestazioni della strategia.

I primi due grafici rappresentano rispettivamente la curva equity e la percentuale di drawdown. Sotto questo ci sono i pannelli delle prestazioni mensili e annuali. Infine vengono presentate la curva del patrimonio netto, le statistiche a livello di trade e temporali:

trading-algoritmico-datatrader-kalman-tlt-iei-tearsheet

La curva di equity inizia relativamente piatta per il primo anno della strategia, ma si intensifica rapidamente durante il 2011. Durante il 2012 la strategia diventa significativamente più volatile rimanendo “sott’acqua” fino al 2015 e raggiungendo una percentuale massima di drawdown del 15,79%. La performance aumenta gradualmente dal drawdown alla fine del 2013 fino al 2016.

Questa strategia con il filtro di Kalman ha un CAGR dell’6,93% con uno Sharpe Ratio di 0,55. Ha anche una durata massima di drawdown di 923 giorni – quasi tre anni! Si noti che questa strategia viene eseguita al lordo dei costi di transazione, quindi la performance reale sarebbe probabilmente peggiore.

Conclusioni

Abbiamo descritto un primo approccio ad una strategia con filtro di Kalman per il pairs trading. E’ necessario molto lavoro di ricerca per trasformare questa in una strategia redditizia da implementarla in un trading dal vivo. Le potenziali aree di miglioramenti includono:

  • Ottimizzazione dei parametri – Variazione dei parametri del filtro Kalman tramite la ricerca nella griglia di convalida incrociata o una qualche forma di ottimizzazione di apprendimento automatico. Tuttavia, questo introduce la netta possibilità di overfitting ai dati storici.
  • Selezione degli asset – La scelta di coppie di ETF aggiuntive o alternative aiuterebbe ad aggiungere diversificazione al portafoglio, ma aumenta la complessità della strategia così come il numero di operazioni (e quindi i costi di transazione).

Nei prossimi articoli considereremo come eseguire queste procedure per varie strategie di trading.

Riferimenti

Il codice completo presentato in questo articolo, basato sul framework di trading quantitativo event-driven DataTrader, è disponibile nel seguente repository GitHub: https://github.com/tradingquant-it/DataTrader.

Benvenuto su TradingQuant!

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

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

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

SCRIVIMI SU TELEGRAM

Per informazioni, suggerimenti, collaborazioni...

Torna in alto