Tests arrière basés sur des événements avec Python - Partie V

Auteur:La bonté, Créé: 2019-03-25 15:54:16, mis à jour:

Dans l'article précédent sur le backtesting basé sur les événements, nous avons examiné comment construire une hiérarchie de classes de stratégie. Les stratégies, telles que définies ici, sont utilisées pour générer des signaux, qui sont utilisés par un objet de portefeuille afin de prendre des décisions sur l'envoi ou non d'ordres. Comme précédemment, il est naturel de créer une classe de base abstraite de portefeuille (ABC) dont toutes les sous-classes suivantes héritent.

Cet article décrit un objet NaivePortfolio qui suit les positions d'un portefeuille et génère des commandes d'une quantité fixe d'actions basées sur des signaux.

Suivi de la position et gestion des commandes

Le système de gestion des ordres de portefeuille est peut-être la composante la plus complexe d'un backtester basé sur des événements. Son rôle est de suivre toutes les positions actuelles sur le marché ainsi que la valeur marchande des positions (connues sous le nom de "holdings").

En plus de la gestion des positions et des avoirs, le portefeuille doit également connaître les facteurs de risque et les techniques de dimensionnement des positions afin d'optimiser les ordres envoyés à un courtier ou à une autre forme d'accès au marché.

En continuant dans la veine de la hiérarchie des classes Event, un objet Portfolio doit être capable de gérer les objets SignalEvent, générer des objets OrderEvent et interpréter des objets FillEvent pour mettre à jour les positions.

Mise en œuvre

Nous créons un nouveau fichierportfolio.pyNous avons besoin d'importer la fonction de sol de la bibliothèque mathématique afin de générer des tailles d'ordre à valeurs entières. Nous avons également besoin des objets FillEvent et OrderEvent puisque le Portfolio gère les deux.

# portfolio.py

import datetime
import numpy as np
import pandas as pd
import Queue

de l'abc à l'import ABCMeta, méthode abstraite depuis le plancher d'importation mathématique

depuis l'importation d'événement FillEvent, OrderEvent Comme précédemment, nous créons un ABC pour Portfolio et avons deux méthodes virtuelles pures update_signal et update_fill. Le premier gère les nouveaux signaux de trading saisis à partir de la file d'attente d'événements et le second gère les remplis reçus d'un objet gestionnaire d'exécution.

# portfolio.py

class Portfolio(object):
    """
    The Portfolio class handles the positions and market
    value of all instruments at a resolution of a "bar",
    i.e. secondly, minutely, 5-min, 30-min, 60 min or EOD.
    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def update_signal(self, event):
        """
        Acts on a SignalEvent to generate new orders 
        based on the portfolio logic.
        """
        raise NotImplementedError("Should implement update_signal()")

    @abstractmethod
    def update_fill(self, event):
        """
        Updates the portfolio current positions and holdings 
        from a FillEvent.
        """
        raise NotImplementedError("Should implement update_fill()")

Le sujet principal de cet article est la classe NaivePortfolio. Elle est conçue pour gérer la taille des positions et les avoirs courants, mais exécutera les ordres de négociation de manière " stupide " en les envoyant simplement directement au courtier avec une taille de quantité fixe prédéterminée, indépendamment de la trésorerie détenue.

Le portefeuille naïf nécessite une valeur de capital initial, que j'ai fixée à 100 000 USD par défaut.

Le portefeuille contient les membres all_positions et current_positions. Le premier stocke une liste de toutes les positions précédentes enregistrées à l'horodatage d'un événement de données de marché. Une position est simplement la quantité de l'actif. Les positions négatives signifient que l'actif a été raccourci. Le dernier membre stocke un dictionnaire contenant les positions actuelles pour la dernière mise à jour de la barre de marché.

En plus des positions des participants, le portefeuille stocke les avoirs, qui décrivent la valeur marchande actuelle des positions détenues. Valeur marchande actuelle désigne dans ce cas le prix de clôture obtenu à partir de la barre de marché actuelle, qui est clairement une approximation, mais est suffisamment raisonnable pour le moment. all_holdings stocke la liste historique de toutes les avoirs symboliques, tandis que current_holdings stocke le dictionnaire le plus à jour de toutes les avoirs symboliques.

# portfolio.py

class NaivePortfolio(Portfolio):
    """
    The NaivePortfolio object is designed to send orders to
    a brokerage object with a constant quantity size blindly,
    i.e. without any risk management or position sizing. It is
    used to test simpler strategies such as BuyAndHoldStrategy.
    """
    
    def __init__(self, bars, events, start_date, initial_capital=100000.0):
        """
        Initialises the portfolio with bars and an event queue. 
        Also includes a starting datetime index and initial capital 
        (USD unless otherwise stated).

        Parameters:
        bars - The DataHandler object with current market data.
        events - The Event Queue object.
        start_date - The start date (bar) of the portfolio.
        initial_capital - The starting capital 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()

La méthode suivante, construct_all_positions, crée simplement un dictionnaire pour chaque symbole, définit la valeur à zéro pour chacun et ajoute ensuite une clé de date et heure, l'ajoutant finalement à une liste.

# portfolio.py

    def construct_all_positions(self):
        """
        Constructs the positions list using the start_date
        to determine when the time index will begin.
        """
        d = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )
        d['datetime'] = self.start_date
        return [d]

La méthode construct_all_holdings est similaire à celle ci-dessus, mais ajoute des clés supplémentaires pour l'argent liquide, la commission et le total, qui représentent respectivement l'argent liquide supplémentaire sur le compte après tout achat, la commission cumulée accumulée et le capital total du compte, y compris l'argent liquide et les positions ouvertes. Les positions courtes sont traitées comme négatives.

# portfolio.py

    def construct_all_holdings(self):
        """
        Constructs the holdings list using the start_date
        to determine when the time index will begin.
        """
        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]

La méthode suivante, construct_current_holdings est presque identique à la méthode ci-dessus sauf qu'elle n'enveloppe pas le dictionnaire dans une liste:

# portfolio.py

    def construct_current_holdings(self):
        """
        This constructs the dictionary which will hold the instantaneous
        value of the portfolio across all symbols.
        """
        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

Dans un scénario de négociation en direct, ces informations peuvent être téléchargées et analysées directement depuis le courtier, mais pour une mise en œuvre de backtesting, il est nécessaire de calculer ces valeurs manuellement.

Malheureusement, il n'existe pas de "valeur de marché courante" en raison des écarts bid/ask et des problèmes de liquidité. Il est donc nécessaire de l'estimer en multipliant la quantité de l'actif détenu par un "prix". L'approche que j'ai adoptée ici est d'utiliser le prix de clôture de la dernière barre reçue. Pour une stratégie intraday, c'est relativement réaliste. Pour une stratégie quotidienne, c'est moins réaliste car le prix d'ouverture peut différer considérablement du prix de clôture.

La méthode update_timeindex gère le suivi des nouvelles positions. Elle obtient d'abord les derniers prix du gestionnaire de données de marché et crée un nouveau dictionnaire de symboles pour représenter les positions actuelles, en définissant les positions new égales aux positions current. Ceux-ci ne sont modifiés que lorsqu'un FillEvent est obtenu, qui est géré plus tard dans le portefeuille. La méthode ajoute ensuite cet ensemble de positions actuelles à la liste des all_positions. Ensuite, les positions sont mises à jour de manière similaire, sauf que la valeur marchande est recalculée en multipliant les positions actuelles par le prix de clôture de la dernière barre (self.current_positions[s] * bars[s][0][5]). Enfin, les nouvelles positions sont ajoutées à toutes les_positions:

# portfolio.py

    def update_timeindex(self, event):
        """
        Adds a new record to the positions matrix for the current 
        market data bar. This reflects the PREVIOUS bar, i.e. all
        current market data at this stage is known (OLHCVI).

        Makes use of a MarketEvent from the events queue.
        """
        bars = {}
        for sym in self.symbol_list:
            bars[sym] = self.bars.get_latest_bars(sym, N=1)

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

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

        # Append the current positions
        self.all_positions.append(dp)

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

        for s in self.symbol_list:
            # Approximation to the real value
            market_value = self.current_positions[s] * bars[s][0][5]
            dh[s] = market_value
            dh['total'] += market_value

        # Append the current holdings
        self.all_holdings.append(dh)

La méthode update_positions_from_fill détermine si un FillEvent est un Buy ou un Sell, puis met à jour le dictionnaire current_positions en conséquence en ajoutant/soustrayant la quantité correcte d'actions:

# portfolio.py

    def update_positions_from_fill(self, fill):
        """
        Takes a FilltEvent object and updates the position matrix
        to reflect the new position.

        Parameters:
        fill - The FillEvent object to update the positions with.
        """
        # 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

        # Update positions list with new quantities
        self.current_positions[fill.symbol] += fill_dir*fill.quantity

La méthode correspondante update_holdings_from_fill est similaire à la méthode ci-dessus, mais met à jour les valeurs de détention à la place. Afin de simuler le coût d'un remplissage, la méthode suivante n'utilise pas le coût associé à l'événement FillEvent. Pourquoi? En termes simples, dans un environnement de backtesting, le coût de remplissage est en fait inconnu et doit donc être estimé. Ainsi, le coût de remplissage est fixé au prix de marché actuel (le prix de clôture de la dernière barre). Les détentions pour un symbole particulier sont alors fixées à égal au coût de remplissage multiplié par la quantité transactionnée.

Une fois que le coût de remplissage est connu, les avoirs courants, les liquidités et les valeurs totales peuvent tous être mis à jour.

# portfolio.py

    def update_holdings_from_fill(self, fill):
        """
        Takes a FillEvent object and updates the holdings matrix
        to reflect the holdings value.

        Parameters:
        fill - The FillEvent object to update the holdings with.
        """
        # 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

        # Update holdings list with new quantities
        fill_cost = self.bars.get_latest_bars(fill.symbol)[0][5]  # Close price
        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)

La méthode virtuelle pure update_fill du Portfolio ABC est implémentée ici. Elle exécute simplement les deux méthodes précédentes, update_positions_from_fill et update_holdings_from_fill, qui ont déjà été discutées ci-dessus:

# portfolio.py

    def update_fill(self, event):
        """
        Updates the portfolio current positions and holdings 
        from a FillEvent.
        """
        if event.type == 'FILL':
            self.update_positions_from_fill(event)
            self.update_holdings_from_fill(event)

Bien que l'objet Portfolio doit gérer FillEvents, il doit également s'occuper de générer des OrderEvents à la réception d'un ou plusieurs SignalEvents. La méthode generate_naive_order prend simplement un signal pour long ou short un actif et envoie ensuite un ordre pour le faire pour 100 actions d'un tel actif.

La méthode gère le longing, le shorting et la sortie d'une position, en fonction de la quantité actuelle et du symbole particulier.

# portfolio.py

    def generate_naive_order(self, signal):
        """
        Simply transacts an OrderEvent object as a constant quantity
        sizing of the signal object, without risk management or
        position sizing considerations.

        Parameters:
        signal - The SignalEvent signal information.
        """
        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

La méthode update_signal appelle simplement la méthode ci-dessus et ajoute l'ordre généré à la file d'attente des événements:

# portfolio.py

    def update_signal(self, event):
        """
        Acts on a SignalEvent to generate new orders 
        based on the portfolio logic.
        """
        if event.type == 'SIGNAL':
            order_event = self.generate_naive_order(event)
            self.events.put(order_event)

La méthode finale dans le NaivePortfolio est la génération d'une courbe de rentabilité. Cela crée simplement un flux de rendement, utile pour les calculs de performance, puis normalise la courbe de rentabilité pour être basée sur le pourcentage. Ainsi, la taille initiale du compte est égale à 1.0:

# portfolio.py

    def create_equity_curve_dataframe(self):
        """
        Creates a pandas DataFrame from the all_holdings
        list of dictionaries.
        """
        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

L'objet Portfolio est l'aspect le plus complexe de l'ensemble du système de backtest basé sur les événements.

Dans l'article suivant, nous examinerons la dernière partie du backtester axé sur les événements, à savoir un objet ExecutionHandler, qui est utilisé pour prendre des objets OrderEvent et en créer des objets FillEvent.


Plus de