In questo articolo presentiamo una semplice strategia di ribilanciamento mensile di ETF con pesi fissi tra azioni e obbligazioni. Vogliamo dimostrare come funzioni il ribilanciamento implementate nel framework di backtesting e live trading ad eventi DataTrader.
La strategia non introduce nulla di nuovo – si tratta infatti del classico portafoglio bilanciato 60/40 – ma l’implementazione in DataTrader offre grande flessibilità operativa. Possiamo usarla come punto di partenza per esplorare mix alternativi e interessanti di ETF long-only a basso turnover.
Introduzione
Molti gestori istituzionali rispettano obblighi contrattuali che li vincolano a investire esclusivamente in strategie long-only con leva finanziaria nulla o molto bassa. Questa condizione li porta spesso a implementare strategie altamente correlate al “mercato”, che di solito coincide con l’indice S&P500.
Non possiamo ridurre al minimo tale correlazione senza introdurre una copertura short del mercato, ma possiamo attenuarla investendo in ETF non azionari.
Queste strategie adottano una frequenza di ribilanciamento settimanale, mensile o talvolta annuale, quindi si distaccano dalle classiche strategie intraday di arbitraggio statistico. Ciononostante, possiamo trattarle efficacemente in modo completamente sistematico, adottando lo stesso rigore operativo delle strategie quantitative a più alta frequenza.
Nel framework open-source di TradingQuant, DataTrader, supportiamo nativamente il ribilanciamento automatico del portafoglio in fase di backtest o live trading. Abbiamo infatti integrato la logica di ribilanciamento direttamente nelle classi Strategy
e PositionSizer
per semplificare la costruzione delle strategie ETF.
La strategia di ribilanciamento mensile di ETF
La strategia prevede il trading su due ETF che seguono fedelmente i movimenti delle azioni statunitensi (SPY) e delle obbligazioni investment-grade (AGG):
Ogni primo giorno utile del mese, acquistiamo entrambi gli ETF, allocando il 60% del capitale su SPY e il restante 40% su AGG. Alla chiusura dell’ultimo giorno di ogni mese liquidiamo completamente le posizioni e ricreiamo l’allocazione 60/40 in base al nuovo valore del capitale.
Scaricare i dati storici
Per testare il ribilanciamento mensile di ETF dobbiamo disporre dei dati OHLCV relativi al periodo specificato per questo backtest.
In particolare, dobbiamo scaricare i seguenti dataset:
- SPY – Periodo dal 1 novembre 2006 al 1 gennaio 2017
- AGG – Periodo dal 1 novembre 2006 al 1 gennaio 2017
Per replicare il backtest, scarichiamo e copiamo questi file nella directory specificata nel file di configurazione di DataTrader.
Implementazione Python con DataTrader
In questa sezione illustriamo i frammenti più importanti del codice, mentre la versione completa si trova in fondo all’articolo. Questa strategia rappresenta un esempio completo del codice base di DataTrader, pronto all’uso su una nuova installazione se abbiamo già scaricato i dati.
Per attivare il ribilanciamento mensile di ETF, abbiamo bisogno di due nuovi componenti: una sottoclasse di Strategy
, chiamata MonthlyLiquidateRebalanceStrategy
, e una di PositionSizer
, chiamata LiquidateRebalancePositionSizer
.
Abbiamo introdotto alcuni nuovi metodi nella classe MonthlyLiquidateRebalanceStrategy
per gestire il calendario del ribilanciamento mensile di ETF. Nel metodo _end_of_month(...)
utilizziamo il modulo calendar di Python insieme alla funzione monthrange
per identificare il giorno di fine mese.
def _end_of_month(self, cur_time):
"""
Determina se il giorno corrente è alla fine del mese.
"""
cur_day = cur_time.day
end_day = calendar.monthrange(cur_time.year, cur_time.month)[1]
return cur_day == end_day
Il secondo metodo è _create_invested_list
, che crea semplicemente un dizionario con tutti i ticker come chiavi e il booleano False
come valori. Questo dizionario tickers_invested
viene utilizzato per “pulizia”: permette di verificare se un asset è stato acquistato quando si esegue la logica di trading sequenziale.
Questo è necessario perché alla prima esecuzione del codice non è necessaria la liquidazione dell’intero portafoglio dato che non c’è nulla da liquidare!
def _create_invested_list(self):
"""
Crea un dizionario con ogni ticker come chiave, con un valore
booleano a seconda che il ticker sia stato ancora "investito".
Ciò è necessario per evitare di inviare un segnale di
liquidazione sulla prima allocazione.
"""
tickers_invested = {ticker: False for ticker in self.tickers}
return tickers_invested
Calcolo dei segnali
La logica di base è incapsulata nel metodo calculate_signals
. Questo codice controlla se l’ultima barra OHLCV acquisita è relativa ad un giorno di fine mese, quindi determina se il portafoglio contiene posizioni aperte e, in tal caso, si deve liquidarlo completamente prima del ribilanciamento mensile di ETF.
Indipendentemente da ciò, invia semplicemente un segnale long (“BOT”) alla coda degli eventi e quindi aggiorna il dizionario tickers_invested
per mostrare che questo ticker è stato acquistato almeno una volta:
def calculate_signals(self, event):
"""
Per uno specifico BarEvent ricevuto, determina se è la fine del
mese (per quella barra) e genera un segnale di liquidazione,
oltre a un segnale di acquisto, per ogni ticker.
"""
if (
event.type in [EventType.BAR, EventType.TICK] and
self._end_of_month(event.time)
):
ticker = event.ticker
if self.tickers_invested[ticker]:
liquidate_signal = SignalEvent(ticker, "EXIT")
self.events_queue.put(liquidate_signal)
long_signal = SignalEvent(ticker, "BOT")
self.events_queue.put(long_signal)
self.tickers_invested[ticker] = True
Questo conclude il codice per il MonthlyLiquidateRebalanceStrategy
.
Gestioni dei pesi e degli ordini
Il prossimo oggetto è la classe LiquidateRebalancePositionSizer
. Nell’inizializzazione dell’oggetto è necessario passare un dizionario contenente i pesi iniziali dei ticker:
def __init__(self, ticker_weights):
self.ticker_weights = ticker_weights
Per questa strategia il dizionario ticker_weights
ha la seguente struttura:
ticker_weights = {
"SPY": 0.6,
"AGG": 0.4
}
Notiamo che possiamo facilmente modificare questo dizionario per includere qualsiasi portafoglio iniziale di ETF o azioni, ciascuno con pesi differenti. In questa fase, anche se non lo richiediamo espressamente, impostiamo il codice per gestire le allocazioni solo se la somma dei pesi equivale a 1.0. Se la somma supera 1.0, implichiamo l’uso della leva finanziaria o del margine, che DataTrader attualmente non supporta in modo esplicito.
Inseriamo il codice principale della classe LiquidateRebalancePositionSizer
all’interno del metodo size_order
, dove gestiamo la logica degli ordini. Per prima cosa, verifichiamo se l’ordine ricevuto è un “EXIT” (liquidazione) oppure un “BOT” (posizione long) per stabilire le azioni da eseguire.
Quando riceviamo un segnale di liquidazione, calcoliamo la quantità corrente e generiamo un ordine opposto per azzerare completamente la posizione esistente. Se invece riceviamo un segnale long per acquistare azioni, determiniamo il prezzo corrente dell’asset accedendo al valore portfolio.price_handler.tickers[ticker]["adj_close"]
.
Dopo aver ottenuto il prezzo corrente dell’attività, verifichiamo l’intero valore del patrimonio netto disponibile nel portafoglio in quel preciso istante. Utilizzando questi due valori, calcoliamo la percentuale di allocazione per uno specifico asset, moltiplicando il patrimonio netto per il peso proporzionale.
Convertiamo infine questo valore in un numero intero che rappresenta la quantità effettiva di azioni da acquistare per completare l’allocazione prevista. Ricordiamo che, per evitare errori di arrotondamento, dividiamo sia il prezzo di mercato sia il patrimonio netto per PriceParser.PRICE_MULTIPLIER
. Spieghiamo questa funzionalità in un articolo precedente.
def size_order(self, portfolio, initial_order):
"""
Dimensionare l'ordine in modo da riflettere la percentuale
in dollari dell'attuale dimensione del conto azionario
in base a pesi pre-specificati dei ticket.
"""
ticker = initial_order.ticker
if initial_order.action == "EXIT":
# Ottenere la quantità corrente e la liquida
cur_quantity = portfolio.positions[ticker].quantity
if cur_quantity > 0:
initial_order.action = "SLD"
initial_order.quantity = cur_quantity
else:
initial_order.action = "BOT"
initial_order.quantity = cur_quantity
else:
weight = self.ticker_weights[ticker]
# Determina il valore totale del portafoglio, calcola il peso
# in dollari e determina la quantità di azioni da acquistare
price = portfolio.price_handler.tickers[ticker]["adj_close"]
price = PriceParser.display(price)
equity = PriceParser.display(portfolio.equity)
dollar_weight = weight * equity
weighted_quantity = int(floor(dollar_weight / price))
initial_order.quantity = weighted_quantity
return initial_order
Esecuzione del Backtest
Il componente finale del codice è incapsulato nel file monthly_liquidate_rebalance_backtest.py
. Il codice completo è riportato alla fine di questo articolo. Non c’è nulla di “particolare” in questo file rispetto a qualsiasi altra configurazione di backtest oltre alle specifiche del dizionario ticker_weights
dei percentuali delle azioni in portafoglio.
Infine, possiamo eseguire la strategia scaricando i dati nella corretta directory e digitando il seguente comando nel terminale:
$ python monthly_liquidate_rebalance_backtest.py --tickers=SPY,AGG
Risultati della Strategia
Di seguito si riporta le statistiche prodotte dal backtest di ribilanciamento mensile di ETF:

Usiamo come benchmark un portafoglio buy-and-hold (cioè senza ribilanciamento mensile) composto esclusivamente dall’ETF SPY.
Anche se il backtest restituisce uno Sharpe Ratio di 0,3, simile allo 0,32 del benchmark, il CAGR risulta inferiore: 3,31% contro 4,59%.
Individuiamo due cause principali della sottoperformance rispetto al benchmark. Prima di tutto, la strategia liquida completamente e ricompra alla fine di ogni mese, generando molti costi di transazione. Inoltre, il fondo obbligazionario AGG, pur attenuando l’impatto del 2008, ha ridotto la performance di SPY negli ultimi cinque anni.
Questa osservazione ci suggerisce due direzioni di ricerca.
La prima consiste nel ridurre al minimo i costi di transazione evitando la liquidazione completa e riequilibrando solo quando necessario. Possiamo impostare una soglia: se la ripartizione 60/40 si discosta troppo dalla proporzione, allora procediamo con il riequilibrio. Questo approccio si contrappone al ribilanciamento mensile fisso, indipendentemente dalla proporzionalità attuale.
La seconda strada prevede di modificare il mix di ETF per aumentare lo Sharpe Ratio. Esistono moltissimi ETF, e molti portafogli mostrano performance superiori al benchmark a posteriori. Il nostro obiettivo è ovviamente costruire a priori questi portafogli!
Conclusioni
Con l’attuale implementazione, DataTrader supporta solo il ribilanciamento di portafogli long-only con pesi iniziali fissi. Per questo motivo, dobbiamo introdurre due miglioramenti fondamentali: la gestione di portafogli con margine e l’aggiornamento dinamico dei pesi del portafoglio.
Inoltre, dobbiamo aggiungere la possibilità di ribilanciare a frequenze diverse, comprese quelle settimanali e annuali. Il ribilanciamento giornaliero richiede l’accesso a dati di mercato intraday e risulta più complesso, ma rappresenta una funzionalità che intendiamo integrare.
Nonostante l’assenza attuale del ribilanciamento settimanale o annuale, quello mensile è estremamente flessibile. Possiamo utilizzarlo per replicare molte strategie long-only già adottate dai principali asset manager, a condizione di avere i dati disponibili.
Dedicheremo molti post futuri a queste strategie e, come in questo post, includeremo sempre tutto il codice necessario per replicarle.
Codice completo
Il codice completo della strategia di ribilanciamento mensile di ETF, basato sul framework di trading quantitativo event-driven DataTrader, è disponibile nel seguente repository GitHub: https://github.com/tradingquant-it/DataTrader.”
# Monthly_liquidate_rebalance_strategy.py
import calendar
from datatrader.strategy.base import AbstractStrategy
from datatrader.event import SignalEvent, EventType
class MonthlyLiquidateRebalanceStrategy(AbstractStrategy):
"""
Una strategia generica che consente il ribilanciamento mensile di
una serie di ticker, tramite la piena liquidazione e la pesatura
in dollari delle nuove posizioni.
Per funzionare correttamente deve essere utilizzato insieme
all'oggetto LiquidateRebalancePositionSizer.
"""
def __init__(self, tickers, events_queue):
self.tickers = tickers
self.events_queue = events_queue
self.tickers_invested = self._create_invested_list()
def _end_of_month(self, cur_time):
"""
Determina se il giorno corrente è alla fine del mese.
"""
cur_day = cur_time.day
end_day = calendar.monthrange(cur_time.year, cur_time.month)[1]
return cur_day == end_day
def _create_invested_list(self):
"""
Crea un dizionario con ogni ticker come chiave, con un valore
booleano a seconda che il ticker sia stato ancora "investito".
Ciò è necessario per evitare di inviare un segnale di
liquidazione sulla prima allocazione.
"""
tickers_invested = {ticker: False for ticker in self.tickers}
return tickers_invested
def calculate_signals(self, event):
"""
Per uno specifico BarEvent ricevuto, determina se è la fine del
mese (per quella barra) e genera un segnale di liquidazione,
oltre a un segnale di acquisto, per ogni ticker.
"""
if (
event.type in [EventType.BAR, EventType.TICK] and
self._end_of_month(event.time)
):
ticker = event.ticker
if self.tickers_invested[ticker]:
liquidate_signal = SignalEvent(ticker, "EXIT")
self.events_queue.put(liquidate_signal)
long_signal = SignalEvent(ticker, "BOT")
self.events_queue.put(long_signal)
self.tickers_invested[ticker] = True
# Monthly_liquidate_rebalance_backtest.py
import datetime
from datatrader import settings
from datatrader.position_sizer.rebalance import LiquidateRebalancePositionSizer
from datatrader.compat import queue
from datatrader.trading_session import TradingSession
from .strategies.monthly_liquidate_rebalance_strategy import MonthlyLiquidateRebalanceStrategy
def run(config, testing, tickers, filename):
# Backtest information
title = [
'Portafoglio di 60%/40% SPY/AGG con Ribilanciamento Mensile'
]
initial_equity = 500000.0
start_date = datetime.datetime(2006, 11, 1)
end_date = datetime.datetime(2016, 10, 12)
# Usa la strategia Monthly Liquidate And Rebalance
events_queue = queue.Queue()
strategy = MonthlyLiquidateRebalanceStrategy(
tickers, events_queue
)
# Usa il sizer delle posizione di liquidazioni e ribilanciamento
# con pesi dei ticker predefiniti
ticker_weights = {
"SPY": 0.6,
"AGG": 0.4,
}
position_sizer = LiquidateRebalancePositionSizer(
ticker_weights
)
# Setup del backtest
backtest = TradingSession(
config, strategy, tickers,
initial_equity, start_date, end_date,
events_queue, position_sizer=position_sizer,
title=title, benchmark=tickers[0],
)
results = backtest.start_trading(testing=testing)
return results
if __name__ == "__main__":
# Dati di configurazione
testing = False
config = settings.from_file(
settings.DEFAULT_CONFIG_FILENAME, testing
)
tickers = ["SPY", "AGG"]
filename = None
run(config, testing, tickers, filename)
# rebalance.py
from math import floor
from .base import AbstractPositionSizer
from datatrader.price_parser import PriceParser
class LiquidateRebalancePositionSizer(AbstractPositionSizer):
"""
Effettua una periodica liquidazione completa e ribilanciamento
del Portafoglio.
Ciò si ottiene determinando se l'ordine è di tipo "EXIT" o
"BOT / SLD".
Nel primo caso, viene determinata la quantità corrente di
azioni nel ticker e quindi BOT o SLD per portare a zero
la posizione.
In quest'ultimo caso, l'attuale quantità di azioni da
ottenere è determinata da pesi prespecificati e rettificata
per riflettere il patrimonio netto di conto corrente.
"""
def __init__(self, ticker_weights):
self.ticker_weights = ticker_weights
def size_order(self, portfolio, initial_order):
"""
Dimensionare l'ordine in modo da riflettere la percentuale
in dollari dell'attuale dimensione del conto azionario
in base a pesi ticker pre-specificati.
"""
ticker = initial_order.ticker
if initial_order.action == "EXIT":
# Ottenere la quantità corrente e la liquida
cur_quantity = portfolio.positions[ticker].quantity
if cur_quantity > 0:
initial_order.action = "SLD"
initial_order.quantity = cur_quantity
else:
initial_order.action = "BOT"
initial_order.quantity = cur_quantity
else:
weight = self.ticker_weights[ticker]
# Determina il valore totale del portafoglio, calcola il peso in
# dollari e infine determina la quantità intera di azioni da acquistare
price = portfolio.price_handler.tickers[ticker]["adj_close"]
price = PriceParser.display(price)
equity = PriceParser.display(portfolio.equity)
dollar_weight = weight * equity
weighted_quantity = int(floor(dollar_weight / price))
initial_order.quantity = weighted_quantity
return initial_order