Gestire gli Split e i Dividendi con Backtrader

Ottimizzare una strategia con backtrader

In questo articolo descriviamo come ottimizzare una strategia con BackTrader. Dopo aver creato una strategia di base ed averla analizzata, il prossimo passo consiste nell’ottimizzare questa strategia. L’ottimizzazione è un processo di verifica che assegna valori diversi per ogni parametro della strategia al fine di individuare quale set di valori (o configurazione) fornisce i migliori risultati, in termini di profitto. Da notare che non tutti i trader algoritmici concordano sul fatto che questo processo può portare a risultati migliori. Infatti è molto facile cadere nella trappola del sovradimensionamento dei dati (meglio noto come overfitting).

Perchè ottimizzare una strategia?

La motivazione è che i mercati sono in continua evoluzione. Abbiamo mercati rialzisti, mercati ribassisti, periodi di inflazione, periodi di deflazione, tempi instabili e momenti di serenità. Se ciò non bastasse, strumenti diversi hanno ritmi diversi e mercati diversi hanno nature e comportamenti diversi. Ciò significa che i parametri per uno strumento in un mercato potrebbero non essere ottimali per un altro strumento in un altro mercato.

…ma attenzione all’OverFitting

Quando si ottimizzano le strategie, è necessario prestare la massima attenzione a non creare parametri che funzionino solo per in un determinato “momento nel tempo”. Può essere allettante ottenere i migliori risultati dall’ottimizzazione e quindi prevedere l’esecuzione live della strategia con tali parametri. Tuttavia, se il set di dati è limitato ad un breve periodo di tempo o copre solamente una determinata condizione di mercato, è possibile che i parametri siano ottimizzati solo quel specifico momento nel passato, quindi del tutto inutilizzabili nel futuro.
Nella statistica o nel machine learning, infatti, un modello statistico o un algoritmo viene applicato ai dati di addestramento (training) in modo che possa essere utilizzato per fare previsioni nel futuro. L’overfitting si verifica quando il modello o l’algoritmo è troppo complesso per il set di dati preso in considerazione. In questo contesto, complesso significa che l’algoritmo è ottimizzato a tal punto da adattarsi (fit) solo a quei dati. L’overfitting provoca reazioni eccessive se applicato all’esterno dei dati di training. Nel nostro ambiente di backtesting si possono considerare i nostri dati storici di backtest come i dati di training e la nostra strategia come l’algoritmo.

Requisiti

Il codice in questo articolo fa seguito al codice sviluppato nel precedente articolo Backtrader: Primo Script e fa parte della serie introduttiva a BackTrader. Se è la prima volta che senti parlare di Backtrader e / o Python, ti suggerisco di iniziare dall’articolo Setup di base per Python e BackTrader.

Il codice di questo tutorial per ottimizzare una strategia con backtrader è costruito su tre esempi. Ogni esempio sarà accompagnato da specifici commenti e output.

Aggiungere i Parametri alla strategia

Prima di poter ottimizzare una strategia con backtrader dobbiamo fornire alla strategia qualcosa da ottimizzare, cioè alcuni parametri modificabili. Se si osserva il codice del precedente articolo, si può notare come abbiamo impostato a 21 il parametro relativo al periodo del RSI. Questa è una codifica rigida cioè il parametro è valorizzato all’interno del codice e non può essere successivamente modificato. Per effettuare l’ottimizzazione è necessario rendere questo parametro configurabile quando si carica la strategia all’interno del motore cerebro.

				
					import backtrader as bt
from datetime import datetime


class rsiStrategy(bt.Strategy):
    params = (
        ('period', 21),
    )

    def __init__(self):
        self.rsi = bt.indicators.RSI_SMA(self.data.close, period=self.params.period)

    def next(self):
        if not self.position:
            if self.rsi < 30:
                self.buy(size=100)
            else:
                if self.rsi > 70:
                    self.sell(size=100)


# Variabile per il capitale iniziale
startcash = 10000

# Creare un'istanza di cerebro
cerebro = bt.Cerebro()

# Aggiungere la strategia
cerebro.addstrategy(rsiStrategy, period=14)

# Download dei dati di Apple da Yahoo Finance.
data = bt.feeds.YahooFinanceData(
    dataname='AAPL',
    fromdate=datetime(2009, 1, 1),
    todate=datetime(2017, 1, 1),
    buffered=True
)

# Aggiungere i dati di Apple a Cerebro
cerebro.adddata(data)

# Impostare il capitale iniziale
cerebro.broker.setcash(startcash)

# Esecuzione
cerebro.run()

# Valore finale del portafoglio
portvalue = cerebro.broker.getvalue()
pnl = portvalue - startcash

# Stampa del risultato finale
print('Final Portfolio Value: ${}'.format(portvalue))
print('P/L: ${}'.format(pnl))

# Grafico dei risultati
cerebro.plot(style='candlestick')

				
			

Spiegazione del codice

Prima di tutto, facciamo riferimento al codice nel nostro primo script. Questo permette facilmente di notare alcune modifiche. Di seguito sono riportati uno snippet del codice per dichiarare la classe e il metodo __init__() del primo script

				
					class rsiStrategy(bt.Strategy):

def __init__(self):
    self.rsi = bt.indicators.RSI_SMA(self.data.close, period=21)
				
			
Mentre in questo esempio, il codice è diventato:
				
					class rsiStrategy(bt.Strategy):
params = (
    ('period',21),
    )

def __init__(self):
    self.rsi = bt.indicators.RSI_SMA(self.data.close, period=self.params.period)
				
			

In questo caso è stata aggiunta la tupla “params“, essa contiene altre tuple che sono utilizzate per dichiarare i parametri della strategia. Che cos’è una tupla? Una tupla è un elenco di elementi fissi che non possono essere cambiati o modificati. Nei linguaggi di programmazione è abitudine differenziare ciò che è immutabile (non modificabile), come le costanti, e ciò che è mutabile (che può essere modificato), come le variabili. All’interno della tupla dei parametri, si ha un parametro ("period", 21). Il primo elemento è una stringa che identifica il nome / riferimento per il parametro. Il secondo elemento è il valore predefinito per quel determinato parametro.

Avere un valore predefinito significa che non è necessario specificare un parametro ogni volta che si esegue la strategia. Se non viene specificato nulla, la strategia verrà eseguita con il valore di default. Puoi inserire tutti i parametri che desideri nella tupla dei parametri. Assicurati solo di aggiungerli come tupla all’interno della tupla principale (nota come tupla nidificata). I parametri della strategia sono accessibili ovunque nella classe. sono infatti gestiti come qualsiasi altro attributo di classe (variabile). Nel metodo __init__() si accede a self.params.period e viene assegnato alla keyword period quando si aggiunge l’indicatore RSI.

Chiamata alla Strategia

				
					cerebro.addstrategy(firstStrategy, period=14)
				
			

Il codice relativo all’aggiunta della strategia all’interno di cerebro è stato modificato in modo da poter specificare la keyword per il parametro. Come accennato in precedenza, questo è facoltativo. Richiamare la strategia in questo modo ci permetterà di ottimizzarla in un secondo momento.

Note

Ci sono un paio di cose a cui prestare attenzione quando si aggiungono parametri. Il primo è che ogni tupla nell’elenco delle tuple necessita di una virgola alla fine . Se sei abituato a scrivere codice in Python, saprai che per gli oggetti list e dict, l’ultimo valore non dovrebbe avere la virgola finale.

Se si digita: (errato)

				
					params = (
    ('period',21)
)
				
			
Invece di (corretto):
				
					params = (
    ('period',21),
)
				
			

Si ottiene un ValueError:

ValueError: too many values to unpack (expected 2)

Inoltre, fai attenzione quando aggiungi i tuoi indicatori nel metodo __init__(). Se dimentichi di usare una keyword, puoi ottenere un TypeError.
Se si digita: (errato)

				
					
def __init__(self):
    self.rsi = bt.indicators.RSI_SMA(self.data.close, self.params.period)
				
			
Invece di: (corretto)
				
					
def __init__(self):
    self.rsi = bt.indicators.RSI_SMA(self.data.close, period=self.params.period)
				
			

Si ottiene il seguente errore:

TypeError: __init__() takes 1 positional argument but 2 were given

Questo errore può creare molta confusione. Infatti abbiamo aggiunto l’indicatore nel metodo __init__() della strategia, ma in realtà l’errore si riferisce al metodo __init __ () dell’indicatore (classe indicators)! Si potrebbe perdere molto tempo ad eseguire il debug della cosa sbagliata.

Ottimizzare una strategia

Ora che siamo in grado di inizializzare la strategia con parametri differenti, ottimizzare una strategia con backtrader è piuttosto semplice. Tecnicamente dobbiamo solamente sostituire la linea cerebro.addstrategy() con:

				
					#Aggiungere la strategia
cerebro.optstrategy(firstStrategy, period=range(14,21))
				
			
Quindi cerebro eseguirà la strategia per ogni periodo nell’intervallo indicato. Tuttavia, l’output non sarebbe utile. Se vogliamo essere in grado di vedere quale parametro ha le migliori prestazioni si dovrà aggiungere un nuovo metodo alla nostra strategia. Il codice completo è il seguente:
				
					import backtrader as bt
from datetime import datetime

class rsiStrategy(bt.Strategy):
    params = (
        ('period',21),
        )

    def __init__(self):
        self.startcash = self.broker.getvalue()
        self.rsi = bt.indicators.RSI_SMA(self.data.close, period=self.params.period)

    def next(self):
        if not self.position:
            if self.rsi < 30: self.buy(size=100)
            else:
                if self.rsi > 70:
                    self.sell(size=100)

    def stop(self):
        pnl = round(self.broker.getvalue() - self.startcash,2)
        print('RSI Period: {} Final PnL: {}'.format(
            self.params.period, pnl))

if __name__ == '__main__':
    # Variabile per il capitale iniziale
    startcash = 10000

    # Creare un'istanza di cerebro
    cerebro = bt.Cerebro()

    # Aggiungere la strategia
    cerebro.optstrategy(rsiStrategy, period=range(14,21))

    # Download dei dati di Apple da Yahoo Finance.
    data = bt.feeds.YahooFinanceData(
        dataname='AAPL',
        fromdate = datetime(2016,1,1),
        todate = datetime(2017,1,1),
        buffered= True
        )

    # Aggiungere i dati di Apple a Cerebro
    cerebro.adddata(data)

    # Impostare il capitale iniziale
    cerebro.broker.setcash(startcash)

    # Esecuzione
    strats = cerebro.run()
				
			

Spiegazione del codice

I lettori attenti avranno sicuramente notato che ci state alcune cancellazioni, oltre al nuovo metodo (funzione) aggiunto alla strategia. Innanzitutto diamo un’occhiata al nuovo metodo:

				
					
    def stop(self):
        pnl = round(self.broker.getvalue() - self.startcash,2)
        print('RSI Period: {} Final PnL: {}'.format(
            self.params.period, pnl))

				
			

Backtrader eseguirà diversi cicli di backtesting, uno per ogni diverso valore dei parametri, prima di arrivare al termine dello script. Nell’esempio precedente, si ha come output il valore del portafoglio e il PnL (profitti e perdite) alla fine dello script. Questo significa che non si ha visibilità dei risultati dei singoli vedrai i risultati dei singoli backtest se lasciamo l’istruzione print() dopo la fine dell’esecuzione di cerebro. Di conseguenza, un metodo stop() viene aggiunto allo script. Questo metodo fa parte della classe base bt.Strategy e si sta semplicemente sovrascrivendo la logica al suo interno, dato che si eredita la bt.Strategy durante la creazione della classe della nostra strategia. Come suggerisce il nome, si richiama questo metodo quando la strategia si interrompe. Questo è l’ideale per restituire i profitti o le perdite finali al termine del test.

Plotting

Oltre a rimuovere l’istruzioni print() alla fine dello script, è stata rimossa anche la funzione di stampa dei grafici. Quando si effettua l’ottimizzazione, ti consiglio di non graficare l’output perchè, al momento della stesura di questo articolo, il framework prevede la creazione di un grafico alla fine di ogni ciclo della strategia. E’ quindi necessario chiudere manualmente il grafico prima dell’inizio del ciclio successivo. Se si ha molti parametri, questo può richiedere molto tempo e diventare fastidioso.

Risultati dell’ottimizzazione della strategia con backtrader

ottimizzazione della strategia con backtrader

Quindi sembra che un periodo pari a 17 sia il valore ottimale per questo set di dati. È interessante notare come se il valore fosse diverso di sole 2 unità (un periodo di 19), i risultati sarebbero drasticamente diversi!

Gestione e stampa dei risultati

L’esempio precedente è funzionalmente corretto ma secondo me c’è un problema. I risultati sopra riportati non sono ordinati e si potrebbe aver esigenza di qualcosa di più della sola stampa dei risultati. Immagina di avere 3 parametri che possono produrre oltre a 100 combinazioni. Sarebbe piuttosto laborioso e soggetto a errori se si dovesse leggere le righe una per una. In questa parte, vedremo come accedere ai risultati dopo che cerebro avrà terminato la sua elaborazione.

				
					
import backtrader as bt
from datetime import datetime

class rsiStrategy(bt.Strategy):
    params = (
        ('period',21),
        )

    def __init__(self):
        self.startcash = self.broker.getvalue()
        self.rsi = bt.indicators.RSI_SMA(self.data.close, period=self.params.period)

    def next(self):
        if not self.position:
            if self.rsi < 30: self.buy(size=100)
            else:
                if self.rsi > 70:
                    self.sell(size=100)


if __name__ == '__main__':
    # Variabile per il capitale iniziale
    startcash = 10000

    # Creare un'istanza di cerebro
    cerebro = bt.Cerebro(optreturn=False)

    # Aggiungere la strategia
    cerebro.optstrategy(rsiStrategy, period=range(14,21))

    # Download dei dati di Apple da Yahoo Finance.
    data = bt.feeds.YahooFinanceData(
        dataname='AAPL',
        fromdate = datetime(2016,1,1),
        todate = datetime(2017,1,1),
        buffered= True
        )

    # Aggiungere i dati di Apple a Cerebro
    cerebro.adddata(data)

    # Impostare il capitale iniziale
    cerebro.broker.setcash(startcash)

    # Esecuzione
    opt_runs = cerebro.run()

    # Genera la lista dei risultati
    final_results_list = []
    for run in opt_runs:
        for strategy in run:
            value = round(strategy.broker.get_value(), 2)
            PnL = round(value - startcash, 2)
            period = strategy.params.period
            final_results_list.append([period, PnL])

    # Ordina la lista dei risultati
    by_period = sorted(final_results_list, key=lambda x: x[0])
    by_PnL = sorted(final_results_list, key=lambda x: x[1], reverse=True)

    # Stampa i risultati
    print('Results: Ordered by period:')
    for result in by_period:
        print('Period: {}, PnL: {}'.format(result[0], result[1]))
    print('Results: Ordered by Profit:')
    for result in by_PnL:
        print('Period: {}, PnL: {}'.format(result[0], result[1]))
				
			

Spiegazione del codice

In questo esempio, ci sono alcune modifiche al codice. Innanzitutto abbiamo rimosso il metodo stop() nell’ultimo esempio. Avremo accesso a tutti i valori di cui abbiamo bisogno solo dopo che lo script avrà terminato l’esecuzione. Un altro cambiamento che potrebbe essere poco visibile se si sta semplicemente copiando e incollando il codice è:
				
					    cerebro = bt.Cerebro(optreturn=False)
				
			

In questo caso abbiamo aggiunto un nuovo parametro all’inizializzazione di cerebro. Questo parametro modifica ciò che viene restituito da cerebro.run() alla fine dello script. In un normale script cerebro.run() restituisce oggetti completi della classe strategia. Questi oggetti sono creati a partite dal modello della classe rsiStrategy che abbiamo scritto nel codice. Gli oggetti Strategia permettono di accedere a tutti disponibili per cerebro durante il test (indicatori, dati, analizzatori, osservatori, ecc.) anche dopo il termine dell’esecuzione di cerebro. In questo modo si ha accesso a tutti i dati e i risultati. Tuttavia, durante il processo per ottimizzare una strategia di backtrader, cerebro.run() restituisce gli oggetti OptReturn come impostazione di default predefinita. Questi sono oggetti limitati dato che contengono solo i parametri e gli analizzatori, al fine di migliorare la velocità di ottimizzazione.

Si presume che le metriche importanti necessarie per decidere quali parametri siano i migliori possano essere dedotte solo dagli analizzatori e dai parametri. Tuttavia, poiché gli esempi riportati in questo articolo hanno restituito il profitto finale, è opportuno mantenere questa convenzione anche nell’esempio finale. Per questo motivo, il parametro optreturn deve essere impostato su false poiché le informazioni del broker (per i profitti / perdite) non fanno parte di un analizzatore. Abbiamo bisogno di Cerebro per ricavare oggetti Strategia completi. Il resto del codice di interesse per questo esempio si verifica al termine dell’esecuzione di cerebro.

Ricavare i dati da un oggetto Strategia

				
					# Esecuzione
opt_runs = cerebro.run()

# Genera la lista dei risultati
final_results_list = []
for run in opt_runs:
    for strategy in run:
        value = round(strategy.broker.get_value(),2)
        PnL = round(value - startcash,2)
        period = strategy.params.period
        final_results_list.append([period,PnL])
				
			
Cerebro restituisce un elenco di oggetti Strategia per ciascun ciclo tramite la lista dei parametri. In questo esempio, esiste solo una strategia. Tuttavia, poiché viene restituito una lista nidificata (lista di liste), è necessario iterare l’oggetto restituito per due volte per ottenere le informazioni necessarie. Dopo aver ricavato i valori desiderati, questi possono essere aggiunti alla lista final_results_list. Questa lista può essere quindi ordinata come si desidera.
				
					# Ordina la lista dei risultati
by_period = sorted(final_results_list, key=lambda x: x[0])
by_PnL = sorted(final_results_list, key=lambda x: x[1], reverse=True)
				
			

Se non conosci Python, questa parte potrebbe sembrare un po’ complessa. Anche final_results_list è una lista nidificata. Per ordinarla correttamente, dobbiamo fornire una chiave di ordinamento. È quindi necessario passare una funzione all’argomento della keyword key. Un lambda è una piccola funzione formata da una riga che ci consente di utilizzare la chiave di ordinamento. Per ulteriori informazioni, ho aggiunto alcuni link di riferimento per letture di approfondimento alla fine di questo articolo.

Risultati della 3° parte

ottimizzare una strategia con backtrader

Eccoci. Questo articolo è diventato molto più lungo di quanto mi aspettassi quando ho iniziato a scriverlo. Se sei riuscito a farcela fino a qui senza saltare, spero che il contenuto abbia fornito qualche consiglio e spunto operativo.

Letture di Approfondimento

  1. Documentazione di Backtrader su l’ottimizzazione: https://www.backtrader.com/docu/quickstart/quickstart?highlight=optimize#let-s-optimize
  2. Documentazione di Backtrader su Cerebro: https://www.backtrader.com/docu/cerebro
  3. Tutorial sul sorting di Python: https://docs.python.org/3/howto/sorting.html
  4. Documentazione di Python su lambda: https://docs.python.org/3/tutorial/controlflow.html#lambda-expressions

Codice completo

In questo articolo  abbiamo descritto come ottimizzare una strategia con BackTrader. Per il codice completo riportato in questo articolo, si può consultare il seguente repository di github:
https://github.com/datatrading-info/BackTrader

Scroll to Top