Ribilanciamento Mensile di ETF con pesi fissi tramite DataTrader

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):

  • SPY – SPDR S&P500 ETF
  • AGG – ETF iShares Core US Aggregate Bond

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 LiquidateRebalancePositionSizerNell’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:

datatrader-60-40-tearsheet-01

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
				
			

Benvenuto su TradingQuant!

Sono Gianluca, ingegnere software e data scientist. Sono appassionato di coding, finanza e trading. Leggi la mia storia.

Ho creato TradingQuant per aiutare le altre persone ad utilizzare nuovi approcci e nuovi strumenti, ed applicarli correttamente al mondo del trading.

TradingQuant vuole essere un punto di ritrovo per scambiare esperienze, opinioni ed idee.

SCRIVIMI SU TELEGRAM

Per informazioni, suggerimenti, collaborazioni...

Torna in alto