Ereignisgesteuertes Backtesting mit Python - Teil V

Schriftsteller:Gutes, Erstellt: 2019-03-25 15:54:16, aktualisiert:

In dem vorherigen Artikel über event-driven backtesting haben wir betrachtet, wie man eine Strategy-Klassen-Hierarchie konstruiert. Strategien, wie hier definiert, werden verwendet, um Signale zu generieren, die von einem Portfolio-Objekt verwendet werden, um Entscheidungen darüber zu treffen, ob Bestellungen gesendet werden sollen. Wie zuvor ist es natürlich, eine Portfolio-Abstrakte Basisklasse (ABC) zu erstellen, von der alle nachfolgenden Unterklassen erben.

In diesem Artikel wird ein NaivePortfolio-Objekt beschrieben, das die Positionen innerhalb eines Portfolios verfolgt und auf Basis von Signalen Aufträge für eine feste Menge an Aktien generiert.

Positionsverfolgung und Auftragsverwaltung

Das Portfolioverwaltungssystem ist möglicherweise die komplexeste Komponente eines ereignisgesteuerten Backtesters. Seine Rolle besteht darin, alle aktuellen Marktpositionen sowie den Marktwert der Positionen (bekannt als Holdings) zu verfolgen. Dies ist einfach eine Schätzung des Liquidationswerts der Position und leitet sich teilweise aus der Datenverarbeitungsmöglichkeit des Backtesters ab.

Neben der Positions- und Bestandsverwaltung muss das Portfolio sich auch der Risikofaktoren und Positionsgrößerungstechniken bewusst sein, um Aufträge, die an eine Maklergesellschaft oder eine andere Form des Marktzugangs gesendet werden, zu optimieren.

Das Portfolio-Objekt muss in der Lage sein, SignalEvent-Objekte zu verarbeiten, OrderEvent-Objekte zu generieren und FillEvent-Objekte zu interpretieren, um Positionen zu aktualisieren.

Durchsetzung

Wir erstellen eine neue Dateiportfolio.pyWir müssen die Floor-Funktion aus der Mathematik-Bibliothek importieren, um ganzzahlige Bestellgrößen zu generieren. Wir brauchen auch die Objekte FillEvent und OrderEvent, da das Portfolio beide behandelt.

# portfolio.py

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

von abc import ABCMeta, abstrakte Methode von der Mathe-Import-Ebene

von Ereignisimport FillEvent, OrderEvent Wie zuvor erstellen wir ein ABC für Portfolio und haben zwei reine virtuelle Methoden update_signal und update_fill. Die erstere behandelt neue Handelssignale, die aus der Ereigniswarteschlange erfasst werden, und die letztere behandelt Füllungen, die von einem Ausführungsobjekt erhalten werden.

# 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()")

Der Hauptgegenstand dieses Artikels ist die NaivePortfolio-Klasse. Sie soll die Positionsgröße und die aktuellen Bestände verwalten, wird aber Handelsorders auf "dumme" Weise ausführen, indem sie sie unabhängig von dem gehaltenen Bargeld mit einer vorgegebenen festen Mengengröße direkt an die Maklergesellschaft senden. Dies sind alles unrealistische Annahmen, aber sie helfen zu skizzieren, wie ein Portfolio-Ordermanagementsystem (OMS) ereignisorientiert funktioniert.

Das NaivePortfolio erfordert einen Anfangskapitalwert, den ich standardmäßig auf 100.000 USD gesetzt habe.

Das Portfolio enthält die Mitglieder all_positions und current_positions. Das erstere speichert eine Liste aller früheren Positionen, die zum Zeitpunkt eines Marktdatenereignisses aufgezeichnet wurden. Eine Position ist einfach die Menge des Vermögenswerts. Negative Positionen bedeuten, dass der Vermögenswert kurz gehalten wurde. Das letztere Mitglied speichert ein Wörterbuch mit den aktuellen Positionen für die letzte Aktualisierung der Marktleiste.

Zusätzlich zu den Positionsmitgliedern speichert das Portfolio Bestände, die den aktuellen Marktwert der gehaltenen Positionen beschreiben. Tagesmarktwert bezeichnet in diesem Fall den Schlusskurs, der aus dem aktuellen Marktwert ermittelt wird, der eindeutig eine Annäherung darstellt, aber vorerst vernünftig genug ist. all_holdings speichert die historische Liste aller Symbolbestände, während current_holdings das aktuellste Wörterbuch aller Symbolbestände speichert.

# 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()

Die folgende Methode, construct_all_positions, erstellt einfach ein Wörterbuch für jedes Symbol, setzt den Wert für jedes auf Null und fügt dann einen Datumszeitschlüssel hinzu und fügt ihn schließlich einer Liste hinzu.

# 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]

Die Methode construct_all_holdings ist ähnlich der oben genannten, fügt jedoch zusätzliche Schlüssel für Bargeld, Provision und Gesamtbetrag hinzu, die jeweils das überschüssige Bargeld auf dem Konto nach allen Einkäufen, die kumulierte Provision und das Gesamtkonto einschließlich Bargeld und offener Positionen darstellen.

# 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]

Die folgende Methode, construct_current_holdings, ist fast identisch mit der obigen Methode, außer dass sie das Wörterbuch nicht in eine Liste einwickelt:

# 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

Bei jedem Heartbeat, d.h. jedes Mal, wenn neue Marktdaten vom DataHandler-Objekt angefordert werden, muss das Portfolio den aktuellen Marktwert aller gehaltenen Positionen aktualisieren.

Leider gibt es aufgrund von Bid/Ask-Spreads und Liquiditätsproblemen nicht so etwas wie den aktuellen Marktwert. Daher muss er durch Multiplikation der Menge des gehaltenen Vermögenswerts durch einen Preis geschätzt werden. Der Ansatz, den ich hier verfolgt habe, besteht darin, den Schlusskurs des letzten empfangenen Bar zu verwenden. Für eine Intraday-Strategie ist dies relativ realistisch. Für eine tägliche Strategie ist dies weniger realistisch, da sich der Eröffnungspreis erheblich vom Schlusskurs unterscheiden kann.

Die Methode update_timeindex verwaltet die neue Holding-Verfolgung. Zunächst erhält sie die neuesten Preise aus dem Marktdaten-Handler und erstellt ein neues Wörterbuch mit Symbolen, um die aktuellen Positionen darzustellen, indem sie die new Positionen gleich den current Positionen festlegt. Diese werden nur geändert, wenn ein FillEvent erhalten wird, der später im Portfolio behandelt wird. Die Methode fügt dann diesen Satz aktueller Positionen zur All_positions-Liste an. Als nächstes werden die Holdings in ähnlicher Weise aktualisiert, mit der Ausnahme, dass der Marktwert durch Multiplikation der aktuellen Positionen mit dem Schlusspreis der letzten Bar (self.current_positions[s] * bars[s][0][5]) neu berechnet wird. Schließlich werden die neuen Positionen zu allen_Holdings angefügt:

# 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)

Die Methode update_positions_from_fill bestimmt, ob ein FillEvent ein Buy oder ein Sell ist, und aktualisiert anschließend das current_positions-Wörterbuch entsprechend, indem die richtige Anzahl von Anteilen addiert/abgezogen wird:

# 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

Die entsprechende update_holdings_from_fill ist der obigen Methode ähnlich, aktualisiert aber stattdessen die Holdingswerte. Um die Kosten einer Füllung zu simulieren, verwendet die folgende Methode nicht die Kosten aus dem FillEvent. Warum ist das so? Einfach ausgedrückt, in einer Backtesting-Umgebung sind die Füllkosten tatsächlich unbekannt und müssen daher geschätzt werden. So wird die Füllkosten auf den aktuellen Marktpreis (der Schlusskurs des letzten Balkens) festgelegt. Die Holdings für ein bestimmtes Symbol werden dann auf die Füllkosten multipliziert mit der Transaktionsmenge festgelegt.

Wenn die Füllkosten bekannt sind, können die aktuellen Bestände, Bargeld und Gesamtwerte aktualisiert werden.

# 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)

Die reine virtuelle Update_fill-Methode aus dem Portfolio ABC wird hier implementiert. Sie führt einfach die beiden vorhergehenden Methoden aus, update_positions_from_fill und update_holdings_from_fill, die bereits oben besprochen wurden:

# 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)

Während das Portfolio-Objekt FillEvents verarbeiten muss, muss es sich auch um die Erzeugung von OrderEvents bei Erhalt eines oder mehrerer SignalEvents kümmern. Die generate_naive_order-Methode nimmt einfach ein Signal, um einen Vermögenswert zu longen oder zu shorten, und sendet dann einen Auftrag, dies für 100 Aktien eines solchen Vermögenswerts zu tun. 100 ist eindeutig ein willkürlicher Wert. In einer realistischen Implementierung wird dieser Wert durch ein Risikomanagement oder eine Positionsgrößenüberlagerung bestimmt. Dies ist jedoch ein NaivePortfolio und sendet daher naively alle Aufträge direkt von den Signalen aus, ohne ein Risikosystem.

Die Methode verarbeitet das Verlangen, die Verkürzung und den Ausstieg aus einer Position, basierend auf der aktuellen Menge und dem bestimmten Symbol.

# 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

Die Methode update_signal ruft einfach die obige Methode auf und fügt die generierte Reihenfolge der Ereigniswarteschlange hinzu:

# 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)

Die letzte Methode im NaivePortfolio ist die Erstellung einer Eigenkapitalkurve. Dies erzeugt einfach einen Renditenstrom, der für Leistungsberechnungen nützlich ist und normalisiert dann die Eigenkapitalkurve auf Prozentbasis. Die anfängliche Kontogröße ist also gleich 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

Das Portfolio-Objekt ist der komplexeste Aspekt des gesamten ereignisgesteuerten Backtestsystems. Die Implementierung hier ist zwar kompliziert, ist aber relativ elementar bei der Handhabung von Positionen. Spätere Versionen werden das Risikomanagement und die Positionsgröße berücksichtigen, was zu einer viel realistischeren Vorstellung von der Strategieleistung führen wird.

Im nächsten Artikel werden wir das letzte Stück des ereignisgesteuerten Backtesters betrachten, nämlich ein ExecutionHandler-Objekt, das verwendet wird, um OrderEvent-Objekte zu nehmen und FillEvent-Objekte daraus zu erstellen.


Mehr