Molti gestori istituzionali sono vincolati da obblighi contrattuali ad investire in strategie long-only con leva finanziaria nulla o minima. Questo causa che le loro strategie sono spesso altamente correlate al “mercato” (di solito l’indice S & P500). Sebbene questa correlazione no può essere ridotta al minimo senza applicare una copertura short del mercato, può essere attenuata investendo in ETF non basati su azioni.
Queste strategie prevedono una frequenza di ribilanciamento settimanale o mensile, oppure a volte anche annuale. Quindi differiscono sostanzialmente da una classica strategia quantitativa di arbitraggio statistico intraday, ma sono comunque adatte ad un approccio completamente sistematico.
Il framework open-source per il backtesting e live trading di DataTrading, DataTrader, prevede il supporto nativo per il ribilanciamento del portafoglio. In particolare è stata implementata la logica di ribilanciamento ed incorporata nelle classi Strategy
e PositionSizer
.
Questo articolo descrive una strategia di base per un mix di ETF azioni / obbligazioni con pesi fissi, al fine di dimostrare questa funzionalità di DataTrader. La strategia in sé non è nuova – è un esempio del classico mix di azioni / obbligazioni “60/40” – ma il meccanismo di ribilanciamento all’interno di DataTrader consente una flessibilità significativamente maggiore. Può fornire un buon punto di partenza per provare alcuni interessanti mix di ETF long-only.
La strategia di trading
La strategia prevede il trading di un paio di Exchange Traded Fund (ETF), che replicano/seguono fedelmente le performance delle azioni statunitensi (SPY) e delle obbligazioni investment-grade (AGG) statunitensi:
La strategia prevede semplicemente di aquistare (posizione long) entrambi gli ETF alla chiusura del primo giorno di contrattazione utile dopo l’inizio di ogni mese, in modo tale che il 60% del capitale iniziale sia investito in SPY e il restante 40% in AGG. Ad ogni successivo giorno di chiusura del mese il portafoglio viene completamente liquidato e ribilanciato per garantire che il 60% dell’attuale capitale sia investito in SPY e il restante 40% in AGG.
I Dati
Per attuare questa strategia è necessario disporre di dati sui prezzi OHLCV per il periodo previsto da questo backtest. In particolare è necessario scaricare quanto segue:
- SPY – Per il periodo dal 1 novembre 2006 al 1 gennaio 2017 (link qui)
- AGG – Per il periodo dal 1 novembre 2006 al 1 gennaio 2017 (link qui)
Se si desidera replicare questo backtest è sufficiente scaricare e copiare questi dati nella directory specificata nel file di configurazione di DataTrader
Implementazione Python con DataTrader
In questa sezione si descrivono i frammenti di codice più significativi, mentre il codice completo è riportato in fondo all’articolo. Questa strategia è in realtà un esempio completo del codice base di DataTrader, che può essere eseguito in una nuova installazione di DataTrader, supponendo che i dati storici siano ottenuti separatamente.
Sono necessari due nuovi componenti per far funzionare questa logica di ribilanciamento mensile. La prima è una sottoclasse della classe base Strategy
, vale a dire MonthlyLiquidateRebalanceStrategy
. La seconda è una sottoclasse della classe base PositionSizer
, vale a dire LiquidateRebalancePositionSizer
.
Alcuni nuovi metodi sono stati aggiunti alla classe MonthlyLiquidateRebalanceStrategy
. Il primo è _end_of_month(...)
, dove si usa semplicemente il modulo calendar di Python insieme al metodomonthrange
se la data passata è un 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
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.
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
. 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
}
Da notare che questo dizionario può essere facilmente modificato in modo da prevedere qualsiasi portafoglio iniziale di ETF / azioni con diversi pesi. In questa fase, sebbene non sia un requisito fondamentale, il codice è impostato per gestire le allocazioni solo quando la somma dei pesi è pari a 1.0. Una somma superiore a 1.0 indicherebbe l’uso della leva finanziaria / margine, che non è attualmente gestito da DataTrader.
Il codice principale della classe LiquidateRebalancePositionSizer
è all’interno del metodo size_order
. Inizialmente si verifica se l’ordine ricevuto è di tipo “EXIT” (liquidazione) o di tipo long “BOT”. Questo determina le modifiche da prevedere nell’ordine.
Se si ha un segnale di liquidazione, viene determinata la quantità corrente e viene creato un segnale opposto per portare a zero la quantità della posizione. Se invece è un segnale long per acquistare azioni, è necessario determinare il prezzo corrente dell’asset. Questo è implementato verificando il valore di portfolio.price_handler.tickers[ticker]["adj_close"]
.
Una volta determinato il prezzo corrente dell’attività, è necessario verificare l’intero patrimonio netto del portafoglio. Con questi due valori è possibile calcolare la nuova percentuale di allocazione per uno specifico asset moltiplicando l’intero patrimonio netto per il peso proporzionale del bene. Questo viene infine convertito in un valore intero delle azioni da acquistare.
Da notare che, per evitare errori di arrotondamento, il prezzo di mercato e il valore del patrimonio netto devono essere divisi per PriceParser.PRICE_MULTIPLIER
. Questa funzionalità è stata descritta in un precedente articolo.
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
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 questa strategia:
Il benchmark è costituito da un portafoglio buy-and-hold (cioè nessun ribilanciamento mensile) formato esclusivamente dall’ETF SPY.
Nonostante il backtest fornisca uno Sharpe Ratio di 0,3, simile al 0,32 del benchmark, presenta però un CAGR inferiore del 3,31% rispetto al 4,59%.
La sottoperformance rispetto al benchmark è dovuta a due fattori. Prima di tutto la strategia effettua una liquidazione completa e riacquisto alla fine di ogni mese generando molti costi di transazione. Inoltre, il fondo obbligazionario AGG, pur attenuando il colpo nel 2008, ha sminuito la performance di SPY negli ultimi cinque anni.
Ciò motiva due strade di ricerca.
Il primo consiste nel ridurre al minimo i costi di transazione evitando una liquidazione completa e riequilibrando solo quando necessario. Questo potrebbe essere impostato come una forma di soglia per cui se la divisione 60/40 si discosta troppo da questa proporzione è necessario effettuare un riequilibrio. Ciò è in contrasto con un riequilibrio effettuato ogni mese indipendentemente dall’attuale proporzionalità.
La seconda prevede l’adeguamento del mix di ETF per aumentare lo Sharpe Ratio. Esiste un vasto numero di ETF là fuori, con molti portafogli che battono il benchmark a posteriori. Il nostro compito è ovviamente decidere come costruire a priori tali portafogli!
Prossimi Passi
L’attuale implementazione di DataTrader supporta solo il ribilanciamento di portafogli long-only con pesi iniziali fissi. Pertanto, due miglioramenti sono necessari in modo da garantire la gestione di portafogli con margine e la gestione di aggiustamenti dinamici dei pesi del portafoglio.
Inoltre DataTrader dovrà supportare il ribilanciamento ad altre frequenze, comprese quelle settimanali e annuali. Il ribilanciamento giornaliero è più difficile da supportare in quanto implica l’accesso ai dati di mercato infragiornalieri. Tuttavia è una funzionalità da prevedere.
Nonostante l’attuale mancanza di ribilanciamento settimanale o annuale, il riequilibrio mensile è estremamente flessibile. Consentirà la replica di molte strategie long-only attualmente in uso da parte dei grandi asset manager, nell’ipotesi di disponibilità di dati.
Molti post futuri saranno dedicati a tali strategie e, come in questo post, conterranno tutti il codice completo necessario per replicare le strategie.
Codice completo
Per il codice completo riportato in questo articolo, utilizzando il framework open-source di backtesting event-driven DataTrader si può consultare il seguente repository di github:
https://github.com/datatrading-info/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