In questo articolo descriviamo come implementare il backtest di strategia mean-reverting cross-sectional con Backtrader e ne testiamo le prestazioni per il trading algoritmico. La strategia è tratta dal libro di Ernest Chan Algorithmic Trading: Winning Strategies and Their Rationale.
Per approfondimenti sul framework open-source in Python è possibile leggere gli altri articoli relativi a Backtrader su Datatrading.
Tipicamente, una strategia mean-reverting cross-sectional è alimentata da un universo di titoli azionari, dove ogni azione ha i propri rendimenti relativi rispetto ai rendimenti medi dell’universo. Un titolo con un rendimento relativo positivo è venduto allo scoperto mentre si acquista un titolo con un rendimento relativo negativo. L’idea è che i titoli che ha avuto una performance inferiore o superiore a quella dell’universo ritornino verso la media dell’universo.
La strategia descritta nel libro di Chan è la seguente: ogni giorno, ad ogni azione \(i\) dell’universo si assegna un peso \(w_i\) secondo la seguente formula:
\(w_i = -(r_i – r_m) / \sum_k | r_k – r_m |\)
Dove \(r_m\) è il rendimento medio dell’universo. Il peso \(w_i\) identifica la quantità di portafoglio che è long o short per lo specifico titolo \(i\). Come possiamo vedere dalla formula, il perso di un singolo titolo è maggiore quanto più i suoi rendimenti sono lontani dalla media.
Raccolta dei dati
Per testare la strategia mean-reverting cross-sectional con Backtrader dobbiamo selezionare un universo di azioni. In questo caso usiamo l’S&P 500. Vogliamo scaricare e memorizzare su file i dati giornalieri di tutti i ticker che compongono l’S&P 500. In questo modo non dobbiamo scaricare nuovamente i dati ad ogni esecuzione del backtest. Iniziamo leggendo l’elenco dei ticker da Wikipedia e salvarli in un file spy/tickers.csv
.
import pandas as pd
import yfinance as yf
import backtrader as bt
import numpy as np
from datetime import datetime
data = pd.read_html('https://en.wikipedia.org/wiki/List_of_S%26P_500_companies')
table = data[0]
tickers = table[1:]['Symbol'].tolist()
pd.Series(tickers).to_csv("spy/tickers.csv")
Ora che abbiamo l’elenco dei ticker, possiamo scaricare tutti i dati degli ultimi 5 anni. Usiamo concurrent.futures.ThreadPoolExecutor
per accelerare il compito.
from concurrent import futures
end = datetime.now()
start = datetime(end.year - 5, end.month , end.day)
bad = []
def download(ticker):
df = yf.download(ticker, start, end)
df.to_csv(f"spy/{ticker}.csv")
with futures.ThreadPoolExecutor(50) as executor:
res = executor.map(download, tickers)
spy
! Adesso possiamo iniziare a scrivere la strategia.
Strategia mean-reverting cross-sectional con Backtrader
Possiamo implementare la strategia completa utilizzando la formula precedente.
class CrossSectionalMR(bt.Strategy):
def prenext(self):
self.next()
def next(self):
# usiamo solo i dati che hanno valori nella giornata precederte
available = list(filter(lambda d: len(d), self.datas))
rets = np.zeros(len(available))
for i, d in enumerate(available):
# calcola i singolo rendimenti giornalieri
rets[i] = (d.close[0] - d.close[-1]) / d.close[-1]
# calcola i pesi tramite la formula
market_ret = np.mean(rets)
weights = -(rets - market_ret)
weights = weights / np.sum(np.abs(weights))
for i, d in enumerate(available):
self.order_target_percent(d, target=weights[i])
next()
di una strategia solo quando ha un tick di prezzo da ogni feed di dati. Ciò significa che per impostazione predefinita la strategia non verrà eseguita se, ad esempio, una società nell’universo non ha ancora iniziato a fare trading pubblicamente. Possiamo aggirare questo problema chiamando next()
e prenext()
quindi applicando la formula di calcolo del peso solo alle azioni per le quali disponiamo di dati.
Backtesting
Siamo pronti per il backtest! Vediamo come funziona questa strategia con un capitale iniziale di $ 1.000.000.
cerebro = bt.Cerebro(stdstats=False)
cerebro.broker.set_coc(True)
for ticker in tickers:
data = bt.feeds.GenericCSVData(
fromdate=start,
todate=end,
dataname=f"spy/{ticker}.csv",
dtformat=('%Y-%m-%d'),
openinterest=-1,
nullvalue=0.0,
plot=False
)
cerebro.adddata(data)
cerebro.broker.setcash(1_000_000)
cerebro.addobserver(bt.observers.Value)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, riskfreerate=0.0)
cerebro.addanalyzer(bt.analyzers.Returns)
cerebro.addanalyzer(bt.analyzers.DrawDown)
cerebro.addstrategy(CrossSectionalMR)
results = cerebro.run()
print(f"Sharpe: {results[0].analyzers.sharperatio.get_analysis()['sharperatio']:.3f}")
print(f"Norm. Annual Return: {results[0].analyzers.returns.get_analysis()['rnorm100']:.2f}%")
print(f"Max Drawdown: {results[0].analyzers.drawdown.get_analysis()['max']['drawdown']:.2f}%")
cerebro.plot()[0][0]
Conclusione
Come possiamo vedere l’algoritmo della strategia mean-reverting cross-sectional con Backtrader ha funzionato abbastanza bene! Aveva un Sharpe Ratio di 1,17 con un rendimento annuo normalizzato del 7,9%. Notiamo che i rendimenti non sono molto maggiori rispetto al buy&hold di SPY ma la volatilità è notevolmente ridotta. Dobbiamo però tenere in considerazione alcune cose:
Il set di dati che stiamo utilizzando potrebbe avere un leggero bias di sopravvivenza in quanto non contiene società che erano presenti nell’S&P 500 5 anni fa e quelle che da allora sono state rimosse.
Il backtest presuppone che calcoli i pesi alla chiusura del mercato e quindi sia in grado di negoziare all’esatto prezzo di chiusura del mercato. In realtà non possiamo calcolare i pesi con l’esatto prezzo di chiusura, ma ad un prezzo molto vicino.
Poiché questo particolare algoritmo negozia contemporaneamente tutte le azioni dell’universo, l’investitore avrà bisogno di una grande quantità di capitale per abbinare accuratamente i pesi calcolati.
Codice completo
In questo articolo abbiamo descritto come eseguire il backtest di una strategia mean-reverting cross-sectional con Backtrader per il trading algoritmico. Per il codice completo riportato in questo articolo, si può consultare il seguente repository di github:
https://github.com/datatrading-info/BackTrader