In questo articolo vediamo come migliorare una strategia mean-reverting con Backtrader in modo da poter iniziare a fare trading algoritmico dal vivo. Nel precedente articolo abbiamo descritto come implementare questa strategia, tratta dal libro Algorithmic Trading: Winning Strategies and Their Rationale di Ernest Chan.
La strategia
Usiamo lo stesso set di dati S&P 500 che abbiamo creato nel precedente articolo. Iniziamo con il caricare i dati dai file CSV.
from datetime import datetime
import pandas as pd
import backtrader as bt
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams["figure.figsize"] = (10, 6) # (w, h)
plt.ioff()
tickers = pd.read_csv('spy/tickers.csv', header=None)[1].tolist()
start = datetime(2015, 1, 1)
end = datetime(2020, 1, 1)
datas = [bt.feeds.GenericCSVData(
fromdate=start,
todate=end,
dataname=f"spy/{ticker}.csv",
dtformat=('%Y-%m-%d'),
openinterest=-1,
nullvalue=0.0,
plot=False
) for ticker in tickers]
Dobbiamo quindi aggiungere una funzione helper che esegue il backtest e restituisca le metriche importanti.
def backtest(datas, strategy, plot=None, **kwargs):
cerebro = bt.Cerebro(stdstats=False)
cerebro.broker.set_coc(True)
cerebro.broker.setcash(1000000)
for data in datas:
cerebro.adddata(data)
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(strategy, **kwargs)
results = cerebro.run()
if plot:
cerebro.plot(iplot=False)[0][0]
return (results[0].analyzers.drawdown.get_analysis()['max']['drawdown'],
results[0].analyzers.returns.get_analysis()['rnorm100'],
results[0].analyzers.sharperatio.get_analysis()['sharperatio'])
Possiamo ora applicare alcuni miglioramenti.
Migliorare una strategia mean-reverting con backtrader
Numero di posizioni
Come accennato nel precedente articolo, uno dei limiti della strategia era il numero di posizioni aperte contemporaneamente. Se alla strategia fosse assegnato un universo di 500 azioni, le negozierebbe tutte. In questo modo non solo si hanno elevate commissioni, ma è difficile eguagliare i pesi calcolati dalla strategia se non abbiamo un capitale molto elevato. La soluzione a questi limiti è abbastanza semplice. Mentre calcoliamo i pesi possiamo semplicemente scegliere gli n titoli con il peso assoluto maggiore.
Di seguito implementiamo questa logica.
def max_n(array, n):
return np.argpartition(array, -n)[-n:]
class CrossSectionalMR(bt.Strategy):
params = (
('num_positions', 100),
)
def __init__(self):
self.inds = {}
for d in self.datas:
self.inds[d] = {}
self.inds[d]["pct"] = bt.indicators.PercentChange(d.close, period=1)
def prenext(self):
self.next()
def next(self):
available = list(filter(lambda d: len(d), self.datas)) # usa solo i dati disponibili nella giornata precedente
rets = np.zeros(len(available))
for i, d in enumerate(available):
rets[i] = self.inds[d]['pct'][0]
market_ret = np.mean(rets)
weights = -(rets - market_ret)
max_weights_index = max_n(np.abs(weights), self.params.num_positions)
max_weights = weights[max_weights_index]
weights = weights / np.sum(np.abs(max_weights))
for i, d in enumerate(available):
if i in max_weights_index:
self.order_target_percent(d, target=weights[i])
else:
self.order_target_percent(d, 0)
Invece di avere posizioni su ogni titolo dell’universo, possiamo negoziare solamente i primi 100 titoli con i pesi maggiori:
dd, cagr, sharpe = backtest(datas, CrossSectionalMR, plot=True, num_positions=100)
print(f"Max Drawdown: {dd:.2f}%\nAPR: {cagr:.2f}%\nSharpe: {sharpe:.3f}")
Max Drawdown: 12.27%
APR: 9.61%
Sharpe: 1.071
I risultati indicano che diminuendo il numero di posizioni, stiamo aumentando la volatilità del portafoglio. Forse se investiamo solo in azioni con una bassa volatilità possiamo avere risultati migliori.
Filtro di volatilità
Proviamo a usare la stessa formula dei pesi, ma negoziamo solamente i titoli che si trovano nei primi n ordinati per peso e negli ultimi n ordinati secondo la deviazione standard degli ultimi 5 giorni. In questo modo negoziamo solamente azioni che presentano un rendimenti maggiori rispetto ai rendimenti medi dell’universo e una volatilità relativamente bassa.
def min_n(array, n):
return np.argpartition(array, n)[:n]
def max_n(array, n):
return np.argpartition(array, -n)[-n:]
class CrossSectionalMR(bt.Strategy):
params = (
('n', 100),
)
def __init__(self):
self.inds = {}
for d in self.datas:
self.inds[d] = {}
self.inds[d]["pct"] = bt.indicators.PercentChange(d.close, period=5)
self.inds[d]["std"] = bt.indicators.StandardDeviation(d.close, period=5)
def prenext(self):
self.next()
def next(self):
available = list(filter(lambda d: len(d) > 5, self.datas)) # usa solo i dati disponibili nella giornata precedente
rets = np.zeros(len(available))
stds = np.zeros(len(available))
for i, d in enumerate(available):
rets[i] = self.inds[d]['pct'][0]
stds[i] = self.inds[d]['std'][0]
market_ret = np.mean(rets)
weights = -(rets - market_ret)
max_weights_index = max_n(np.abs(weights), self.params.n)
low_volality_index = min_n(stds, self.params.n)
selected_weights_index = np.intersect1d(max_weights_index,
low_volality_index)
if not len(selected_weights_index):
# non si effettua trade
return
selected_weights = weights[selected_weights_index]
weights = weights / np.sum(np.abs(selected_weights))
for i, d in enumerate(available):
if i in selected_weights_index:
self.order_target_percent(d, target=weights[i])
else:
self.order_target_percent(d, 0)
Come possiamo vedere, selezioniamo le azioni da negoziare calcolando l’intersezione tra le azioni con peso massimo e le azioni con deviazione standard minima. Eseguiamo il backtest.
dd, cagr, sharpe = backtest(datas, CrossSectionalMR, plot=True, n=100)
print(f"Max Drawdown: {dd:.2f}%\nAPR: {cagr:.2f}%\nSharpe: {sharpe:.3f}")
Max Drawdown: 17.77%
APR: 23.30%
Sharpe: 1.668
La strategia di mean-reverting cross sectional con filtro di volatilità ha un rendimento medio annuo del 23,3% con un o Sharpe Ratio di 1,688. Questa è la strategia con le migliori prestazioni che abbiamo ottenuto finora. Da notare che abbiamo introdotto alcuni parametri (il numero di posizioni e il periodo della deviazione standard) che possiamo ottimizzare (e potenzialmente overfittare).
Infine sottolineiamo che, come nel precedente algoritmo mean-reverting, non consideriamo il bias di sopravvivenza nell’S&P 500 nel periodo di 5 anni del nostro backtest.
Il prossimo passo è provare a fare trading algoritmico dal vivo con questa strategia! Per ulteriori idee e strategie possiamo dare un’occhiata al libro Algorithmic Trading: Winning Strategies and Their Rationale.
Codice completo
In questo articolo abbiamo descritto come migliorare una strategia mean-reverting con Backtrader per poter iniziare a fare trading algoritmico dal vivo. Per il codice completo riportato in questo articolo, si può consultare il seguente repository di github:
https://github.com/datatrading-info/BackTrader