Motore di Backtesting con Python – Parte V (Portafoglio)

Nel precedente articolo relativo al backtesting basato sugli eventi abbiamo descritto come costruire la gerarchia della classe Strategy.

Le strategie, per come sono state definite, sono utilizzate per generare signals, che sono l’input di un oggetto portfolio al fine di decidere se inviare o meno gli orders. Inizialmente, è naturale creare una classe astratta di base (ABC) del Portfolio da cui si ereditano tutte le sottoclassi successive.

Questo articolo descrive un oggetto NaivePortfolio che tiene traccia delle posizioni all’interno di un portafoglio e genera ordini di una quantità fissa di azioni in base ai segnali. Oggetti di portfolio avanzati includono strumenti di gestione del rischio più sofisticati e saranno oggetto di articoli successivi.

Monitoraggio della Posizione e Gestione degli Ordini

Il sistema di gestione degli ordini del portafoglio è probabilmente la componente più complessa di un ambiente backtesting basato sugli eventi. Questa componente ha il compito di tenere traccia di tutte le attuali posizioni aperte sul mercato e del valore di mercato di queste posizioni (note come “holdings”). Questa è semplicemente una stima del valore di liquidazione della posizione ed è derivata in parte dalla funzione di gestione dei dati del backtester.

Oltre alle posizioni e alla gestione degli holdings, il portafoglio deve essere a conoscenza dei fattori di rischio e delle tecniche di dimensionamento delle posizioni al fine di ottimizzare gli ordini inviati ad un broker o verso altre forme di accesso al mercato.

In analogia alla gerarchia della classe Event, un oggetto Portfolio deve essere in grado di gestire oggetti SignalEvent, generare oggetti OrderEvent e interpretare oggetti FillEvent per aggiornare le posizioni. Pertanto non sorprende che gli oggetti portfolio siano spesso il componente più importante dei sistemi event-driven, in termini di righe di codice (LOC).

Implementazione

Si crea un nuovo file portfolio.py e si importa le librerie necessarie. Queste sono le stesse della maggior parte delle altre implementazioni delle classe astratte di base. In particolare si importa la funzione floor dalla libreria math per generare dimensioni di ordine con valori interi, ed inoltre si importano gli oggetti FillEvent e OrderEvent poiché il Portfolio gestisce entrambi.
            # portfolio.py

import datetime
import numpy as np
import pandas as pd
import queue

from abc import ABCMeta, abstractmethod
from math import floor

from event import FillEvent, OrderEvent
        

 

A questo punto si crea una classe ABC per il Portfolio e si implementano due metodi virtuali update_signal e update_fill. Il primo elabora i nuovi segnali di trading che vengono prelevati dalla coda degli eventi, mentre il secondo gestisce gli ordini eseguiti e ricevuti dall’oggetto di gestione dell’esecuzione.

            # portfolio.py

class Portfolio(object):
    """
    La classe Portfolio gestisce le posizioni e il valore di
    mercato di tutti gli strumenti alla risoluzione di una "barra",
    cioè ogni secondo, ogni minuto, 5 minuti, 30 minuti, 60 minuti o EOD.
    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def update_signal(self, event):
        """
        Azioni su un SignalEvent per generare nuovi ordini
        basati sulla logica di portafoglio
        """
        raise NotImplementedError("Should implement update_signal()")

    @abstractmethod
    def update_fill(self, event):
        """
        Aggiorna le posizioni e il patrimonio del portafoglio 
        da un FillEvent.
        """
        raise NotImplementedError("Should implement update_fill()")
        

L’argomento principale di questo articolo è la classe NaivePortfolio. Questa classe è progettata per gestire il dimensionamento delle posizioni e gli holdings correnti, ma esegue gli ordini di compravendita in modo “stupido”, semplicemente inviandoli direttamente al broker con una dimensione fissa e predeterminata, indipendentemente dalla liquidità detenuta. Queste sono tutte ipotesi irrealistiche, ma aiutano a delineare come funziona un sistema di gestione degli ordini di portafoglio (OMS) basato sugli eventi.

La NaivePortfolio richiede un valore del capitale iniziale, che ho impostato sul valore predefinito di 100.000 USD. Richiede anche una data di inizio.

Il portfolio contiene gli attributi all_positions e current_positions. Il primo memorizza un elenco di tutte le precedenti posizioni registrate ad uno specifico timestamp di un evento di dati di mercato. Una posizione è semplicemente la quantità dell’asset. Le posizioni negative indicano che l’asset è stato ridotto. Il secondo attributo memorizza un dizionario contenente le posizioni correnti per l’ultimo aggiornamento dei dati di mercato.

Oltre agli attributi delle posizioni, il portafoglio memorizza gli holdings, che descrivono il valore corrente di mercato delle posizioni detenute. Il “Valore corrente di mercato” indica, in questo caso, il prezzo di chiusura ottenuto dalla barra OLHCV corrente, che è chiaramente un’approssimazione, ma è abbastanza accettabile in questo momento. L’attributo all_holdings memorizza la lista storica di tutte gli holding dei simboli, mentre current_holdings memorizza il dizionario aggiornato di tutti i valori di holdings dei simboli.

            # portfolio.py

class NaivePortfolio(Portfolio):
    """
    L'oggetto NaivePortfolio è progettato per inviare ordini a
    un oggetto di intermediazione con una dimensione di quantità costante,
    cioè senza alcuna gestione del rischio o dimensionamento della posizione. È
    utilizzato per testare strategie più semplici come BuyAndHoldStrategy.
    """

    def __init__(self, bars, events, start_date, initial_capital=100000.0):
        """
        Inizializza il portfolio con la coda delle barre e degli eventi.
        Include anche un indice datetime iniziale e un capitale iniziale
        (USD se non diversamente specificato).

        Parametri:
        bars - L'oggetto DataHandler con i dati di mercato correnti.
        events: l'oggetto Event Queue (coda di eventi).
        start_date - La data di inizio (barra) del portfolio.
        initial_capital - Il capitale iniziale in USD.
        """
        self.bars = bars
        self.events = events
        self.symbol_list = self.bars.symbol_list
        self.start_date = start_date
        self.initial_capital = initial_capital

        self.all_positions = self.construct_all_positions()
        self.current_positions = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])

        self.all_holdings = self.construct_all_holdings()
        self.current_holdings = self.construct_current_holdings()
        

 

Il seguente metodo, construct_all_positions, crea semplicemente un dizionario per ogni simbolo, e per ciascuno imposta il valore a zero e quindi aggiunge una chiave datetime, inserendo infine questo oggetto in un elenco. Usa una comprensione del dizionario, che è simile alla comprensione di una lista:

            # portfolio.py

    def construct_all_positions(self):
        """
        Costruisce l'elenco delle posizioni utilizzando start_date
        per determinare quando inizierà l'indice temporale.
        """
        d = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )
        d['datetime'] = self.start_date
        return [d]
        
Il metodo construct_all_holdings è simile al precedente, ma aggiunge delle chiavi extra per memorizzare i contanti, le commissioni e il totale, che rappresentano rispettivamente la riserva di denaro nel conto dopo eventuali acquisti, la commissione cumulativa maturata e il totale del conto azionario inclusi i contanti e le posizioni aperte. Le posizioni short sono considerate negative. I contanti (cash) e il totale (total) sono entrambi inizializzati con il capitale iniziale:
            # portfolio.py

    def construct_all_holdings(self):
        """
        Costruisce l'elenco delle partecipazioni utilizzando start_date
        per determinare quando inizierà l'indice temporale.
        """
        d = dict( (k,v) for k, v in [(s, 0.0) for s in self.symbol_list] )
        d['datetime'] = self.start_date
        d['cash'] = self.initial_capital
        d['commission'] = 0.0
        d['total'] = self.initial_capital
        return [d]
        

 

Il metodo seguente, construct_current_holdings è quasi identico al metodo precedente, tranne per il fatto che non racchiude il dizionario in un elenco:

            # portfolio.py

    def construct_current_holdings(self):
        """
        Questo costruisce il dizionario che conterrà l'istantaneo
        valore del portafoglio attraverso tutti i simboli.
        """
        d = dict( (k,v) for k, v in [(s, 0.0) for s in self.symbol_list] )
        d['cash'] = self.initial_capital
        d['commission'] = 0.0
        d['total'] = self.initial_capital
        return d
        

Ad ogni “battito” o impulso del sistema, cioè ogni volta che vengono richiesti nuovi dati di mercato dall’oggetto DataHandler, il portfolio deve aggiornare il valore corrente di mercato di tutte le posizioni detenute. In uno scenario di trading live queste informazioni possono essere scaricate e analizzate direttamente dal broker, ma per un’implementazione di backtesting è necessario calcolare manualmente questi valori.

Sfortunatamente non esiste una cosa come il “valore corrente di mercato” a causa degli spread bid / ask e delle problematiche di liquidità. Quindi è necessario stimarlo moltiplicando la quantità del bene detenuta per un determinato “prezzo”. L’approccio utilizzato in questo esempio prevede di utilizzare il prezzo di chiusura dell’ultima barra ricevuta. Per una strategia intraday questo è relativamente realistico. Per una strategia quotidiana questo è meno realistico in quanto il prezzo di apertura può differire molto dal prezzo di chiusura.

Il metodo update_timeindex gestisce il monitoraggio dei nuovi holdings. In particolare ricava i prezzi più recenti dal gestore dei dati di mercato e crea un nuovo dizionario di simboli per rappresentare le posizioni correnti, impostando le posizioni “nuove” uguali alle posizioni “correnti”. Questi vengono modificati solo quando si riceva un FillEvent, che viene successivamente gestito dal portfolio. Il metodo quindi aggiunge questo insieme di posizioni correnti alla lista all_positions. Successivamente, le posizioni vengono aggiornate in modo simile, con l’eccezione che il valore di mercato viene ricalcolato moltiplicando il conteggio delle posizioni correnti con il prezzo di chiusura dell’ultima barra (self.current_positions [s] * bars [s] [0] [ 5]). Infine, i nuovi holdings sono agggiunti a all_holdings:

            # portfolio.py

    def update_timeindex(self, event):
        """
        Aggiunge un nuovo record alla matrice delle posizioni per la barra corrente
        dei dati di mercato. Questo riflette la barra PRECEDENTE, cioè in questa fase
        tutti gli attuali dati di mercato sono noti (OLHCVI).

        Utilizza un MarketEvent dalla coda degli eventi.
        """
        latest_datetime = self.bars.get_latest_bar_datetime(
                                self.symbol_list[0]
                            )

        # Update positions
        dp = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )
        dp['datetime'] = latest_datetime

        for s in self.symbol_list:
            dp[s] = self.current_positions[s]

        # Aggiunge le posizioni correnti
        self.all_positions.append(dp)

        # Aggiorno delle holdings
        dh = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )
        dh['datetime'] = latest_datetime
        dh['cash'] = self.current_holdings['cash']
        dh['commission'] = self.current_holdings['commission']
        dh['total'] = self.current_holdings['cash']

        for s in self.symbol_list:
            # Approossimazione ad un valore reale
            market_value =  market_value = self.current_positions[s] * \
                            self.bars.get_latest_bar_value(s, "adj_close")"adj_close")
            dh[s] = market_value
            dh['total'] += market_value

        # Aggiunta alle holdings correnti
        self.all_holdings.append(dh)
        

 

Il metodo update_positions_from_fill determina se FillEvent è un Buy o un Sell e quindi aggiorna di conseguenza il dizionario current_positions aggiungendo / sottraendo la corretta quantità di asset:

            # portfolio.py

    def update_positions_from_fill(self, fill):
        """
        Prende un oggetto FilltEvent e aggiorna la matrice delle posizioni
        per riflettere le nuove posizioni.

        Parametri:
        fill - L'oggetto FillEvent da aggiornare con le posizioni.
        """
        # Check whether the fill is a buy or sell
        fill_dir = 0
        if fill.direction == 'BUY':
            fill_dir = 1
        if fill.direction == 'SELL':
            fill_dir = -1

        # Aggiorna le posizioni con le nuove quantità
        self.current_positions[fill.symbol] += fill_dir * fill.quantity
        

 

Il corrispondente update_holdings_from_fill è simile al metodo precedente ma aggiorna i valori di holdings. Per simulare il costo di riempimento, il metodo seguente non utilizza il costo associato a FillEvent. Perchè questo approccio? In parole povere, in un ambiente di backtesting il costo di riempimento è in realtà sconosciuto e quindi deve essere stimato. Quindi il costo di riempimento è impostato sul “prezzo corrente di mercato” (il prezzo di chiusura dell’ultima barra). Le posizioni per un particolare simbolo vengono quindi impostate per essere uguali al costo di riempimento moltiplicato per la quantità del trade.

Una volta che il costo di riempimento è noto, gli holdings correnti, i contanti e i valori totali possono essere aggiornati. Anche la commissione cumulativa viene aggiornata:

            # portfolio.py

    def update_holdings_from_fill(self, fill):
        """
        Prende un oggetto FillEvent e aggiorna la matrice delle holdings
        per riflettere il valore delle holdings.

        Parametri:
        fill - L'oggetto FillEvent da aggiornare con le holdings.
        """
        # Controllo se l'oggetto fill è un buy o sell
        fill_dir = 0
        if fill.direction == 'BUY':
            fill_dir = 1
        if fill.direction == 'SELL':
            fill_dir = -1

        # Aggiorna la lista di holdings con le nuove quantità
        fill_cost = self.bars.get_latest_bar_value(fill.symbol, "adj_close")
        cost = fill_dir * fill_cost * fill.quantity
        self.current_holdings[fill.symbol] += cost
        self.current_holdings['commission'] += fill.commission
        self.current_holdings['cash'] -= (cost + fill.commission)
        self.current_holdings['total'] -= (cost + fill.commission)
        

 

Qui viene implementato il metodo virtuale update_fill della classe ABC Portfolio . Esegue semplicemente i due metodi precedenti, update_positions_from_fill update_holdings_from_fill, che sono già stati discussi sopra:

             # portfolio.py

def update_fill(self, event):
        """
        Aggiorna le attuali posizioni e holdings del portafoglio da un FillEvent.
        """
        if event.type == 'FILL':
            self.update_positions_from_fill(event)
            self.update_holdings_from_fill(event)
        

L’oggetto Portfolio, oltre a gestire i FillEvents, deve anche occuparsi della generazione degli OrderEvents al ricevimento di uno o più SignalEvents. Il metodo generate_naive_order prende un segnale di long o short di un asset e invia un ordine per aprire una posizione per 100 shares di tale asset. Chiaramente 100 è un valore arbitrario. In un’implementazione realistica questo valore sarà determinato da una gestione del rischio o da un overlay di ridimensionamento della posizione. Tuttavia, questo è un NaivePortfolio e quindi “ingenuamente” invia tutti gli ordini direttamente dai segnali, senza un sistema di dimensionamento della posizione.

Il metodo gestisce il long, lo short e l’uscita di una posizione, in base alla quantità corrente e allo specifico simbolo. Infine vengono generati i corrispondenti oggetti OrderEvent:

            # portfolio.py

    def generate_naive_order(self, signal):
        """
        Trasmette semplicemente un oggetto OrderEvent con una quantità costante
        che dipendente dell'oggetto segnale, senza gestione del rischio o
        considerazioni sul dimensionamento della posizione.

        Parametri:
        signal - L'oggetto SignalEvent.
        """     
        order = None

        symbol = signal.symbol
        direction = signal.signal_type
        strength = signal.strength

        mkt_quantity = floor(100 * strength)
        cur_quantity = self.current_positions[symbol]
        order_type = 'MKT'

        if direction == 'LONG' and cur_quantity == 0:
            order = OrderEvent(symbol, order_type, mkt_quantity, 'BUY')
        if direction == 'SHORT' and cur_quantity == 0:
            order = OrderEvent(symbol, order_type, mkt_quantity, 'SELL')   
    
        if direction == 'EXIT' and cur_quantity > 0:
            order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL')
        if direction == 'EXIT' and cur_quantity < 0:
            order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY')
        return order
        

 

Il metodo update_signal richiama semplicemente il metodo precedente e aggiunge l’ordine generato alla coda degli eventi:

            # portfolio.py

    def update_signal(self, event):
        """
        Azioni a seguito di un SignalEvent per generare nuovi ordini
        basati sulla logica del portafoglio 
        """
        if event.type == 'SIGNAL':
            order_event = self.generate_naive_order(event)
            self.events.put(order_event)
        
Il penultimo metodo di NaivePortfolio prevede la generazione di una curva equity. Crea semplicemente un flusso dei rendimenti, utilizzato per i calcoli delle prestazioni e quindi normalizza la curva equity in base alla percentuale. La dimensione iniziale dell’account è pari a 1,0:
            # portfolio.py

    def create_equity_curve_dataframe(self):
        """
        Crea un DataFrame pandas dalla lista di dizionari "all_holdings"
        """
        curve = pd.DataFrame(self.all_holdings)
        curve.set_index('datetime', inplace=True)
        curve['returns'] = curve['total'].pct_change()
        curve['equity_curve'] = (1.0+curve['returns']).cumprod()
        self.equity_curve = curve
        
Il metodo finale nel NaivePortfolio è l’output della curva azionaria e di varie statistiche sulle performance della strategia. L’ultima riga genera un file, equity.csv, nella stessa directory del codice, che può essere caricato in uno script Matplotlib Python (o un foglio di calcolo come MS Excel o LibreOffice Calc) per un’analisi successiva. Si noti che la Durata del Drawdown è data in termini di numero assoluto di “barre” per le quali si è svolto il Drawdown, al contrario di un determinato periodo di tempo.
            # portfolio.py

    def output_summary_stats(self):
        """
        Crea un elenco di statistiche di riepilogo per il portafoglio
        come lo Sharpe Ratio e le informazioni sul drowdown.
        """
        total_return = self.equity_curve['equity_curve'][-1]
        returns = self.equity_curve['returns']
        pnl = self.equity_curve['equity_curve']
        sharpe_ratio = create_sharpe_ratio(returns, periods=252 * 60 * 6.5)
        drawdown, max_dd, dd_duration = create_drawdowns(pnl)
        self.equity_curve['drawdown'] = drawdown
        stats = [("Total Return", "%0.2f%%" % \
                  ((total_return - 1.0) * 100.0)),
                 ("Sharpe Ratio", "%0.2f" % sharpe_ratio),
                 ("Max Drawdown", "%0.2f%%" % (max_dd * 100.0)),
                 ("Drawdown Duration", "%d" % dd_duration)]
        self.equity_curve.to_csv('equity.csv')
        return stats
        

L’oggetto NaivePortfolio è la componente più complessa dell’intero sistema di backtesting basato sugli eventi. L’implementazione è complessa, quindi in questo articolo abbiamo semplificato alcuni aspetti tra cui la gestione delle posizioni. Le versioni successive prenderanno in considerazione la gestione del rischio e il dimensionamento delle posizioni, che porterà a un’idea molto più realistica delle prestazioni della strategia.

Nel prossimo articolo considereremo l’ultimo modulo di un sistema di backtesting event-driven, ovvero l’oggetto ExecutionHandler, che viene utilizzato per prelevare oggetti OrderEvent e creare oggetti FillEvent.

 

Per il codice completo riportato in questo articolo, utilizzando il modulo di backtesting event-driven DataBacktest si può consultare il seguente repository di github:
https://github.com/datatrading-info/DataBacktest

Torna in alto
Scroll to Top