Backtesting uma estratégia de pares de reversão da média intradiária entre SPY e IWM

Autora:Bem-estar, Criado: 2019-03-28 10:51:06, Atualizado:

Neste artigo, vamos considerar nossa primeira estratégia de negociação intradiária. Usará uma idéia de negociação clássica, a de pares de negociação. Neste caso, vamos usar dois fundos negociados em bolsa (ETFs), SPY e IWM, que são negociados na Bolsa de Valores de Nova York (NYSE) e tentam representar os índices do mercado de ações dos EUA, o S&P500 e o Russell 2000, respectivamente.

A estratégia cria um spread entre o par de ETFs, longing um e shorting uma quantidade do outro. A proporção de longo para curto pode ser definida de muitas maneiras, como utilizando técnicas de cointegração estatística de séries de tempo. Neste cenário, vamos calcular uma proporção de cobertura entre SPY e IWM através de uma regressão linear rolante. Isso então nos permitirá criar um spread entre SPY e IWM, que é normalizado para uma pontuação z. Os sinais de negociação serão gerados quando a pontuação z exceder certos limiares sob a crença de que o spread retornará à média.

A lógica da estratégia é que SPY e IWM estão caracterizando aproximadamente a mesma situação, a da economia de um grupo de corporações norte-americanas de grande capitalização e de pequena capitalização. A premissa é que, se tomarmos o spread dos preços, então ele deve ser reversível, uma vez que, embora os eventos local (no tempo) possam afetar os índices S&P500 ou Russell 2000 separadamente (como diferenças de pequena capitalização / grande capitalização, datas de reequilíbrio ou blocos de negociações), as séries de preços de longo prazo dos dois provavelmente serão cointegradas.

A estratégia

A estratégia é executada nas seguintes etapas:

  1. Os dados - barras de 1 minuto de SPY e IWM são obtidos de Abril de 2007 até Fevereiro de 2014.
  2. Processamento - Os dados estão corretamente alinhados e as barras em falta são mutuamente descartadas.
  3. Spread - A relação de cobertura entre os dois ETFs é calculada tomando uma regressão linear rolante. Isso é definido como o coeficiente de regressão β usando uma janela de retrocesso que se desloca para frente em 1 bar e recalcula os coeficientes de regressão. Assim, a relação de cobertura βi, para bar bi é calculada em todos os pontos bi−1−k a bi−1 para uma retrocesso de k bares.
  4. Z-Score - A pontuação padrão do spread é calculada da maneira usual. Isso significa subtrair a média (amostra) do spread e dividir pelo desvio padrão (amostra) do spread. A razão para isso é tornar os parâmetros de limiar mais diretos para ser interpretados, já que o z-score é uma quantidade sem dimensões.
  5. Trades - Sinais longos são gerados quando a pontuação z negativa cai abaixo de um limite pré-determinado (ou pós-otimizado), enquanto sinais curtos são o inverso disso. Os sinais de saída são gerados quando a pontuação z absoluta cai abaixo de um limite adicional. Para esta estratégia, eu escolhi (um pouco arbitrariamente) um limite de entrada absoluto de 10.000z=2 e um limite de saída de 10.000z=1.

Talvez a melhor maneira de entender a estratégia em profundidade é realmente implementá-la. A seção seguinte descreve um código completo de Python (arquivo único) para implementar essa estratégia de reversão da média.

Implementação do Python

Como com todos os tutoriais Python / pandas, é necessário ter um ambiente de pesquisa Python configurado como descrito neste tutorial. Uma vez configurado, a primeira tarefa é importar as bibliotecas Python necessárias. Para esse backtest, são necessários matplotlib e pandas.

As versões específicas da biblioteca que estou usando são as seguintes:

  • Python - 2.7.3
  • NumPy - 1.8.0
  • Pandas - 0.12.0
  • matplotlib - 1.1.0 Vamos importar as bibliotecas:
# mr_spy_iwm.py

import matplotlib.pyplot as plt
import numpy as np
import os, os.path
import pandas as pd

A seguinte função create_pairs_dataframe importa dois arquivos CSV contendo as barras intradiárias de dois símbolos. No nosso caso, serão SPY e IWM. Em seguida, cria um par separado de quadros de dados, que usa os índices de ambos os arquivos originais. Como seus carimbos de tempo provavelmente serão diferentes devido a trocas e erros perdidos, isso garante que teremos dados correspondentes. Este é um dos principais benefícios de usar uma biblioteca de análise de dados como pandas. O código boilerplate é manuseado para nós de forma muito eficiente.

# mr_spy_iwm.py

def create_pairs_dataframe(datadir, symbols):
    """Creates a pandas DataFrame containing the closing price
    of a pair of symbols based on CSV files containing a datetime
    stamp and OHLCV data."""

    # Open the individual CSV files and read into pandas DataFrames
    print "Importing CSV data..."
    sym1 = pd.io.parsers.read_csv(os.path.join(datadir, '%s.csv' % symbols[0]),
                                  header=0, index_col=0, 
                                  names=['datetime','open','high','low','close','volume','na'])
    sym2 = pd.io.parsers.read_csv(os.path.join(datadir, '%s.csv' % symbols[1]),
                                  header=0, index_col=0, 
                                  names=['datetime','open','high','low','close','volume','na'])

    # Create a pandas DataFrame with the close prices of each symbol
    # correctly aligned and dropping missing entries
    print "Constructing dual matrix for %s and %s..." % symbols    
    pairs = pd.DataFrame(index=sym1.index)
    pairs['%s_close' % symbols[0].lower()] = sym1['close']
    pairs['%s_close' % symbols[1].lower()] = sym2['close']
    pairs = pairs.dropna()
    return pairs

O próximo passo é realizar a regressão linear rolante entre SPY e IWM. Neste caso, IWM é o preditor (x) e SPY é a resposta (y). Eu definiu uma janela de lookback padrão de 100 bares. Como discutido acima, este é um parâmetro da estratégia. Para que a estratégia seja considerada robusta, idealmente queremos ver um perfil de retornos (ou outra medida de desempenho) como uma função convexa do período de lookback. Assim, em uma fase posterior do código, realizaremos uma análise de sensibilidade variando o período de lookback em uma faixa.

Uma vez que o coeficiente de beta rolante é calculado no modelo de regressão linear para SPY-IWM, nós o adicionamos aos pares DataFrame e deixamos as linhas vazias. Isso constitui o primeiro conjunto de barras igual ao tamanho do lookback como uma medida de corte. Nós então criamos o espalhamento dos dois ETFs como uma unidade de SPY e −βi unidades de IWM. Claramente esta não é uma situação realista, pois estamos tomando quantidades fracionárias de IWM, o que não é possível em uma implementação real.

Por fim, criamos a pontuação z do spread, que é calculada subtraindo a média do spread e normalizando pelo desvio padrão do spread. Observe que há um viés de olho bastante sutil ocorrendo aqui. Deixei-o deliberadamente no código porque queria enfatizar o quão fácil é cometer tal erro na pesquisa. A média e o desvio padrão são calculados para toda a série de tempo do spread. Se isso for refletir a verdadeira precisão histórica, então essa informação não estaria disponível, pois implícitamente faz uso de informações futuras. Assim, devemos usar uma média rolante e stdev para calcular o z-score.

# mr_spy_iwm.py

def calculate_spread_zscore(pairs, symbols, lookback=100):
    """Creates a hedge ratio between the two symbols by calculating
    a rolling linear regression with a defined lookback period. This
    is then used to create a z-score of the 'spread' between the two
    symbols based on a linear combination of the two."""
    
    # Use the pandas Ordinary Least Squares method to fit a rolling
    # linear regression between the two closing price time series
    print "Fitting the rolling Linear Regression..."
    model = pd.ols(y=pairs['%s_close' % symbols[0].lower()], 
                   x=pairs['%s_close' % symbols[1].lower()],
                   window=lookback)

    # Construct the hedge ratio and eliminate the first 
    # lookback-length empty/NaN period
    pairs['hedge_ratio'] = model.beta['x']
    pairs = pairs.dropna()

    # Create the spread and then a z-score of the spread
    print "Creating the spread/zscore columns..."
    pairs['spread'] = pairs['spy_close'] - pairs['hedge_ratio']*pairs['iwm_close']
    pairs['zscore'] = (pairs['spread'] - np.mean(pairs['spread']))/np.std(pairs['spread'])
    return pairs

Em create_long_short_market_signals, os sinais de negociação são criados. Estes são calculados indo longo o spread quando a pontuação z excede negativamente uma pontuação z negativa e indo curto o spread quando a pontuação z excede positivamente uma pontuação z positiva. O sinal de saída é dado quando o valor absoluto da pontuação z é menor ou igual a outro limiar (menor em magnitude).

Para alcançar essa situação é necessário saber, para cada barra, se a estratégia é in ou out do mercado. long_market e short_market são duas variáveis definidas para acompanhar as posições de mercado longas e curtas. Infelizmente, isso é muito mais simples de codificar de forma iterativa em oposição a uma abordagem vetorizada e, portanto, é lento de calcular. Apesar de barras de 1 minuto exigindo ~ 700.000 pontos de dados por arquivo CSV, ainda é relativamente rápido de calcular em minha máquina desktop mais antiga!

Para iterar sobre um DataFrame panda (que não é uma operação comum) é necessário usar o método iterrows, que fornece um gerador sobre o qual iterar:

# mr_spy_iwm.py

def create_long_short_market_signals(pairs, symbols, 
                                     z_entry_threshold=2.0, 
                                     z_exit_threshold=1.0):
    """Create the entry/exit signals based on the exceeding of 
    z_enter_threshold for entering a position and falling below
    z_exit_threshold for exiting a position."""

    # Calculate when to be long, short and when to exit
    pairs['longs'] = (pairs['zscore'] <= -z_entry_threshold)*1.0
    pairs['shorts'] = (pairs['zscore'] >= z_entry_threshold)*1.0
    pairs['exits'] = (np.abs(pairs['zscore']) <= z_exit_threshold)*1.0

    # These signals are needed because we need to propagate a
    # position forward, i.e. we need to stay long if the zscore
    # threshold is less than z_entry_threshold by still greater
    # than z_exit_threshold, and vice versa for shorts.
    pairs['long_market'] = 0.0
    pairs['short_market'] = 0.0

    # These variables track whether to be long or short while
    # iterating through the bars
    long_market = 0
    short_market = 0

    # Calculates when to actually be "in" the market, i.e. to have a
    # long or short position, as well as when not to be.
    # Since this is using iterrows to loop over a dataframe, it will
    # be significantly less efficient than a vectorised operation,
    # i.e. slow!
    print "Calculating when to be in the market (long and short)..."
    for i, b in enumerate(pairs.iterrows()):
        # Calculate longs
        if b[1]['longs'] == 1.0:
            long_market = 1            
        # Calculate shorts
        if b[1]['shorts'] == 1.0:
            short_market = 1
        # Calculate exists
        if b[1]['exits'] == 1.0:
            long_market = 0
            short_market = 0
        # This directly assigns a 1 or 0 to the long_market/short_market
        # columns, such that the strategy knows when to actually stay in!
        pairs.ix[i]['long_market'] = long_market
        pairs.ix[i]['short_market'] = short_market
    return pairs

Neste estágio, temos pares atualizados para conter os sinais longos/cortos reais, o que nos permite determinar se precisamos estar no mercado. Agora precisamos criar uma carteira para acompanhar o valor de mercado das posições. A primeira tarefa é criar uma coluna de posições que combina os sinais longos e curtos. Isso contém uma lista de elementos de (1,0,−1), com 1 representando uma posição longa/mercado, 0 representando nenhuma posição (deve sair) e −1 representando uma posição curta/mercado. As colunas sym1 e sym2 representam os valores de mercado das posições SPY e IWM no fechamento de cada barra.

Uma vez que os valores de mercado do ETF foram criados, nós os somamos para produzir um valor total de mercado no final de cada barra. Isso é então transformado em um fluxo de retornos pelo método pct_change para esse objeto da série. Linhas subsequentes de código limpam as entradas erradas (elementos NaN e inf) e, finalmente, calculam a curva de equidade completa.

# mr_spy_iwm.py

def create_portfolio_returns(pairs, symbols):
    """Creates a portfolio pandas DataFrame which keeps track of
    the account equity and ultimately generates an equity curve.
    This can be used to generate drawdown and risk/reward ratios."""
    
    # Convenience variables for symbols
    sym1 = symbols[0].lower()
    sym2 = symbols[1].lower()

    # Construct the portfolio object with positions information
    # Note that minuses to keep track of shorts!
    print "Constructing a portfolio..."
    portfolio = pd.DataFrame(index=pairs.index)
    portfolio['positions'] = pairs['long_market'] - pairs['short_market']
    portfolio[sym1] = -1.0 * pairs['%s_close' % sym1] * portfolio['positions']
    portfolio[sym2] = pairs['%s_close' % sym2] * portfolio['positions']
    portfolio['total'] = portfolio[sym1] + portfolio[sym2]

    # Construct a percentage returns stream and eliminate all 
    # of the NaN and -inf/+inf cells
    print "Constructing the equity curve..."
    portfolio['returns'] = portfolio['total'].pct_change()
    portfolio['returns'].fillna(0.0, inplace=True)
    portfolio['returns'].replace([np.inf, -np.inf], 0.0, inplace=True)
    portfolio['returns'].replace(-1.0, 0.0, inplace=True)

    # Calculate the full equity curve
    portfolio['returns'] = (portfolio['returns'] + 1.0).cumprod()
    return portfolio

OprincipalA função reúne tudo. Os arquivos CSV intradiários estão localizados no caminho datadir. Certifique-se de modificar o código abaixo para apontar para o seu diretório particular.

Para determinar o quão sensível a estratégia é ao período de lookback, é necessário calcular uma métrica de desempenho para um intervalo de lookbacks. Escolhi o retorno porcentual total final do portfólio como medida de desempenho e o intervalo de lookback em [50,200] com incrementos de 10. Você pode ver no código a seguir que as funções anteriores são envolvidas em um loop for em todo esse intervalo, com outros limiares mantidos fixos.

# mr_spy_iwm.py

if __name__ == "__main__":
    datadir = '/your/path/to/data/'  # Change this to reflect your data path!
    symbols = ('SPY', 'IWM')

    lookbacks = range(50, 210, 10)
    returns = []

    # Adjust lookback period from 50 to 200 in increments
    # of 10 in order to produce sensitivities
    for lb in lookbacks: 
        print "Calculating lookback=%s..." % lb
        pairs = create_pairs_dataframe(datadir, symbols)
        pairs = calculate_spread_zscore(pairs, symbols, lookback=lb)
        pairs = create_long_short_market_signals(pairs, symbols, 
                                                z_entry_threshold=2.0, 
                                                z_exit_threshold=1.0)

        portfolio = create_portfolio_returns(pairs, symbols)
        returns.append(portfolio.ix[-1]['returns'])

    print "Plot the lookback-performance scatterchart..."
    plt.plot(lookbacks, returns, '-o')
    plt.show()

O gráfico do período de lookback versus retornos agora pode ser visto. Observe que há um máximo global em torno de um lookback igual a 110 bares. Se tivéssemos visto uma situação em que o lookback era independente dos retornos, isso teria sido motivo de preocupação:imgAnálise de sensibilidade do período de referência do índice de cobertura de regressão linear SPY-IWM

Nenhum artigo de backtesting seria completo sem uma curva de equidade com inclinação ascendente! Assim, se você deseja traçar uma curva dos retornos acumulados versus o tempo, você pode usar o seguinte código. Ele traçará o portfólio final gerado a partir do estudo do parâmetro lookback. Assim, será necessário escolher o lookback dependendo do gráfico que você deseja visualizar. O gráfico também traça os retornos do SPY no mesmo período para ajudar na comparação:

# mr_spy_iwm.py

    # This is still within the main function
    print "Plotting the performance charts..."
    fig = plt.figure()
    fig.patch.set_facecolor('white')

    ax1 = fig.add_subplot(211,  ylabel='%s growth (%%)' % symbols[0])
    (pairs['%s_close' % symbols[0].lower()].pct_change()+1.0).cumprod().plot(ax=ax1, color='r', lw=2.)

    ax2 = fig.add_subplot(212, ylabel='Portfolio value growth (%%)')
    portfolio['returns'].plot(ax=ax2, lw=2.)

    fig.show()

O seguinte gráfico da curva de participação é para um período retrospectivo de 100 dias:imgAnálise de sensibilidade do período de referência do índice de cobertura de regressão linear SPY-IWM

Observe-se que a redução do SPY foi significativa em 2009, durante o período da crise financeira. A estratégia também teve um período volátil nesta fase. Observe-se também que o desempenho se deteriorou ligeiramente no último ano devido à natureza fortemente tendente do SPY neste período, que reflete o índice S&P500.

Observe que ainda temos que levar em conta o viés do look-head ao calcular a pontuação z do spread. Além disso, todos esses cálculos foram realizados sem custos de transação. Esta estratégia certamente funcionaria muito mal quando esses fatores forem levados em consideração. As taxas, o spread bid/ask e o slippage não são atualmente contabilizados. Além disso, a estratégia está negociando em unidades fracionárias de ETFs, o que também é muito irrealista.

Em artigos posteriores, criaremos um backtester muito mais sofisticado, orientado por eventos, que levará esses fatores em consideração e nos dará significativamente mais confiança em nossa curva de equidade e métricas de desempenho.


Mais.