[Перевод] Событийно-ориентированный бэктестинг на Python шаг за шагом. Часть 3

ecc9366797aa47e2a868041d4cd98c7b.png

В предыдущих статьях мы говорили о том, что такое событийно-ориентированная система бэктестинга, разобрали иерархию классов, которую необходимо для нее разработать, и обсудили то, как подобные системы используют рыночные данные в контексте исторического тестирования и для «живой» работы на бирже.

Сегодня мы опишем объект NaivePortfolio, который отвечает за отслеживание позиций в портфолио и на основе поступающих сигналов генерирует приказы с ограниченным количеством акций.

Отслеживание позиций и работа с ордерами


Система управления ордерами является одним из самых сложных компонентов событийно-ориентированного бэктестера. Ее роль заключается в отслеживании текущих рыночных позиций и их рыночной стоимости. Таким образом на основе данных, полученных из соответствующего компонента бэктестера, рассчитывается ликвидационная стоимость позиции.

Помимо анализа позиций компонент портфолио должен учитывать факторы риска и уметь применять техники определения размера позиций — это нужно для оптимизации ордеров, отправляемых на рынок через торговую систему брокера.

Объект Portfolio должен уметь обрабатывать объекты SignalEvent, генерировать объекты OrderEvent и интерпретировать объекты FillEvent, чтобы обновлять позиции. Таким образом, нет ничего удивительного в том, что объекты Portfolio обычно являются наиболее объемными элементами системы бэктестинта с точки зрения строк кода.

Реализация


Создадим новый файл portfolio.py и импортируем необходимые библиотеки — те же реализации абстрактного базового класса, что мы использовали ранее. Нужно импортировать функцию floor из библиотеки math, чтобы генерировать целочисленные приказы. Также необходимы объекты FillEvent и OrderEvent — объект Portfolio обрабатывает каждый из них.

# 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


Создается абстрактный базовый класс для Portfolio и два абстрактных метода update_signal и update_fill. Первый обрабатывает новые торговые сигналы, которые забираются из очереди событий, а последний работает с информацией об исполненных ордерах, получаемых из движка объекта-обработчика.

# portfolio.py

class Portfolio(object):
    """
    Класс Portfolio обрабатывает позиции и рыночную стоимость всех инструментов на основе баров: секунда, минута, 5 минут, 30 мин, 60 минут или день.
    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def update_signal(self, event):
        """
        Использует SignalEvent для генерации новых ордеров в соответствие с логикой портфолио.

        """
        raise NotImplementedError("Should implement update_signal()")

    @abstractmethod
    def update_fill(self, event):
        """
        Обновляет текущие позиции и зарезервированные средства в портфолио на основе      FillEvent.

        """
        raise NotImplementedError("Should implement update_fill()")



Главный объект сегодняшней статьи — класс NaivePortfolio. Он создан для расчета размеров позиций и зарезервированных средств и обработки торговых приказов в простой манере — просто отправляя их в брокерскую торговую систему с заданным количеством акций. В реальном мире все сложнее, но подобные упрощения помогают понять, как должна функционировать система обработки приказов портфолио в событийно-ориентированных продуктах.

NaivePortfolio требует величину начального капитала — в примере она установлена на $100000. Также необходимо задать день и время начала работы.

Портфолио содержит all_positions и current_positions. Первый элемент хранит список всех предыдущий позиций, записанных по временной метке рыночного события. Позиция — это просто количество финансвого инструмента. Негативные позиции означают, что акции проданы «в короткую». Второй элемент хранит словарь, содержащий текущие позиции для последнего обновления баров.

В добавок к элементами, отвечающим за позиции, в портфолио хранится информация о текущей рыночной стоимости открытых позиций (holdings). «Текущая рыночная стоимость» в данном случае означает цену закрытия, полученную из текущего бара, которая является приблизительной, но достаточно правдоподобной на данный момент. Элемент all_holdings хранит исторический список стоимости всех позиций, а current_holdings хранит наиболее свежий словарь значений:

# portfolio.py

class NaivePortfolio(Portfolio):
    """
    
Объект NaivePortfolio создан для слепой (т.е. без всякого риск-менеджмента)  отправки приказов на покупку/продажу установленного количество акций, в брокерскую систему. Используется для тестирования простых стратегий вроде BuyAndHoldStrategy.
    """
    
    def __init__(self, bars, events, start_date, initial_capital=100000.0):
        """
        Инициализирует портфолио на основе информации из баров и очереди событий. Также включает дату и время начала и размер начального капитала (в долларах, если не указана другая валюта).

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


Следующий метод construct_all_positions просто создает словарь для каждого финансового инструмента, устанавливает каждое значение в ноль, а затем добавляет ключ даты и времени. Используется генераторы словарей Python.

# portfolio.py

    def construct_all_positions(self):
        """
        Конструирует список позиций, используя start_date для определения момента, с которой должен начинаться временной индекс.
        """
        d = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )
        d['datetime'] = self.start_date
        return [d]


Метож construct_all_hldings похож на описанный выше, но добавляет некоторые дополнительные ключи для свободных средств, комиссий и остаток денег на счету после совершения сделок, общую уплаченную комиссию и общий объём имеющихся активов (открытые позиции и деньги). Короткие позиции рассматриваются как «негативные». Величины starting cash и total account равняются первоначальному капиталу:

# portfolio.py

    def construct_all_holdings(self):
        """
        Конструирует список величин текущей стоимости позиций, используя start_date для определения момента, с которой должен начинаться временной индекс.
        """
        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]


Метод construct_current_holdings практически идентичен предыдущему, за исключением того, что не «оборачивает» словарь в список:

# portfolio.py

    def construct_current_holdings(self):
        """
        Конструирует словарь, который будет содержать мгновенное значение портфолио по всем инструментам.
      
        """
        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


На каждом «ударе сердца», то есть при каждом запросе рыночных данных из объекта DataHandler, портфолио должно обновлять текущее рыночное значение удерживаемых позиций. В сценарии реального трейдинга эту информацию можно загрузать и парсить напрямую из брокерской системы, но для системы бэктестинга необходимо отдельно высчитывать эти значения.

К сожалению, из-за спредов бидов/асков и ликвидности, такой вещи, как «текущее рыночное значение» не существует. Поэтому необходимо его оценивать умножая количество удерживаемого актива на «цену». В нашем примере используется цена закрытия предыдущего полученного бара.Для стратегий торговли внутри дня это довольно реалистичный подход, но для торговли на временных промежутках больше дня, все уже не столь правдоподобно, поскольку цена открытия может значительно отличаться от цены открытия следующего бара.

Метод update_timeindex отвечает за обработку текущей стоимости новых позиций. Он получает последние цены из обработчика рыночных данных и создает новый словарь инструментов, которые представляют текущие позиции, и приравнивая «новые» позиции к «текущим» позициям. Эта схема меняется только при получении FillEvent. После этого метод присоединяет набор текущих позиций к списку all_positions. Затем величины текущей стоимости обновляются схожим образом за исключением того, что рыночное значение вычисляется с помощью умножения числа текущих позиций на цену закрытия последнего бара (self.current_positions[s] * bars[s][0][5]). Новые полученные значения добавляются к списку all_holdings:

# portfolio.py

    def update_timeindex(self, event):
        """
        Добавляет новую запись в матрицу позиций для текущего бара рыночных данных. Отражает ПРЕДЫДУЩИЙ бар, т.е. на этой стадии известны все рыночные данные (OLHCVI). Используется MarketEvent из очередий событий.

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


Метод update_positions_from_fill определяет, каким конкретно был FillEvent (покупка или продажа), а затем обновляет словарь current_positions, добавляя или удаляя соответствующее количество акций:

# portfolio.py

    def update_positions_from_fill(self, fill):
        """
        Обрабатывает объект FillEvent и обновляет матрицу позиций так, чтобы она отражала новые позиции.
       
        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

        #Список позиций обновляется новыми значениями
        self.current_positions[fill.symbol] += fill_dir*fill.quantity


Соответствующий метод update_holdings_from_fill похож на описанный выше, но обновляет величину holdings. Для симуляции стоимости исполнения, метод не использует цену, связанную с FillEvent. Почему так просиходит? В среде бэктестинга цена исполнения на самом деле неизвестна, а значит ее нужно предположить. Таким образом, цена исполнения устанавливается как «текущая рыночная цена» (цена закрытия последнего бара). Значение текущих позиций для конкретного инструмента потом приравнивается к цене исполнения, умноженной на количество ценных бумаг в ордере.

После определения цены исполнения текущее значение holdings, доступных средств и общих величин могут обновляться. Также обновляется общая комиссия:

# portfolio.py

    def update_holdings_from_fill(self, fill):
        """
        Использует объект FillEvent и обновляет матрицу holdings для отображения изменений.

    Параметры:
        fill - Объект FillEvent, который используется для обновлений.
        """
        # 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)


Далее реализуется абстрактный метод update_fill из абстрактного базового класса Portfolio. Он просто исполняет два предыдущих метода update_positions_from_fill и update_holdings_from_fill:

# portfolio.py

    def update_fill(self, event):
        """
        Обновляет текущие позиции в портфолио и их рыночную стоимость на основе FillEvent.
     
        """
        if event.type == 'FILL':
            self.update_positions_from_fill(event)
            self.update_holdings_from_fill(event)



Объект Portfolio должен не только обратывать события FillEvent, но еще и генерировать OrderEvent при получении сигнальных событий SignalEvent. Метод generate_naive_order использует сигнал на открытие длинной или короткой позиции, целевой финансовый инструмент и затем посылает соответствующий ордер со 100 акциями нужного актива. 100 здесь — произвольное значение. В ходе реальной торговли оно было бы определено системой риск-менеджмента или модулем расчета величины позиций. Однако в NaivePortfolio можно «наивно» посылать приказы прямо после получения сигналов без всякого риск-менеджмента.

Метод обрабатывает открытие длинных и коротких позиций, а также выход из них на основе текущего количество и конкретного финансового инструмента. Затем генерируется соответствующий объект OrderEvent:

# portfolio.py

    def generate_naive_order(self, signal):
        """
        Просто передает OrderEvent как постоянное число акций на основе сигнального объекта без анализа рисков.
       
        Параметры:
        signal - Сигнальная информация 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


Метод update_signal просто вызывает описанный выше метод и добавляет сгенерированный приказ в очередь событий.

# portfolio.py

    def update_signal(self, event):
        """
        На основе SignalEvent генерирует новые приказы в соответствии с логикой портфолио.
      
        """
        if event.type == 'SIGNAL':
            order_event = self.generate_naive_order(event)
            self.events.put(order_event)


Финальный метод в NaivePortfolio — это генерации кривой капитала. Создается поток с информацией о прибыли, что полезно для расчетов производительности стратегии, затем кривая нормализуется на процентной основе. Первоначальный размер счета устанавливается раным 1.0:

# portfolio.py

    def create_equity_curve_dataframe(self):
        """
        Создает pandas DataFrame из списка словарей 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


Объект Portfolio — это наиболее сложный аспект всего событийно-ориентированного бэктестера. Несмотря на сложность, обработка позиций здесь реализована на очень простом уровне.

В следующей статье мы рассмотрим последнюю часть событийно-ориентированной системы исторического тестирования — объект ExecutionHandler, который использует объекты OrderEvent для создания из них FillEvent.

Продолжение следует…

P.S. Ранее в нашем блоге на Хабре мы уже рассматривали различные этапы разработки торговых систем. ITinvest и наши партнеры проводят онлайн-курсы по данной тематике.

© Habrahabr.ru