In questo articolo descriviamo come implementare il backtest della strategia momentum “Stocks on the Move” con Backtrader per il trading algoritmico. La strategia è descritta in “Stocks on the Move” Beating the Market with Hedge Fund Momentum Strategy, libro di Andreas F. Clenow e testare la sua performance usando il set di dati privo di bias di sopravvivenza che abbiamo creato nel precedente articolo.
Le strategie di momentum sono quasi l’opposto delle strategie di ritorno alla media. Una tipica strategia di momentum prevede di acquistare le azioni che hanno mostrato un trend al rialzo nella speranza che il trend continui. La strategia di momentum definita nei libri di Clenow si basa sulle seguenti regole:
- Fare trading una volta alla settimana. Nel suo libro, Clenow fa trading ogni mercoledì. Ma sottolinea che il giorno è del tutto arbitrario.
- Classificare i titoli nell’S&P 500 in base al momentum. Il momentum viene calcolato moltiplicando la pendenza di regressione esponenziale annualizzata degli ultimi 90 giorni per il coefficiente \(R^2\) del calcolo di regressione.
- La dimensione della posizione viene calcolata usando l’Average True Range di 20 giorni di ciascuna azione, moltiplicato per 10 punti base del valore del portafoglio.
- Aprire nuove posizioni solo se l’S&P 500 è al di sopra della sua media mobile a 200 giorni.
- Ogni settimana, vendere azioni che non si trovano nelle prime 20 posizioni della classifica del momentum o che sono scese al di sotto della media mobile a 100 giorni. Acquistare le azioni nelle prime 20 posizioni della classifica con il denaro rimanente.
- Ogni due settimane, ribilanciare le posizioni esistenti con i valori Average True Range aggiornati.
Prima di eseguire il backtest della strategia momentum “Stocks on the Move” con Backtrader, esaminiamo le formule del momentum e della dimensione della posizione.
Momentum
Nella strategia momentum descritta in “Stock on the Move”, il momentum viene calcolato moltiplicando la pendenza della regressione esponenziale annualizzata degli ultimi 90 giorni per il coefficiente \(R^2\) del calcolo di regressione.
Come verifica, diamo un’occhiata ai maggiori valori di momentum calcolati per il set di dati. Per prima cosa dobbiamo caricare il set di dati:
from datetime import datetime
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
plt.rcParams["figure.figsize"] = (10, 6) # (w, h)
plt.ioff()
tickers = pd.read_csv('data/tickers.csv', header=None)[1].tolist()
stocks = (
(pd.concat(
[pd.read_csv(f"data/{ticker}.csv", index_col='date', parse_dates=True)[
'close'
].rename(ticker)
for ticker in tickers],
axis=1,
sort=True)
)
)
stocks = stocks.loc[:, ~stocks.columns.duplicated()]
Creiamo la funzione per il calcolo del momentum. Possiamo calcolare la regressione esponenziale di un titolo eseguendo una regressione lineare sul logaritmo naturale delle chiusure giornaliere del titolo.
from scipy.stats import linregress
def momentum(closes):
returns = np.log(closes)
x = np.arange(len(returns))
slope, _, rvalue, _, _ = linregress(x, returns)
return ((1 + slope) ** 252) * (rvalue ** 2) # Annualizza la pendenza e moltiplica per R^2
Applichiamo il calcolo del momentum su una finestra mobile di 90 giorni a tutti i titoli azionari del nostro universo.
momentums = stocks.copy(deep=True)
for ticker in tickers:
momentums[ticker] = stocks[ticker].rolling(90).apply(momentum, raw=False)
Diamo un’occhiata ai 5 titoli con i migliori valori di momentum e tracciamoli insieme alla loro curva di regressione.
plt.figure(figsize=(12, 9))
plt.xlabel('Days')
plt.ylabel('Stock Price')
bests = momentums.max().sort_values(ascending=False).index[:5]
for best in bests:
end = momentums[best].index.get_loc(momentums[best].idxmax())
rets = np.log(stocks[best].iloc[end - 90 : end])
x = np.arange(len(rets))
slope, intercept, r_value, p_value, std_err = linregress(x, rets)
plt.plot(np.arange(180), stocks[best][end-90:end+90])
plt.plot(x, np.e ** (intercept + slope*x))
plt.show()
Come possiamo vedere, le curve di regressione si adattano abbastanza bene a ciascun titolo; Le azioni non sembrano seguire la curva al di fuori della finestra di misurazione, ma è importante ricordare che questo indicatore di momentum viene utilizzato solo per classificare le azioni e non tenta in alcun modo di prevedere i prezzi.
Dimensionamento a parità di rischio
La strategia di Clenow utilizza l’allocazione a Risk Parity per calcolare le dimensioni delle posizioni di ciascun titolo. Ad ogni azione viene assegnata una dimensione tramite la seguente formula:
\(Size = {{AccountValue\times RiskFactor} \over {{ATR}_{20}}}\)
Dove \({ATR}_{20}\) è l’Average True Range di un titolo negli ultimi 20 giorni.
Nel nostro caso il fattore di rischio è di 10 punti base (0,1%). In altre parole se supponiamo che l’ATR di ciascun titolo rimanga simile in futuro, allora ciascun titolo ha un impatto giornaliero pari allo 0,1% sul nostro portafoglio. Stiamo semplicemente normalizzando i pesi dei titoli nel nostro portafoglio in base al rischio.
Ora che abbiamo capito come funziona la strategia, eseguiamo il backtest!
Strategia momentum “Stocks on the Move” con Backtrader.
Per prima cosa codifichiamo l’indicatore Momentum
e la nostra strategia:
import backtrader as bt
class Momentum(bt.Indicator):
lines = ('trend',)
params = (('period', 90),)
def __init__(self):
self.addminperiod(self.params.period)
def next(self):
returns = np.log(self.data.get(size=self.p.period))
x = np.arange(len(returns))
slope, _, rvalue, _, _ = linregress(x, returns)
annualized = (1 + slope) ** 252
self.lines.trend[0] = annualized * (rvalue ** 2)
class Strategy(bt.Strategy):
def __init__(self):
self.i = 0
self.inds = {}
self.spy = self.datas[0]
self.stocks = self.datas[1:]
self.spy_sma200 = bt.indicators.SimpleMovingAverage(self.spy.close, period=200)
for d in self.stocks:
self.inds[d] = {}
self.inds[d]["momentum"] = Momentum(d.close, period=90)
self.inds[d]["sma100"] = bt.indicators.SimpleMovingAverage(d.close, period=100)
self.inds[d]["atr20"] = bt.indicators.ATR(d, period=20)
def prenext(self):
# chiamata al metodo next() anche quando non ci sono i dati disponibili per tutti i ticker
self.next()
def next(self):
if self.i % 5 == 0:
self.rebalance_portfolio()
if self.i % 10 == 0:
self.rebalance_positions()
self.i += 1
def rebalance_portfolio(self):
# filtra i dati per i quali possiamo calcolare l'indicatore
self.rankings = list(filter(lambda d: len(d) > 100, self.stocks))
self.rankings.sort(key=lambda d: self.inds[d]["momentum"][0])
num_stocks = len(self.rankings)
# vende le azioni che rispettano le condizioni
for i, d in enumerate(self.rankings):
if self.getposition(self.data).size:
if i > num_stocks * 0.2 or d < self.inds[d]["sma100"]:
self.close(d)
if self.spy < self.spy_sma200:
return
# acquista azioni con il capitale rimanente
for i, d in enumerate(self.rankings[:int(num_stocks * 0.2)]):
cash = self.broker.get_cash()
value = self.broker.get_value()
if cash <= 0:
break
if not self.getposition(self.data).size:
size = value * 0.001 / self.inds[d]["atr20"]
self.buy(d, size=size)
def rebalance_positions(self):
num_stocks = len(self.rankings)
if self.spy < self.spy_sma200:
return
# ribilanciamento delle azioni
for i, d in enumerate(self.rankings[:int(num_stocks * 0.2)]):
cash = self.broker.get_cash()
value = self.broker.get_value()
if cash <= 0:
break
size = value * 0.001 / self.inds[d]["atr20"]
self.order_target_size(d, size)
Come possiamo vedere nel codice, la strategia è composta dal metodo rebalance_portfolio
che cerca le azioni che deve vendere ogni settimana e dal metodo rebalance_positions
che ribilancia tutte le sue posizioni ogni due settimane.
Eseguiamo un backtest.
cerebro = bt.Cerebro(stdstats=False)
cerebro.broker.set_coc(True)
spy = bt.feeds.YahooFinanceData(dataname='SPY',
fromdate=datetime(2012,2,28),
todate=datetime(2018,2,28),
plot=False)
cerebro.adddata(spy) # aggiunge l'indice S&P 500
for ticker in tickers:
df = pd.read_csv(f"survivorship-free/{ticker}.csv",
parse_dates=True,
index_col=0)
if len(df) > 100: # i dati devono essere abbastanza per calcolare la SMA a 100 giorni
cerebro.adddata(bt.feeds.PandasData(dataname=df, plot=False))
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)
results = cerebro.run()
cerebro.plot(iplot=False)[0][0]
plt.show()
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}%")
Sharpe: 1.269
Norm. Annual Return: 8.99%
Max Drawdown: 11.71%
Come possiamo vedere l’algoritmo funziona abbastanza bene. Rende una media di quasi il 9% all’anno con un drawdown massimo di solo l’11%. Sebbene l’S&P 500 superi leggermente l’algoritmo in questo periodo di tempo (CAGR del 12%), lo fa con maggiore volatilità (Max Drawdown del 13,5%, Sharpe di 1,07). Nel complesso, questo algoritmo fornisce una buona base per una strategia di momentum e può probabilmente essere migliorato modificando i parametri, applicando filtri e aggiungendo leva finanziaria. Consiglio vivamente di leggere il libro di Clenow Stocks on the Move: Beating the Market with Hedge Fund Momentum Strategy , poiché fornisce una descrizione molto più approfondita di come funziona l’algoritmo, nonché un’analisi dettagliata di come si è comportato storicamente.
Codice completo
In questo articolo abbiamo descritto come implementare il backtest della strategia momentum “Stocks on the Move” 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