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.

Recommended Posts