In questo articolo descriviamo come implementare una strategia di trading con lo z-score basata su una logica mean-reverting ed effettuare il backtest con Python e Pandas. L’articolo fa parte della miniserie “Backtest di una strategia di mean reverting con gli ETF”.
Gli articoli che fanno parte di questa serie sono:
- Backtest ETF: Web scraping e Database Sqlite3
- Backtest ETF: Creazione di coppie di Ticker
- Backtest ETF: Mean-reverting con Python
- Backtest ETF: Strategia di Trading con lo Z-score
- Backtest ETF: Pair trading con Python
Nell’articolo precedente abbiamo descritto come creare la serie degli spread tra le due serie di prezzi dei ETF (eseguendo prima una regressione lineare per trovare il rapporto di copertura) e abbiamo eseguito un test di Augmented Dickey Fuller, oltre a calcolare l’emivita per verificare se la serie degli spread fosse un candidato decente per una strategia di pair trading profittevole.
Dobbiamo ora concludere lo script con il calcolo dello Z-Score “normalizzato” della serie degli spread e impostare un sistema di entrata e uscita in stile “bollinger-band” in base al quale vengono effettuate operazioni short se lo Z-score normalizzato sale sopra 2 ed esce quando scende sotto 0, e viceversa per operazioni long (cioè lo Z-Score deve scendere sotto -2 per entrare ed uscire quando sale sopra 0).
Lo Z-score normalizzato
Iniziamo calcolando lo Z-Score tramite una finestra mobile per la media e la deviazione standard, impostata pari al valore dell’emivita calcolata nel precedente articolo. In questo modo evitiamo di introdurre un look-forward bias usando la media dell’intero periodo o di introdurre un data-mining bias usando una finestra di ricerca arbitraria che dovrebbe essere ottimizzata.
df1['zScore'] = (df1.spread - meanSpread) / stdSpread
df1['zScore'].plot()
Backtest della strategia
A questo punto dobbiamo aggiungere una colonna nel dataframe per indicare se dovremmo avere posizioni long, short o flat. Possiamo farlo seguendo un paio di passaggi.
Per prima cosa creiamo una colonna chiamata num units long
che prevede le righe con 1 per indicare quando dobbiamo essere long, e le righe rimanenti con uno 0 per indicare nessuna posizione long. Usiamo lo stesso approccio con le posizioni short impostando una colonna chiamata num unit short
dove le righe con valore -1 indicano posizioni short e quelle con 0 indicano che non ci sono posizioni short.
Questo si ottiene con il seguente codice, dove impostiamo anche gli Z-score di entra e uscita rispettivamente a 2 e 0):
entryZscore = 2
exitZscore = 0
# calcolo num units long
df1['long entry'] = ((df1.zScore < - entryZscore) & ( df1.zScore.shift(1) > - entryZscore))
df1['long exit'] = ((df1.zScore > - exitZscore) & (df1.zScore.shift(1) < - exitZscore))
df1['num units long'] = np.nan
df1.loc[df1['long entry'],'num units long'] = 1
df1.loc[df1['long exit'],'num units long'] = 0
df1['num units long'][0] = 0
df1['num units long'] = df1['num units long'].fillna(method='pad')
# calcolo num units short
df1['short entry'] = ((df1.zScore > entryZscore) & ( df1.zScore.shift(1) < entryZscore))
df1['short exit'] = ((df1.zScore < exitZscore) & (df1.zScore.shift(1) > exitZscore))
df1.loc[df1['short entry'],'num units short'] = -1
df1.loc[df1['short exit'],'num units short'] = 0
df1['num units short'][0] = 0
df1['num units short'] = df1['num units short'].fillna(method='pad')
num unit long
e num unit short
per ottenere le numUnits
– la posizione complessiva che dovrebbe avere il nostro portafoglio in quel momento; long (1), short (-1) o flat (0).
Generiamo anche una colonna contenente la variazione percentuale della serie degli spread e una colonna con il rendimento del portafoglio, moltiplicando la variazione percentuale della serie degli spread per la posizione corrente del portafoglio (long, short o flat).
Sommiamo cumulativamente i rendimenti giornalieri per generare la curva di equity cum rets
.
df1['numUnits'] = df1['num units long'] + df1['num units short']
df1['spread pct ch'] = (df1['spread'] - df1['spread'].shift(1)) / ((df1['x'] * abs(df1['hr'])) + df1['y'])
df1['port rets'] = df1['spread pct ch'] * df1['numUnits'].shift(1)
df1['cum rets'] = df1['port rets'].cumsum()
df1['cum rets'] = df1['cum rets'] + 1
Possiamo ora visualizzare la curva equity del portafoglio come segue:
plt.plot(df1['cum rets'])
plt.xlabel("EWC")
plt.ylabel("EWA")
plt.show()
Performance della strategia
Non ci resta che calcolare lo Sharpe Ratio e il Compound Annual Growth Rate (CAGR) per valutare le prestazione della strategia:
sharpe = ((df1['port rets'].mean() / df1['port rets'].std()) * sqrt(252))
start_val = 1
end_val = df1['cum rets'].iat[-1]
start_date = df1.iloc[0].name
end_date = df1.iloc[-1].name
days = (end_date - start_date).days
CAGR = round(((float(end_val) / float(start_val)) ** (252.0 / days)) - 1, 4)
print("CAGR = {}%".format(CAGR * 100))
print("Sharpe Ratio = {}".format(round(sharpe, 2)))
CAGR = 0.95%
Sharpe Ratio = 0.32
Vediamo che il risultato non sembra affatto eccezionale in termini di rendimenti e tenendo conto delle commissioni di transazione e dei costi di negoziazione, abbiamo un rendimento quasi flat.
Il prossimo passo è testare la strategia su diverse coppie di ETF e su diversi intervalli di tempo, come descritto nel prossimo e ultimo articolo relativo alla miniserie “Backtest di una strategia di mean reverting con gli ETF.