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?

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

Il codice di questo tutorial è costruito su tre esempi. Ogni esempio sarà accompagnato da specifici commenti e output.

Parte 1° - Aggiungere i Parametri

Prima di poter ottimizzare il codice 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)


#Variable for our starting cash
startcash = 10000

#Create an instance of cerebro
cerebro = bt.Cerebro()

#Add our strategy
cerebro.addstrategy(rsiStrategy, period=14)

#Get Apple data from Yahoo Finance.
data = bt.feeds.YahooFinanceData(
    dataname='AAPL',
    fromdate = datetime(2016,1,1),
    todate = datetime(2017,1,1),
    buffered= True
    )

#Add the data to Cerebro
cerebro.adddata(data)

# Set our desired cash start
cerebro.broker.setcash(startcash)

# Run over everything
cerebro.run()

#Get final portfolio Value
portvalue = cerebro.broker.getvalue()
pnl = portvalue - startcash

#Print out the final result
print('Final Portfolio Value: ${}'.format(portvalue))
print('P/L: ${}'.format(pnl))

#Finally plot the end results
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.

Parte 2° - Ottimizzazione

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

#Add our strategy
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__':
    #Variable for our starting cash
    startcash = 10000

    #Create an instance of cerebro
    cerebro = bt.Cerebro()

    #Add our strategy
    cerebro.optstrategy(rsiStrategy, period=range(14,21))

    #Get Apple data from Yahoo Finance.
    data = bt.feeds.YahooFinanceData(
        dataname='AAPL',
        fromdate = datetime(2016,1,1),
        todate = datetime(2017,1,1),
        buffered= True
        )

    #Add the data to Cerebro
    cerebro.adddata(data)

    # Set our desired cash start
    cerebro.broker.setcash(startcash)

    # Run over everything
    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 della 2° Parte

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!

Parte 3 - Fare un ulteriore passo avanti

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)
elif self.rsi > 70: self.sell(size=100) if __name__ == '__main__': #Variable for our starting cash startcash = 10000 #Create an instance of cerebro cerebro = bt.Cerebro(optreturn=False) #Add our strategy cerebro.optstrategy(rsiStrategy, period=range(14,21)) #Get Apple data from Yahoo Finance. data = bt.feeds.YahooFinanceData( dataname='AAPL', fromdate = datetime(2016,1,1), todate = datetime(2017,1,1), buffered= True ) #Add the data to Cerebro cerebro.adddata(data) # Set our desired cash start cerebro.broker.setcash(startcash) # Run over everything opt_runs = cerebro.run() # Generate results list 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]) #Sort Results List by_period = sorted(final_results_list, key=lambda x: x[0]) by_PnL = sorted(final_results_list, key=lambda x: x[1], reverse=True) #Print results 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 l’ottimizzazione, 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

# Run over everything
opt_runs = cerebro.run()

# Generate results list
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.
#Sort Results List
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 approffondimento alla fine di questo articolo.

Risultati della 3° parte

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