Gestione del rischio con i modelli di Markov nascosti in DataTrader

Nella lezione sui modelli di Markov nascosti abbiamo descritto la loro applicazione per indicizzare i dati sui rendimenti come meccanismo per scoprire i “regimi di mercato” latenti. Abbiamo analizzato i rendimenti dell’S&P500 usando le librerie statistiche Python e identificato periodi di diversa volatilità, utilizzando sia modelli a due stati che a tre stati. In questo articolo, implementiamo la gestione del rischio con i modelli di Markov nascosti per identificare il regime di mercato, e effettuiamo il backtest di una strategia tramite il framework DataTrader.

L’obbiettivo consiste nel decidere di non effettuare operazioni quando prevediamo regimi di elevata volatilità. In questo modo vogliamo eliminare i trade non redditizi e, possibilmente, ridurre la volatilità della strategia, aumentando così il suo Sharpe ratio. Per raggiungere questo obiettivo abbiamo apportato alcune piccole modifiche al codice di DataTrader, disponibile nella sua pagina Github.

Abbiamo abbinato l’identificazione del regime di mercato a una strategia di trend-following a breve termine molto semplice, basata su regole di crossover della media mobile. La strategia in sé è relativamente irrilevante per questo articolo, poiché ci concentriamo soprattutto sull’implementazione della logica di gestione del rischio con i modelli di Markov nascosti, identificando il regime di mercato.

Poiché DataTrader è scritto in Python, per questo articolo utilizziamo una libreria Python che ci fornisce un’implementazione pronta all’uso del Modello di Markov Nascosto. La libreria che usiamo si chiama hmmlearn.

Gestione del rischio con i modelli di Markov nascosti

Se non conosci i modelli di Markov nascosti e/o non sai come possiamo usarli come strumento per la gestione del rischio, vale la pena consultare le seguenti lezioni del corso di finanza quantitativa:

Nella prima lezione spieghiamo i concetti matematici e statistici alla base del modello, mentre nel secondo utilizziamo Python per adattare un HMM ai rendimenti dell’S&P500.

I modelli di Markov nascosti rappresentano una classe di modelli stocastici dello spazio degli stati. Presuppongono l’esistenza di stati “nascosti” o “latenti” che non possiamo osservare direttamente. Tuttavia, questi stati influenzano i valori che osserviamo, chiamati osservazioni. Uno degli obiettivi principali del modello consiste nell’inferire lo stato attuale a partire dalle osservazioni note.

Nel trading quantitativo, traduciamo questo problema nell’identificazione di regimi di mercato latenti, come variazioni normative o periodi di alta volatilità. In questo contesto, consideriamo i rendimenti dei dati di mercato come osservazioni indirettamente influenzate dai regimi nascosti. Adattando un modello di Markov nascosto ai rendimenti, possiamo “prevedere” nuovi stati di regime, da usare come filtro nella gestione del rischio.

La strategia di trading

Abbiamo scelto una strategia di trading estremamente semplice per facilitarne la comprensione. L’aspetto centrale che vogliamo approfondire riguarda la gestione del rischio con i modelli di Markov nascosti.

La strategia di trend-following a breve termine che adottiamo è il classico crossover di medie mobili. Le regole sono le seguenti:

  • A ogni barra calcoliamo le medie mobili semplici (SMA) a 10 e 30 giorni
  • Se la SMA a 10 giorni supera la SMA a 30 giorni e non siamo a mercato, entriamo long
  • Se la SMA a 30 giorni supera la SMA a 10 giorni e siamo a mercato, chiudiamo la posizione

Questa strategia, con questi parametri, non si rivela particolarmente efficace sui prezzi dell’indice S&P500. Otteniamo risultati simili a un approccio buy-and-hold sull’ETF SPY nello stesso periodo.

Tuttavia, se combiniamo la strategia con un filtro di gestione del rischio, otteniamo risultati migliori. Possiamo infatti eliminare i trade che si verificano in periodi di elevata volatilità, in cui le strategie di trend-following tendono a fallire.

Costruiamo il filtro addestrando un modello di Markov nascosto sui dati S&P500 dal 29 gennaio 1993 (data iniziale disponibile per SPY) al 31 dicembre 2004. Successivamente serializziamo il modello (tramite pickle di Python) e lo utilizziamo nella sottoclasse RiskManager di DataTrader.

Il gestore del rischio verifica, per ogni operazione inviata, se lo stato corrente corrisponde a un regime di bassa o alta volatilità. Se la volatilità è bassa, eseguiamo tutte le operazioni long. Se la volatilità è alta, chiudiamo qualsiasi operazione aperta al segnale di uscita e blocchiamo ogni nuovo trade long prima dell’esecuzione.

In questo modo otteniamo l’effetto desiderato: eliminare i trade trend-following in fasi di alta volatilità, quando è più probabile che risultino in perdita per via dell’errata identificazione del “trend”.

Set di Dati Storici

Effettuiamo il backtest della strategia dal 1 gennaio 2005 al 31 dicembre 2014, senza riaddestrare l’HMM. Questo implica che usiamo il modello in modalità out-of-sample, su dati diversi da quelli di addestramento.

Per testare la strategia di gestione del rischio con i modelli di Markov nascosti abbiamo bisogno dei dati OHLCV giornalieri dell’ETF SPY, sia per il periodo di addestramento dell’HMM che per il periodo di backtest:

TickerNomePeriodo
SPYSPDR S&P 500 ETF29 gennaio 1993 – 31 dicembre 2014

Se vogliamo replicare i risultati, dobbiamo inserire questi dati nella directory indicata dal file delle impostazioni di DataTrader.

Implementazione Python

 

Calcolo dei rendimenti con DataTrader

Per effettuare la gestione del rischio con i modelli di Markov nascosti dobbiamo calcolare e memorizzare i rendimenti dei prezzi di chiusura dell’ETF SPY. Finora abbiamo memorizzato solo i prezzi. La sede naturale per archiviare i rendimenti è all’interno della sottoclasse PriceHandler. Perciò abbiamo aggiunto questa funzionalità al framework DataTrader.

Si è trattato di un aggiornamento relativamente semplice, composto da due modifiche principali. Per prima cosa abbiamo introdotto un flag booleano calc_adj_returns nell’inizializzazione della classe. Quando lo impostiamo su True, calcoliamo e memorizziamo i rendimenti. Il valore predefinito del flag è False, così minimizziamo l’impatto sul resto del codice client.

La seconda modifica consiste nel sovrascrivere il metodo “virtuale” _store_event presente nella classe AbstractBarPriceHandler con il nuovo codice nella classe YahooDailyCsvBarPriceHandler.

Il codice verifica se calc_adj_returns è uguale a True. In tal caso, memorizziamo i prezzi di chiusura attuali e precedenti tramite il PriceParser, calcoliamo i rendimenti percentuali e li aggiungiamo alla lista adj_close_returns. Il RegimeHMMRiskManager utilizza in seguito questa lista per prevedere lo stato corrente del regime.

				
					    
    def _store_event(self, event):
        """
        Memorizza il prezzo di chiusura e di chiusura aggiustata per ogni evento
        """
        ticker = event.ticker
        # Se il flag calc_adj_returns è True, calcola e memorizza
        # in un elenco tutta la lista dei rendimenti percentuali
        # del prezzo di chiusura aggiustata
        # TODO: Aumentare la velocità
        if self.calc_adj_returns:
            prev_adj_close = self.tickers[ticker]["adj_close"] / float(PriceParser.PRICE_MULTIPLIER)
            cur_adj_close = event.adj_close_price / float(PriceParser.PRICE_MULTIPLIER)
            self.tickers[ticker][
                "adj_close_ret"
            ] = cur_adj_close / prev_adj_close - 1.0
            self.adj_close_returns.append(self.tickers[ticker]["adj_close_ret"])
        self.tickers[ticker]["close"] = event.close_price
        self.tickers[ticker]["adj_close"] = event.adj_close_price
        self.tickers[ticker]["timestamp"] = event.time
				
			

Questa modifica è disponibile nell’ultima versione di DataTrader, che (come sempre) possiamo trovare alla pagina Github.

Implementazione per identificare il regime

Ora concentriamo l’attenzione sull’implementazione del filtro per il regime di mercato e sulla strategia di trend following a breve termine che utilizziamo per eseguire il backtest. Per eseguire questa strategia, usiamo quattro differenti file. Riportiamo il listato completo di ciascun file alla fine di questo articolo. In questo modo chi desidera implementare un metodo simile può replicare direttamente i risultati.

Nel primo file adattiamo un modello gaussiano di Markov nascosto a un ampio periodo dei rendimenti dell’S&P500. Nel secondo inseriamo la logica per effettuare il trend-following di breve termine. Il terzo file fornisce il filtro del regime di mercato tramite un oggetto per la gestione del rischio. Nell’ultimo file colleghiamo insieme tutti questi moduli in una procedura di backtest per la gestione del rischio con i modelli di Markov.

Addestrare il modello di Markov nascosto

Prima di creare un filtro per rilevare il regime del mercato, adattiamo il modello di Markov nascosto a un dataset dei rendimenti. A tale scopo usiamo la libreria Python hmmlearn. La sua API è estremamente semplice e ci consente di adattare e memorizzare facilmente il modello per un uso successivo.

Iniziamo importando le librerie necessarie. Utilizziamo pickle per serializzare il modello da usare nel gestore del rischio per il rilevamento del regime. Con warnings eliminiamo gli avvisi di deprecazione generati da Scikit-Learn tramite le chiamate API di hmmlearn. Usiamo GaussianHMM di hmmlearn come base del nostro modello. Infine importiamo Matplotlib e Seaborn per visualizzare i grafici degli stati nascosti all’interno del campione, fondamentali per eseguire un “controllo di integrità” sul comportamento dei modelli.

				
					
# regime_hmm_train.py

import datetime
import pickle
import warnings

from hmmlearn.hmm import GaussianHMM
from matplotlib import cm, pyplot as plt
from matplotlib.dates import YearLocator, MonthLocator
import numpy as np
import pandas as pd
import seaborn as sns
				
			

La funzione obtain_prices_df legge il file CSV dei dati SPY scaricati da Yahoo Finance  e importa i dati in un Pandas DataFrame. Quindi calcola i rendimenti percentuali dei prezzi di chiusura rettificati e tronca la data di fine al periodo di  addestramento desiderato. Il calcolo dei rendimenti percentuali introduce  valori NaN nel DataFrame, che vengono quindi eliminati:

				
					
def obtain_prices_df(csv_filepath, end_date):
    """
    Legge i prezzi dal file CSV e li carica in un Dataframe, 
    filtra per data di fine e calcola i rendimenti percentuali.
    """
    df = pd.read_csv(
        csv_filepath, header=0,
        names=[
            "Date", "Open", "High", "Low",
            "Close", "Volume", "Adj Close"
        ],
        index_col="Date", parse_dates=True
    )
    df["Returns"] = df["Adj Close"].pct_change()
    df = df[:end_date.strftime("%Y-%m-%d")]
    df.dropna(inplace=True)
    return df
				
			

Grafico degli stati

La seguente funzione, plot_in_sample_hidden_states, non è strettamente necessaria ai fini dell’addestramento. Abbiamo modificato il file tutorial, presente nella documentazione di hmmlearn.

Il codice prende i dati del modello e il dataframe dei prezzi e crea un grafico per ogni stato nascosto generato dal modello. Ogni grafico mostra il prezzo di chiusura mascherato da quel particolare stato/regime nascosto. Questo è utile per verificare se l’HMM sta producendo stati “sani”:

				
					
def plot_in_sample_hidden_states(hmm_model, df):
    """
    Traccia il grafico dei prezzi di chiusura rettificati
    mascherati dagli stati nascosti nel campione come
    meccanismo per comprendere i regimi di mercato.
    """
    # Array con gli stati nascosti previsti
    hidden_states = hmm_model.predict(df["Returns"])
    # Crea il grafico formattato correttamente
    fig, axs = plt.subplots(
        hmm_model.n_components,
        sharex=True, sharey=True
    )
    colours = cm.rainbow(
        np.linspace(0, 1, hmm_model.n_components)
    )
    for i, (ax, colour) in enumerate(zip(axs, colours)):
        mask = hidden_states == i
        ax.plot_date(
            df.index[mask],
            df["Adj Close"][mask],
            ".", linestyle='none',
            c=colour
        )
        ax.set_title("Hidden State #%s" % i)
        ax.xaxis.set_major_locator(YearLocator())
        ax.xaxis.set_minor_locator(MonthLocator())
        ax.grid(True)
    plt.show()
				
			

La seguente figura riporta l’output di questa specifica funzione:

trading-algoritmico-datatrader-regime-detection-in-sample-hidden-states

Notiamo come il rilevamento del regime cattura in gran parte periodi di “tendenza” e periodi altamente volatili. In particolare la maggior parte del 2008 si trova nel Hidden State #1.

Script per eseguire l’addestramento

Questo script è legato insieme nella funzione __main__. In primo luogo, tutti gli avvisi vengono ignorati. A rigor di logica questo non l’approccio corretto, ma in questo caso ci sono molti avvisi di deprecazione generati da Scikit-Learn che oscurano l’output desiderato dello script.

Successivamente apriamo il file CSV e si crea la variabile rets tramite il comando np.column_stack perché hmmlearn richiede una matrice di oggetti Series di pandas, nonostante si tratti di un modello univariato (agisce solo sui rendimenti). A tale scopo dobbiamo usare le funzionalità messe a disposizione dalla libreria NumPy.

L’oggetto GaussianHMM richiede di specificare il numero di stati tramite il parametro n_components. In questo articolo usiamo due stati, ma si può facilmente testare lo stesso algoritmo con tre stati. Inoltre usiamo una matrice di covarianza completa, anziché una versione diagonale e usiamo il parametro n_iter per definire il numero di iterazioni per l’algoritmo Expectation-Maximisation.

Addestriamo il modello e visualizziamo l’output dell’algoritmo e il grafico degli Hidden State dei prezzi di chiusura rettificati. Infine il modello è serializzato in pickle_path, pronto per essere usato nel gestore del rischio per rilevamento del regime:

				
					
if __name__ == "__main__":
    # Nasconde gli avvisi di deprecazione per sklearn
    warnings.filterwarnings("ignore")

    # Crea il dataframe SPY dal file CSV di Yahoo Finance e
    # formatta correttamente i rendimente per l'uso nell'HMM
    csv_filepath = "/path/to/your/data/SPY.csv"
    pickle_path = "/path/to/your/model/hmm_model_spy.pkl"
    end_date = datetime.datetime(2004, 12, 31)
    spy = obtain_prices_df(csv_filepath, end_date)
    rets = np.column_stack([spy["Returns"]])

    # Crea il Gaussian Hidden markov Model e lo adatta ai
    # dati dei rendimenti di SPY, visualizzando il punteggio
    hmm_model = GaussianHMM(
        n_components=2, covariance_type="full", n_iter=1000
    ).fit(rets)
    print("Model Score:", hmm_model.score(rets))

    # Grafico dei valori di chiusura degli stati nascosti del campione
    plot_in_sample_hidden_states(hmm_model, spy)

    print("Pickling HMM model...")
    pickle.dump(hmm_model, open(pickle_path, "wb"))
    print("...HMM model pickled.")
				
			

Strategia Trend Following a breve termine

La fase successiva del processo consiste nel creare la classe Strategy che implementa la logica trend following a breve termine, filtrata successivamente dal modulo RiskManager.

Come per tutte le strategie sviluppate all’interno di DataTrader è necessario importare alcune classi specifiche, tra cui PriceParserSignalEvente la classe  base AbstractStrategy. Questa strategia è simile a molte descritte negli articoli precedenti quindi non descriviamo i singoli passaggi di importazione delle librerie:

				
					
# regime_hmm_strategy.py

from collections import deque

import numpy as np

from datatrader.price_parser import PriceParser
from datatrader.event import SignalEvent, EventType
from datatrader.strategy.base import AbstractStrategy
				
			

In realtà la sottoclasse MovingAverageCrossStrategy sottoclasse è già stata usata in uno degli esempi precedenti. Tuttavia la replichiamo per completezza. La strategia utilizza due code a doppia estremità, disponibili nel modulo deque, per fornire le finestre scorrevoli sui dati sui prezzi. Questo serve per calcolare le medie mobili semplici che formano la logica del trend-following a breve termine:

				
					
class MovingAverageCrossStrategy(AbstractStrategy):
    """
    Requisiti:
    tickers - La lista dei simboli dei ticker
    events_queue - Il manager della coda degli eventi
    short_window - Periodo di lookback per la media mobile breve
    long_window - Periodo di lookback per la media mobile lunga
    """
    def __init__(
        self, tickers, 
        events_queue, base_quantity,
        short_window=10, long_window=30
    ):
        self.tickers = tickers
        self.events_queue = events_queue
        self.base_quantity = base_quantity
        self.short_window = short_window
        self.long_window = long_window
        self.bars = 0
        self.invested = False
        self.sw_bars = deque(maxlen=self.short_window)
        self.lw_bars = deque(maxlen=self.long_window)
				
			

Calcolo dei Segnali

Nel framework di backtesting event-driven DataTrader tutte le sottoclassi derivate da AbstractStrategy prevedono l’uso del metodo calculate_signals per generare oggetti SignalEvent. Per la nostra strategia il metodo verifica innanzitutto se l’evento è una barra OHLCV. Ad esempio, potrebbe essere un SentimentEvent (come in altre strategie ) e quindi è necessario prevedere un controllo. Aggiungiamo i prezzi più recenti alle code delle finestre mobili in modo da aggiornare le SMA.

Se ci sono abbastanza barre per eseguire le medie mobili, queste sono  entrambe calcolate. Una volta che questi valori sono presenti, eseguiamo le regole di trading sopra descritte. Nel caso la SMA della finestra breve supera la SMA della finestra lunga e la strategia non è già a mercato, generiamo una posizione long di base_quantity azioni. Altrimenti, se la SMA della finestra lunga supera la SMA della finestra breve la posizione viene chiusa se siamo a mercato:

				
					    
    def calculate_signals(self, event):
        # Applica SMA al primo ticker
        ticker = self.tickers[0]
        if event.type == EventType.BAR and event.ticker == ticker:
            # Aggiunge l'ultimo prezzo di chiusura ai dati
            # delle finestre corta e lunga
            price = event.adj_close_price / PriceParser.PRICE_MULTIPLIER
            self.lw_bars.append(price)
            if self.bars > self.long_window - self.short_window:
                self.sw_bars.append(price)

            # Sono presenti abbastanza barre per il trading
            if self.bars > self.long_window:
                # Calcola le medie mobili semplici
                short_sma = np.mean(self.sw_bars)
                long_sma = np.mean(self.lw_bars)
                # Segnali di trading basati sulla media mobile incrociata
                if short_sma > long_sma and not self.invested:
                    print("LONG: %s" % event.time)
                    signal = SignalEvent(ticker, "BOT", self.base_quantity)
                    self.events_queue.put(signal)
                    self.invested = True
                elif short_sma < long_sma and self.invested:
                    print("SHORT: %s" % event.time)
                    signal = SignalEvent(ticker, "SLD", self.base_quantity)
                    self.events_queue.put(signal)
                    self.invested = False
            self.bars += 1
				
			

Rilevamento del regime nel RiskManager

In questo articolo vediamo come creare un oggetto come sottoclasse di AbstractRiskManager. E’ il primo utilizzo importante della gestione del rischio con i modelli di Markov nascosti applicata separatamente a una strategia di trading fino ad oggi sul sito tradingquant.it. Come indicato sopra, l’obiettivo di questo oggetto è quello di filtrare le operazioni trend-following a breve termine quando si trovano in un regime ad alta volatilità non desiderato.

Tutte le sottoclassi di tipo RiskManager richiedono l’accesso ad un OrderEvent perchè devono poter di eliminare, modificare o creare ordini a seconda dei vincoli di rischio del portafoglio:

				
					# regime_hmm_risk_manager.py

import numpy as np

from datatrader.event import OrderEvent
from datatrader.price_parser import PriceParser
from datatrader.risk_manager.base import AbstractRiskManager
				
			

Creiamo la classe RegimeHMMRiskManager che richiede semplicemente la lettura del file del modello HMM deserializzato. Inoltre dobbiamo tenere traccia se la strategia è “investita” o meno, poiché l’oggetto Strategy non è a conoscenza se i suoi segnali sono stati effettivamente eseguiti:

				
					
class RegimeHMMRiskManager(AbstractRiskManager):
    """  
    Utilizza un modello Hidden Markov precedentemente adattato 
    come meccanismo di rilevamento del regime. Il gestore del 
    rischio ignora gli ordini che si verificano durante 
    un regime non desiderato.

    Ciò spiega anche il fatto che un'operazione può essere 
    a cavallo di due regimi separati. Se un ordine di chiusura 
    viene ricevuto nel regime non desiderato e l'ordine è aperto, 
    verrà chiuso, ma non verranno generati nuovi ordini fino 
    al raggiungimento del regime desiderato.
    """
    def __init__(self, hmm_model):
        self.hmm_model = hmm_model
        self.invested = False
				
			

Creiamo un metodo helper, determine_regime, che usa l’oggetto price_handler e l’evento l’ sized_order per ottenere l’elenco completo dei rendimenti dei prezzi di chiusura calcolati da  DataTrader (per i dettagli vedere il codice nella sezione precedente). Quindi usiamo il  metodo predict dell’oggetto  GaussianHMM per produrre una serie di stati di regime previsti. Prendiamo il valore più recente e lo usiamo come “stato nascosto” o regime corrente:

				
					

    def determine_regime(self, price_handler, sized_order):
        """
        Determina il probabile regime effettuando una previsione sui rendimenti 
        dei prezzi di chiusura nell'oggetto PriceHandler e quindi prende 
        il valore intero finale come "stato del regime nascosto"
        """
        returns = np.column_stack(
            [np.array(price_handler.adj_close_returns)]
        )
        hidden_state = self.hmm_model.predict(returns)[-1]
        return hidden_state
				
			

Gestione degli ordini

Il metodo refine_orders è obbligatori in tutte le sottoclassi derivate da AbstractRiskManager. In questo caso eseguiamo il metodo determine_regime per determinare lo stato del regime. Creiamo infine il corretto oggetto l’ OrderEvent, che sarà modificato successivamente:

				
					
    def refine_orders(self, portfolio, sized_order):
        """
        Utilizza il modello di Markov nascosto con i rendimenti percentuali
        per determinare il regime corrente, 0 per desiderabile o 1 per
        indesiderabile. Ingressi Long seguiti solo in regime 0, operazioni
        di chiusura sono consentite in regime 1.
        """
        # Determinare il regime previsto HMM come un intero
        # uguale a 0 (desiderabile) o 1 (indesiderabile)
        price_handler = portfolio.price_handler
        regime = self.determine_regime(
            price_handler, sized_order
        )
        action = sized_order.action
        # Crea l'evento dell'ordine, indipendentemente dal regime. Sarà 
        # restituito solo se le condizioni corrette sono soddisfatte.
        order_event = OrderEvent(
            sized_order.ticker,
            sized_order.action,
            sized_order.quantity
        )
        ..
        ..
				
			

Nella seconda metà del metodo implementiamo la logica per gestire il rischio a seguito del rilevamento del regime. Inseriamo un blocco condizionale che verifica quale stato di regime abbiamo individuato.

Se siamo in uno stato di bassa volatilità #0, controlliamo se l’ordine è di tipo “BOT” o “SLD”. Nel caso di un ordine “BOT” (long), restituiamo un OrderEvent e aggiorniamo lo stato “invested”. Se invece l’ordine è “SLD” (chiudi), chiudiamo la posizione se è aperta, altrimenti annulliamo l’ordine.

Inoltre, se prevediamo un regime di alta volatilità #1, verifichiamo quale ordine abbiamo creato. Non vogliamo posizioni long in questo regime di mercato. Consentiamo invece di chiudere una posizione solo se abbiamo precedentemente aperto una posizione long, altrimenti annulliamo l’ordine.

Questo ci permette di non generare mai una nuova posizione long quando siamo in regime #1. Tuttavia, possiamo chiudere una posizione long precedentemente aperta nel regime n. 1.

Un approccio alternativo potrebbe essere chiudere immediatamente qualsiasi posizione long aperta appena entriamo nel regime #1. Lasciamo questo come esercizio per il lettore!

				
					       
        ..
        ..
        # Se abbiamo un regime desiderato, permettiamo gli ordini di acquisto e di 
        # vendita normalmente per una strategia di trend following di solo lungo
        if regime == 0:
            if action == "BOT":
                self.invested = True
                return [order_event]
            elif action == "SLD":
                if self.invested == True:
                    self.invested = False
                    return [order_event]
                else:
                    return []
        # Se abbiamo un regime non desiderato, non permetiamo ordini di 
        # acquisto e permettiamo solo di chiudere posizioni aperte se la 
        # strategia è già a mercato (da un precedenete regime desiderato)
        elif regime == 1:
            if action == "BOT":
                self.invested = False
                return []
            elif action == "SLD":
                if self.invested == True:
                    self.invested = False
                    return [order_event]
                else:
                    return []

				
			

Esecuzione del Backtest

Questo conclude il codice RegimeHMMRiskManager. Non ci resta che collegare insieme i tre script/moduli precedenti tramite un  oggetto Backtest. Il codice completo per questo script può essere trovato, come per il resto dei moduli, alla fine di questo articolo.

In regime_hmm_backtest.py importiamo le classi ExampleRiskManager e RegimeHMMRiskManager. Questo ci permette con un semplice “switch out” dei gestori del rischio per verificare i risultati di differenti backtest:

				
					# regime_hmm_backtest.py

..
..

from datatrader.risk_manager.example import ExampleRiskManager

..
..

from regime_hmm_strategy import MovingAverageCrossStrategy
from regime_hmm_risk_manager import RegimeHMMRiskManager
				
			

Nella funzione run iniziamo con specificare il percorso di  del file pickle con i dati necessari per la deserializzazione del l modello HMM. Successivamente specifichiamo il gestore dei dati dei prezzi. Impostiamo il flag calc_adj_return su true, in modo che il gestore dei prezzi calcoli e memorizzi l’array dei rendimenti.

In questa fase configuriamo la MovingAverageCrossStrategy con una finestra breve di 10 giorni, una finestra lunga di 30 giorni e una quantità base di azioni SPY pari a 10.000 unità.

Infine deserializziamo il hmm_model tramite pickle e creiamo un’istanza di risk_manager. Il resto dello script è estremamente simile ad altri backtest descritti negli articoli precedente, quindi riportiamo il codice completo solo alla fine dell’articolo.

Il metodo Run

È semplice “cambiare” i gestori del rischio commentando la riga RegimeHMMRiskManager, sostituendola con la riga ExampleRiskManager e quindi rieseguire il backtest:

				
					
def run(config, testing, tickers, filename):
    # Impostazione delle variabili necessarie al backtest
    pickle_path = "/path/to/your/model/hmm_model_spy.pkl"

    ..
    ..

    # uso del Use Yahoo Daily Price Handler
    start_date = datetime.datetime(2005, 1, 1)
    end_date = datetime.datetime(2014, 12, 31)
    price_handler = YahooDailyCsvBarPriceHandler(
        csv_dir, events_queue, tickers,
        start_date=start_date, end_date=end_date,
        calc_adj_returns=True
    )

    # Uso della strategia Moving Average Crossover
    base_quantity = 10000
    strategy = MovingAverageCrossStrategy(
        tickers, events_queue, base_quantity,
        short_window=10, long_window=30
    )
    strategy = Strategies(strategy, DisplayStrategy())

    ..
    ..

    # Uso del Risk Manager di determinazione del regime HMM
    hmm_model = pickle.load(open(pickle_path, "rb"))
    risk_manager = RegimeHMMRiskManager(hmm_model)
    # Uso di un Risk Manager di esempio
    #risk_manager = ExampleRiskManager()
				
			

Per eseguire il backtest è necessario aprire il Terminale e digitare quanto segue:

				
					$ python regime_hmm_backtest.py --tickers=SPY
				
			

L’output è il seguente:

				
					..
..
---------------------------------
Backtest complete.
Sharpe Ratio: 0.518857928421
Max Drawdown: 0.356537705234
Max Drawdown Pct: 0.356537705234
				
			

Risultati della strategia

 

Costi di transazione

I risultati della strategia qui presentati sono al netto dei costi di transazione. I costi sono simulati utilizzando i prezzi fissi delle azioni statunitensi di Interactive Brokers per le azioni del Nord America. Sono ragionevolmente rappresentativi di ciò che potrebbe essere ottenuto in una vera strategia di trading.

Nessun filtro di rilevamento del regime

trading-algoritmico-datatrader-regime-detection-no-filter-tearsheet

La strategia è progettata per catturare i trend a breve termine dell’ETF SPY. Otteniamo uno Sharpe Ratio di 0,37, cioè stiamo assumendo una notevole quantità di volatilità per generare pochi rendimenti. In effetti il ​​benchmark ha uno Sharpe ratio quasi identico. Il massimo drawdown giornaliero è leggermente superiore al benchmark, ma produce un leggero aumento del CAGR al 6,41% rispetto al 5,62%.

In sostanza, la strategia si comporta come il benchmark buy-and-hold. Questo è prevedibile dato che le medie mobili sono un indicatore di ritardo e, nonostante abbia effettuato 41 operazioni, non evita i grandi movimenti al ribasso. La domanda principale è se un filtro di regime migliorerà la strategia o meno.

Filtro di rilevamento del regime HMM

 

trading-algoritmico-datatrader-regime-detection-with-filter-tearsheet

Notiamo che applichiamo il filtro del regime in modalità out-of-sample. Cioè, non utilizziamo nessun dato dei rendimenti impiegato nel backtest per addestrare il modello Hidden Markov.

Conclusioni

La strategia basata sul filtro del regime produce risultati sensibilmente diversi. In particolare, riduciamo il drawdown giornaliero massimo della strategia a circa il 24%, rispetto al 56% del benchmark. Si tratta di un’ottima riduzione del “rischio”. Tuttavia, lo Sharpe ratio, pari a 0,48, non cresce in modo significativo rispetto al valore precedente, poiché la strategia continua a subire l’elevata volatilità necessaria per ottenere quei rendimenti.

Il CAGR migliora solo lievemente, raggiungendo il 6,88% rispetto al 6,41% della strategia precedente, ma riusciamo a ridurre leggermente il rischio.

Un aspetto più problematico riguarda il numero di operazioni, che scende da 41 a 31. Anche se eliminiamo grandi movimenti al ribasso (e quindi vantaggiosi), questo significa che la strategia effettua meno “scommesse positive” e, di conseguenza, possiede una minore validità statistica.

Inoltre, non eseguiamo alcuna operazione dall’inizio del 2008 alla metà del 2009. Pertanto, restiamo effettivamente al di sotto del precedente high watermark durante questo periodo. Il vantaggio principale, naturalmente, è che evitiamo perdite quando molti altri ne avrebbero subite!

Per portare “live” questa strategia, probabilmente dobbiamo prevedere un addestramento periodico del modello Hidden Markov, dato che le probabilità di transizione degli stati non si mantengono stazionarie nel tempo. In sostanza, l’HMM può prevedere le transizioni di stato solo in base alle distribuzioni precedenti dei rendimenti ricevuti come input. Se la distribuzione cambia (per esempio a causa di un nuovo contesto normativo), dobbiamo riadattare il modello per coglierne il nuovo comportamento. La frequenza con cui compiamo questa operazione resta, ovviamente, un interessante tema per ricerche future!

Codice Completo

Il codice completo della gestione del rischio con i modelli di Markov nascosti presentata in questo articolo, basato sul framework di trading quantitativo event-driven DataTrader, è disponibile nel seguente repository GitHub: https://github.com/tradingquant-it/DataTrader.

				
					# regime_hmm_train.py

import datetime
import pickle
import warnings

from hmmlearn.hmm import GaussianHMM
from matplotlib import cm, pyplot as plt
from matplotlib.dates import YearLocator, MonthLocator
import numpy as np
import pandas as pd
import seaborn as sns


def obtain_prices_df(csv_filepath, end_date):
    """
    Legge i prezzi dal file CSV e li carica in un Dataframe,
    filtra per data di fine e calcola i rendimenti percentuali.
    """
    df = pd.read_csv(
        csv_filepath, header=0,
        names=[
            "Date", "Open", "High", "Low",
            "Close", "Volume", "Adj Close"
        ],
        index_col="Date", parse_dates=True
    )
    df["Returns"] = df["Adj Close"].pct_change()
    df = df[:end_date.strftime("%Y-%m-%d")]
    df.dropna(inplace=True)
    return df

def plot_in_sample_hidden_states(hmm_model, df):
    """
    Traccia il grafico dei prezzi di chiusura rettificati
    mascherati dagli stati nascosti nel campione come
    meccanismo per comprendere i regimi di mercato.
    """
    # Array con gli stati nascosti previsti
    hidden_states = hmm_model.predict(df["Returns"])
    # Crea il grafico formattato correttamente
    fig, axs = plt.subplots(
        hmm_model.n_components,
        sharex=True, sharey=True
    )
    colours = cm.rainbow(
        np.linspace(0, 1, hmm_model.n_components)
    )
    for i, (ax, colour) in enumerate(zip(axs, colours)):
        mask = hidden_states == i
        ax.plot_date(
            df.index[mask],
            df["Adj Close"][mask],
            ".", linestyle='none',
            c=colour
        )
        ax.set_title("Hidden State #%s" % i)
        ax.xaxis.set_major_locator(YearLocator())
        ax.xaxis.set_minor_locator(MonthLocator())
        ax.grid(True)
    plt.show()


if __name__ == "__main__":
    # Nasconde gli avvisi di deprecazione per sklearn
    warnings.filterwarnings("ignore")

    # Crea il dataframe SPY dal file CSV di Yahoo Finance e
    # formatta correttamente i rendimente per l'uso nell'HMM
    csv_filepath = "/path/to/your/data/SPY.csv"
    pickle_path = "/path/to/your/model/hmm_model_spy.pkl"
    end_date = datetime.datetime(2004, 12, 31)
    spy = obtain_prices_df(csv_filepath, end_date)
    rets = np.column_stack([spy["Returns"]])

    # Crea il Gaussian Hidden markov Model e lo adatta ai
    # dati dei rendimenti di SPY, visualizzando il punteggio
    hmm_model = GaussianHMM(
        n_components=2, covariance_type="full", n_iter=1000
    ).fit(rets)
    print("Model Score:", hmm_model.score(rets))

    # Grafico dei valori di chiusura degli stati nascosti del campione
    plot_in_sample_hidden_states(hmm_model, spy)

    print("Pickling HMM model...")
    pickle.dump(hmm_model, open(pickle_path, "wb"))
    print("...HMM model pickled.")
				
			
				
					
# regime_hmm_strategy.py

from collections import deque

import numpy as np

from datatrader.price_parser import PriceParser
from datatrader.event import SignalEvent, EventType
from datatrader.strategy.base import AbstractStrategy

class MovingAverageCrossStrategy(AbstractStrategy):
    """
    Requisiti:
    tickers - La lista dei simboli dei ticker
    events_queue - Il manager della coda degli eventi
    short_window - Periodo di lookback per la media mobile breve
    long_window - Periodo di lookback per la media mobile lunga
    """
    def __init__(
        self, tickers,
        events_queue, base_quantity,
        short_window=10, long_window=30
    ):
        self.tickers = tickers
        self.events_queue = events_queue
        self.base_quantity = base_quantity
        self.short_window = short_window
        self.long_window = long_window
        self.bars = 0
        self.invested = False
        self.sw_bars = deque(maxlen=self.short_window)
        self.lw_bars = deque(maxlen=self.long_window)

    def calculate_signals(self, event):
        # Applica SMA al primo ticker
        ticker = self.tickers[0]
        if event.type == EventType.BAR and event.ticker == ticker:
            # Aggiunge l'ultimo prezzo di chiusura ai dati
            # delle finestre corta e lunga
            price = event.adj_close_price / PriceParser.PRICE_MULTIPLIER
            self.lw_bars.append(price)
            if self.bars > self.long_window - self.short_window:
                self.sw_bars.append(price)

            # Sono presenti abbastanza barre per il trading
            if self.bars > self.long_window:
                # Calcola le medie mobili semplici
                short_sma = np.mean(self.sw_bars)
                long_sma = np.mean(self.lw_bars)
                # Segnali di trading basati sulla media mobile incrociata
                if short_sma > long_sma and not self.invested:
                    print("LONG: %s" % event.time)
                    signal = SignalEvent(ticker, "BOT", self.base_quantity)
                    self.events_queue.put(signal)
                    self.invested = True
                elif short_sma < long_sma and self.invested:
                    print("SHORT: %s" % event.time)
                    signal = SignalEvent(ticker, "SLD", self.base_quantity)
                    self.events_queue.put(signal)
                    self.invested = False
            self.bars += 1
				
			
				
					
# regime_hmm_risk_manager.py

import numpy as np

from datatrader.event import OrderEvent
from datatrader.price_parser import PriceParser
from datatrader.risk_manager.base import AbstractRiskManager

class RegimeHMMRiskManager(AbstractRiskManager):
    """
    Utilizza un modello Hidden Markov precedentemente adattato
    come meccanismo di rilevamento del regime. Il gestore del
    rischio ignora gli ordini che si verificano durante
    un regime non desiderato.

    Ciò spiega anche il fatto che un'operazione può essere
    a cavallo di due regimi separati. Se un ordine di chiusura
    viene ricevuto nel regime non desiderato e l'ordine è aperto,
    verrà chiuso, ma non verranno generati nuovi ordini fino
    al raggiungimento del regime desiderato.
    """
    def __init__(self, hmm_model):
        self.hmm_model = hmm_model
        self.invested = False

    def determine_regime(self, price_handler, sized_order):
        """
        Determina il probabile regime effettuando una previsione sui rendimenti
        dei prezzi di chiusura nell'oggetto PriceHandler e quindi prende
        il valore intero finale come "stato del regime nascosto"
        """
        returns = np.column_stack(
            [np.array(price_handler.adj_close_returns)]
        )
        hidden_state = self.hmm_model.predict(returns)[-1]
        return hidden_state

    def refine_orders(self, portfolio, sized_order):
        """
        Utilizza il modello di Markov nascosto con i rendimenti percentuali
        per determinare il regime corrente, 0 per desiderabile o 1 per
        indesiderabile. Ingressi Long seguiti solo in regime 0, operazioni
        di chiusura sono consentite in regime 1.
        """
        # Determinare il regime previsto HMM come un intero
        # uguale a 0 (desiderabile) o 1 (indesiderabile)
        price_handler = portfolio.price_handler
        regime = self.determine_regime(
            price_handler, sized_order
        )
        action = sized_order.action
        # Crea l'evento dell'ordine, indipendentemente dal regime. Sarà
        # restituito solo se le condizioni corrette sono soddisfatte.
        order_event = OrderEvent(
            sized_order.ticker,
            sized_order.action,
            sized_order.quantity
        )

        # Se abbiamo un regime desiderato, permettiamo gli ordini di acquisto e di
        # vendita normalmente per una strategia di trend following di solo lungo
        if regime == 0:
            if action == "BOT":
                self.invested = True
                return [order_event]
            elif action == "SLD":
                if self.invested == True:
                    self.invested = False
                    return [order_event]
                else:
                    return []
        # Se abbiamo un regime non desiderato, non permetiamo ordini di
        # acquisto e permettiamo solo di chiudere posizioni aperte se la
        # strategia è già a mercato (da un precedenete regime desiderato)
        elif regime == 1:
            if action == "BOT":
                self.invested = False
                return []
            elif action == "SLD":
                if self.invested == True:
                    self.invested = False
                    return [order_event]
                else:
                    return []

				
			
				
					
# regime_hmm_backtest.py

import click
import datetime
import pickle

from datatrader import settings
from datatrader.compat import queue
from datatrader.price_parser import PriceParser
from datatrader.price_handler.yahoo_daily_csv_bar import YahooDailyCsvBarPriceHandler
from datatrader.strategy.base import Strategies
from datatrader.position_sizer.naive import NaivePositionSizer
from datatrader.risk_manager.example import ExampleRiskManager
from datatrader.portfolio_handler import PortfolioHandler
from datatrader.compliance.example import ExampleCompliance
from datatrader.execution_handler.ib_simulated import IBSimulatedExecutionHandler
from datatrader.statistics.tearsheet import TearsheetStatistics
from datatrader.trading_session import TradingSession

from regime_hmm_strategy import MovingAverageCrossStrategy
from regime_hmm_risk_manager import RegimeHMMRiskManager


def run(config, testing, tickers, filename):
    # Impostazione delle variabili necessarie al backtest
    pickle_path = "/path/to/your/model/hmm_model_spy.pkl"
    events_queue = queue.Queue()
    csv_dir = config.CSV_DATA_DIR
    initial_equity = PriceParser.parse(500000.00)

    # uso del Use Yahoo Daily Price Handler
    start_date = datetime.datetime(2005, 1, 1)
    end_date = datetime.datetime(2014, 12, 31)
    price_handler = YahooDailyCsvBarPriceHandler(
        csv_dir, events_queue, tickers,
        start_date=start_date, end_date=end_date,
        calc_adj_returns=True
    )

    # Uso della strategia Moving Average Crossover
    base_quantity = 10000
    strategy = MovingAverageCrossStrategy(
        tickers, events_queue, base_quantity,
        short_window=10, long_window=30
    )
    strategy = Strategies(strategy)

    # Uso di un Position Sizer standard
    position_sizer = NaivePositionSizer()

    # Uso del Risk Manager di determinazione del regime HMM
    hmm_model = pickle.load(open(pickle_path, "rb"))
    risk_manager = RegimeHMMRiskManager(hmm_model)
    # Uso di un Risk Manager di esempio
    #risk_manager = ExampleRiskManager()

    # Use del Manager di Portfolio di default
    portfolio_handler = PortfolioHandler(
        PriceParser.parse(initial_equity), events_queue, price_handler,
        position_sizer, risk_manager
    )

    # Uso del componente ExampleCompliance
    compliance = ExampleCompliance(config)

    # Uso un Manager di Esecuzione che simula IB
    execution_handler = IBSimulatedExecutionHandler(
        events_queue, price_handler, compliance
    )

    # Uso delle statistiche di default
    title = ["Trend Following Regime Detection with HMM"]
    statistics = TearsheetStatistics(
        config, portfolio_handler, title,
        benchmark="SPY"
    )

    # Settaggio del backtest
    backtest = TradingSession(
        config, strategy, tickers,
        initial_equity, start_date, end_date, events_queue,
        price_handler=price_handler,
        portfolio_handler=portfolio_handler,
        compliance=compliance,
        position_sizer=position_sizer,
        execution_handler=execution_handler,
        risk_manager=risk_manager,
        statistics=statistics,
        sentiment_handler=None,
        title=title, benchmark='SPY'
    )
    results = backtest.start_trading(testing=testing)
    statistics.save(filename)
    return results


@click.command()
@click.option('--config', default=settings.DEFAULT_CONFIG_FILENAME, help='Config filename')
@click.option('--testing/--no-testing', default=False, help='Enable testing mode')
@click.option('--tickers', default='SPY', help='Tickers (use comma)')
@click.option('--filename', default='', help='Pickle (.pkl) statistics filename')
def main(config, testing, tickers, filename):
    tickers = tickers.split(",")
    config = settings.from_file(config, testing)
    run(config, testing, tickers, filename)


if __name__ == "__main__":
    main()
				
			

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