Событийно-ориентированный бэктестинг на Python шаг за шагом » Элитный трейдер
Элитный трейдер


Событийно-ориентированный бэктестинг на Python шаг за шагом

16 октября 2015 ITI Capital

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

Событийно-ориентированный софт

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

На каждом тике петли вызывается функция для получения последнего события, которое было сгенерировано каким-либо действием в игре. В зависимости от природы этого события (нажатие клавиши, клик мыши) предпринимается последующее действие, которое либо прерывает петлю, либо создает дополнительные события, и процесс продолжается. Проиллюстрировать все это можно таким псевдокодом:

while True:  # Петля продолжается бесконечно
    new_event = get_new_event()   # Получаем последнее событие

    # В зависимости от типа события выполняем действие
    if new_event.type == "LEFT_MOUSE_CLICK":
        open_menu()
    elif new_event.type == "ESCAPE_KEY_PRESS":
        quit_game()
    elif new_event.type == "UP_KEY_PRESS":
        move_player_north()
    # ... and many more events

    redraw_screen()   # Обновляем экран для отображения соответствующей анимации
    tick(50)   # Ждем 50 миллисекунд


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

Почему именно событийно-ориентированный бэктестер

Событийно-ориентированные системы обладают рядом преимуществ перед векторизированным подходом:

Повторное использование кода. Благодаря своей природе событийно-ориентированный модуль тестирования может быть использован как для работы с историческими данными, так и при реальной торговле на бирже при необходимости лишь минимальной «доводки» компонентов. В случае векторизированных бэктестеров нам необходимо иметь весь набор данных сразу для проведения статистического анализа.
Предугадывание искажений. Событийно-ориентированные бэктестеры воспринимают рыночные данные, в качестве «событий», на которые нужно как-то реагировать. Таким образом, можно «скормить» модулю информацию, реакция на которую будет максимально соответствовать тому, что будет наблюдаться впоследствии в реальной торговле.
Реализм. Событийно-ориентированные бэктестеры позволяют значительно кастомизировать процесс выполнения ордеров и оптимизировать транзакционные издержки. Важно уметь работать с базовыми типами ордеров (market, limit) и более сложными (market-on-open, MOO и market-on-close, MOC) — таким образом можно создаться «кастомный» обработчик исключений.

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

Обзор бэктестера

Чтобы применить событийно-ориентированный подход, прежде всего необходимо разобраться с частями нашей системы, которые будут отвечать за определенные участки работы:

Событие (event) — это фундаментальная единица класса событийно-ориентированной системы. Содержит тип (например, «MARKET», «SIGNAL», «ORDER» или «FILL»), который влияет на то, как событие будет обрабатываться в петле.
Очередь событий (event queue) — in-memory-объект Python, который хранит все объекты подклассов Event, сгенерированные остальными частями системы.
DataHandler — это абстрактный базовый класс (АБК), который представляет собой интерфейс для обработки исторических и текущих рыночных данных. Это повышает гибкость системы, поскольку модули стратегии и управления портфолио могут быть использованы как для тестирования на исторических данных так и для работы на «живом» рынке. DataHandler генерирует событие MarketEvent при каждом «ударе сердца» (heartbeat) системы.
Модуль стратегии (Strategy) — еще один АБК, который представляет собой интерфейс для забора рыночных данных и генерации на их основе сигнальных событий (SignalEvent), которые используются объектом Portfolio. SignalEvent содержит символ биржевого тикера, направление ордера (Long, Short) и временную метку.
Portfolio — также АБК, отвечающий за обработку приказов, связанных с текущей и последующими позициями, подразумевающимися стратегией (Strategy). Сюда же входит риск-менеджмент портфолио, включая контроль размеров позиций и анализ секторов рынка. В более сложных реализациях эта часть работы может быть передана классу RiskManagement. Portfolio берет SignalEvent из очереди и генерирует события ордеров (OrderEvent), которые также попадают в очередь.
ExecutionHandler — в нашем случае симулирует соединение с брокерской системой. Задача обработчика заключается в том, чтобы брать события OrderEvent из очереди, выполнять их (в режиме симуляции или через реальное подключение к брокеру). Когда ордер выполнен, обработчик создает событие FillEvent, которое описывает транзакцию, включая комиссии брокера и биржи, а также проскальзывание (если оно учитывается в модели).
Петля (Loop) — все описанные компоненты включены в петлю событий, которая обрабатывает все типы событий, направляя их к соответствующему модулю системы.

Выше мы описали базовую модель торгового дивжка, которую можно усложнять и расширять по многим направлениям, например, в области работы модуля Portfolio. Кроме того, можно вынести разные модели транзакционных издержек в отдельную иерархию классов. В нашем случае, однако, это только создаст лишние сложности, поэтому мы будем лишь постепенно привносить в систему больше реализма.

Ниже представлен кусок кода на Python, который демонстрирует практическую работу бэктестера. В коде возникают две петли. Внешняя петля используется для придания бэктестеру сердцебиения (heartbeat). В онлайн-трейдинге это означает частоту, с которой происходит запрос рыночных данных. Для стратегий тестирования на исторических данных — это не обязательный компонент, поскольку рыночные данные вливаются в систему по частям — см. строку bars.update_bars().

Внутренняя петля нужна для обработки событий из объекта Queue. Конкретные события делегируются соответствующим компонентам в очередь последовательно добавляются новые события. Когда очередь пустеем петля сердцебиения делает новый виток:

# Объявление компонентнов с соответствующими параметрами
bars = DataHandler(..)
strategy = Strategy(..)
port = Portfolio(..)
broker = ExecutionHandler(..)

while True:
    # Обновляем бары (код для бэктестинга, а не живой торговли)
    if bars.continue_backtest == True:
        bars.update_bars()
    else:
        break
    
    # Обрабатываем события
    while True:
        try:
            event = events.get(False)
        except Queue.Empty:
            break
        else:
            if event is not None:
                if event.type == 'MARKET':
                    strategy.calculate_signals(event)
                    port.update_timeindex(event)

                elif event.type == 'SIGNAL':
                    port.update_signal(event)

                elif event.type == 'ORDER':
                    broker.execute_order(event)

                elif event.type == 'FILL':
                    port.update_fill(event)

    # следующий удар сердца через 10 минут
    time.sleep(10*60)


Классы событий

В описанной схеме есть четыре типа событий:

MarketEvent — инициируется, когда внешняя петля начинает новый «удар сердца». Оно возникает, когда объект DataHandler получает новое обновление рыночных данных для любых отслеживаемых финансовых инструментов. Оно используется для того, чтобы запустить генерацию торговых сигналов объектом Strategy. Объект события содержит идентификатор того, что это рыночное событие, и никакой другой структуры.
SignalEvent — объект Strategy использует рыночную информацию для создания нового сигнального события SignalEvent. Это событие содержит символ тикера, временную метку генерации и направление ордера (long или short). Такие сигнальные события используются объектом Portfolio в качестве своеобразных подсказок на тему того, как торговать.
OrderEvent — когда объект Portfolio получает SignalEvent, он использует такие события для более широкого контекста портфолио (расчет рисков и размера позиции). Все это приводит к созданию OrderEvent, который затем посылается в ExecutionHandler.
FillEvent — когда ExecutionHandler получает OrderEvent, он обязан его выполнить. После того, как произошла транзакция, создается событие FillEvent, которое описывает стоимость покупки или продажи и траназкционные издержки (проскальзывания, комиссии и т.п.)

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

# event.py

class Event(object):
    """
    Event — это базовый класс, обеспечивающий интерфейс для последующих (наследованных) событий, которые активируют последующие    события в торговой инфраструктуре. 
    """
    pass[/quote]

MarketEvent наследует от Event и несет в себе чуточку больше, чем простая самоидентификация типа ‘MARKET’:

[quote]# event.py

class MarketEvent(Event):
    """
    Обрабатывает событие получние нового обновления рыночной информации с соответствущими барами.
    """

    def __init__(self):
        """
        Инициализирует MarketEvent.
        """
        self.type = 'MARKET'


SignalEvent требует наличия символа тикера, временной метки и направления ордера, которые объект портфолио может использовать в качестве «совета» при торговле:

# event.py

class SignalEvent(Event):
    """
    Обрабатывает событие отправки Signal из объекта Strategy. Его получает объект Portfolio, который предпринимает нужное действие.
    """
    
    def __init__(self, symbol, datetime, signal_type):
        """
        Инициализирует SignalEvent.

        Параметры:
        symbol - Символ тикера, например для Google — 'GOOG'.
        datetime - временная метка момента генерации сигнала.
        signal_type - 'LONG' или 'SHORT'.
        """
        
        self.type = 'SIGNAL'
        self.symbol = symbol
        self.datetime = datetime
        self.signal_type = signal_type


OrderEvent сложнее, чем SignalEvent, и содержит дополнительное поле для указания количества единиц финансового инструмента в ордере. Количество определяется ограничениями объекта Portfolio. Вдобавок OrderEvent содержит метод print_order(), который используется для вывода информация в консоль при необходимости:

# event.py

class OrderEvent(Event):
    """
    Обрабатывает событие отправки приказа Order в торговый движок. Приказ содержит тикер (например, GOOG), тип (market или limit), количество и направление.
    """

    def __init__(self, symbol, order_type, quantity, direction):
        """
        Инициализирует тип приказа (маркет MKT или лимит LMT), также устанавливается число единиц финансового инструмента и направление ордера (BUY или SELL).

        Параметры:
        symbol - Инструмент, сделку с которым нужно осуществить.
        order_type - 'MKT' или 'LMT' для приказов Market или Limit.
        quantity - Не-негативное целое (integer) для определения количества единиц инструмента. 
        direction - 'BUY' или 'SELL' для длинной или короткой позиции.
        """
        
        self.type = 'ORDER'
        self.symbol = symbol
        self.order_type = order_type
        self.quantity = quantity
        self.direction = direction

    def print_order(self):
        """
        Выводит значения, содержащиеся в приказе Order.
        """
        print "Order: Symbol=%s, Type=%s, Quantity=%s, Direction=%s" % \
            (self.symbol, self.order_type, self.quantity, self.direction)


FillEvent — это Event повышенной сложности. Оно содержит временную метку исполнения приказа, тикер и информациб о бирже, на которой он был исполнен, количество единиц финансового инструмента (акций, фьючерсов и т.п.), фактическую цену сделки и сопутствующие комиссии.

Сопутствующие издержки вычисляются с помощью API брокерской системы (у ITinvest есть свой API-интерфейс). В нашем примере используется система американского брокера, комиссия которого составляет минимум $1.30 с ордера с единой ставкой от $0,013 или $0,08 за акцию в зависимости от того, превышает ли количество акций 500 единиц или нет.

# event.py

class FillEvent(Event):
    """
    Инкапсулирует понятие исполненного ордера (Filled Order), возвращаемое брокером. 
    Хранит количество единиц инструмента, которые были куплены/проданы по конкретной цене. 
    Также хранит комиссии сделки.
    """

    def __init__(self, timeindex, symbol, exchange, quantity, 
                 direction, fill_cost, commission=None):
        """
        Инициализирует объек FillEvent. 
        Устанавливает тикер, биржевую площадку, количество, направление, цены и (опционально) комиссии.

        Если информация о комиссиях отсутствиет, то объект Fill вычислит их на основе объема сделки
        и информации о тарифах брокерах (полученной через API)

        Параметры:
        timeindex - Разрешение баров в момент выполнения ордера.
        symbol - Инструмент, по которому прошла сделка.
        exchange - Биржа, на которой была осуществлена сделка.
        quantity - Количество единиц инструмента в сделке.
        direction - Направление исполнения ('BUY' или 'SELL')
        fill_cost - Размер обеспечения.
        commission - Опциональная комиссия, информация отправляемая бркоером.
        """
        
        self.type = 'FILL'
        self.timeindex = timeindex
        self.symbol = symbol
        self.exchange = exchange
        self.quantity = quantity
        self.direction = direction
        self.fill_cost = fill_cost

        # Calculate commission
        if commission is None:
            self.commission = self.calculate_ib_commission()
        else:
            self.commission = commission

    def calculate_ib_commission(self):
        """
        Вычисляет издержки торговли на основе данных API брокера (в нашем случае, американского, т.е. цены в долларах). 

       Не включает комиссии биржи.
        """
        full_cost = 1.3
        if self.quantity <= 500:
            full_cost = max(1.3, 0.013 * self.quantity)
        else: # Greater than 500
            full_cost = max(1.3, 0.008 * self.quantity)
        full_cost = min(full_cost, 0.5 / 100.0 * self.quantity * self.fill_cost)
        return full_cost


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

Работа с рыночными данными

Одной из задач при создании событийно ориентированной торговой системы является минимизация необходимости писать разный код для одних и тех же задач в контексте тестирования на исторических данных и для реальной торговли. В идеале, следует использовать единую методологию генерации сигналов и управления портфолио для каждого из этих случаев. Чтобы этого добиться, объект Strategy, который генерирует торговые сигналы (Signals), и объект Portfolio, который на их основе генерирует ордера (Orders), должны использовать один интерфейс доступа к рыночным данным как в контексте исторического тестирования, так и работы в реальном времени.

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

Среди таких подклассов могут быть HistoricCSVDataHandler, QuandlDataHandler, SecuritiesMasterDataHandler, InteractiveBrokersMarketFeedDataHandler и так далее. Здесь мы рассмотрим только создание обработчика CSV с историческими данными, который будет загружать соответствующий CSV-файл финансовых данных внутри дня в формате баров (значения цены Low, High, Close, а также объем торгов Volume и открытый интерес OpenInterest). На основе этих данных при каждом «ударе сердца» системы (heartbeat) можно уже проводить углубленный анализ компонентами Strategy и Portfolio, что позволит избежать различных искажений.

На первом шаге нужно импортировать требуемые библиотеки, в частности pandas и abstract base class. Поскольку DataHandler генерирует события MarketEvents, нужно также импортировать и event.py:

# data.py

import datetime
import os, os.path
import pandas as pd

from abc import ABCMeta, abstractmethod

from event import MarketEvent


DataHandler — это абстрактный базовый класс (АБК), что означает невозможность создания экземпляра напрямую. Это можно сделать только с помощью подклассов. Обоснование этого заключается в том, что АБК, предоставляет интерфейс для подлежащих подклассов DataHandler, который они должны использовать, что позволяет добиться совместимости с другими классами, с которыми может осуществляться взаимодействие.

Чтобы Python «понял», что имеет дело с абстрактным базовым классом, мы будем использовать свойство _metaclass_. Также с помощью декоратора @abstractmethod указывается, что метод будет переопределен в подклассах (в точности аналогично полностью виртуальному методу в C++).

Два интересующих нас метода — это get_latest_bars и update_bars. Первый из них возвращает последние N баров из текущей временной метки «удара сердца» системы, что полезно для осуществления вычислений для классов Strategy. Последний метод предоставляет механизм анализа для наложения информацию бара на новую структуру данных, что полностью позволяет избавиться от прогнозных искажений. Если произойдет попытка созданий экземпляра класса, возникнет исключение:

# data.py

class DataHandler(object):
    """
    DataHandler — абстрактный базовый класс, предоставляющий интерфейс для всех наследованных обработчиков (для живой торговли и работы с историческими данными)

Цель (выделенного) объекта DataHandler заключается в выводе сгенерированного набора баров (OLHCVI) для каждого запрощенного финансового инструмента.

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

    __metaclass__ = ABCMeta

    @abstractmethod
    def get_latest_bars(self, symbol, N=1):
        """
        Возвращает последние N баров из списка    latest_symbol или меньше, если столько баров еще недоступно.
        """
        raise NotImplementedError("Should implement get_latest_bars()")

    @abstractmethod
    def update_bars(self):
        """
        Накладывает последний бар на последнюю структуру инструмента для всех инструментов в списке.
        """
        raise NotImplementedError("Should implement update_bars()")


После описания класса DataHandler следующим шагом является создание обработчика для исторических CSV-файлов. HistoricCSVDataHandler будет брать множество CSV-файлов (по одному для каждого финансового инструмента) и конвертировать их в словарь фреймов DataFrames для pandas.

Обработчику нужно несколько параметров — очередь событий (Event Queue), в которую публиковать рыночную информацию MarketEvent, абсолютный путь к CSV-файлам и список инструментов. Вот так выглядит инициализация класса:

# data.py

class HistoricCSVDataHandler(DataHandler):
    """
    HistoricCSVDataHandler создан для чтения CSV-файло с диска и создания интерфейса для получения «последнего» бара, как при реальной торговле.

    """

    def __init__(self, events, csv_dir, symbol_list):
        """
        Инициализирует обработчик исторических данных запросом местоположения CSV-файлов и списка инструментов.

Предполагается, что все файлы имеют форму  'symbol.csv', где symbol — это строка списка.


        Параметры:
        events - очередь событий.
        csv_dir - Абсолютный путь к директории с CSV-файлами.
        symbol_list - Список строк инструментов.
        """
        self.events = events
        self.csv_dir = csv_dir
        self.symbol_list = symbol_list

        self.symbol_data = {}
        self.latest_symbol_data = {}
        self.continue_backtest = True       

        self._open_convert_csv_files()


Он будет пытаться открыть файлы в формате “SYMBOL.csv”, в которым SYMBOL — это тикер инструмента. Использованный здесь формат совпадает с предлагаемым поставщиком данных DTN IQFeed, но его легко можно модифицировать для работы с другими форматами. Открытие файлов обрабатывается методом _open_convert_csv_files.

Одно из преимуществ использования пакета pandas для хранения данных внутри HistoricCSVDataHandler заключается в том, что индексы всех отслеживаемых инструментов можно слить воедино. Это позволяет интерполировать даже отсутствующие данные, что полезно для побарового сравнения инструментов (бывает нужно в стратегиях mean reversion). При комбинировании индексов для инструментов используются методы union и reindex:

# data.py

    def _open_convert_csv_files(self):
        """
       Открывает CSV-файлы из директории, конвертирует их в pandas DataFrames внутри словаря инструментов.

Для данного обработчика предположим, что данные берутся из фида DTN IQFeed, и работа идет с этим форматом.
        """
        comb_index = None
        for s in self.symbol_list:
            # Загрузка CSV-файла без заголовочной информации, индексированный по дате

            self.symbol_data[s] = pd.io.parsers.read_csv(
                                      os.path.join(self.csv_dir, '%s.csv' % s),
                                      header=0, index_col=0, 
                                      names=['datetime','open','low','high','close','volume','oi']
                                  )

            # Комбинируется индекс для «подкладывания» значений
            if comb_index is None:
                comb_index = self.symbol_data[s].index
            else:
                comb_index.union(self.symbol_data[s].index)

            # Set the latest symbol_data to None
            self.latest_symbol_data[s] = []

        # Reindex the dataframes
        for s in self.symbol_list:
            self.symbol_data[s] = self.symbol_data[s].reindex(index=comb_index, method='pad').iterrows()


Метод _get_new_bar создает генератор для создания форматированной версии данных в барах. Это означается, что последующие вызовы метода результируются в новом баре (и так до того момента, пока не будет достигнут конец строки данных по инструментам):

# data.py

    def _get_new_bar(self, symbol):
        """
        Возвращает последний бар из дата-фида в формате: 
        (sybmbol, datetime, open, low, high, close, volume).
        """
        for b in self.symbol_data[symbol]:
            yield tuple([symbol, datetime.datetime.strptime(b[0], '%Y-%m-%d %H:%M:%S'), 
                        b[1][0], b[1][1], b[1][2], b[1][3], b[1][4]])


Первый абстрактный метод из DataHаndler, который нужно реализовать — это get_latest_bars. Он просто выводит список последних N баров из структуры latest_symbol_data. Установка N = 1 позволяет получать текущий бар:

# data.py

def get_latest_bars(self, symbol, N=1):
"""
Возвращает N последних баров из списка latest_symbol, или N-k, если доступно меньше.

"""
try:
bars_list = self.latest_symbol_data[symbol]
except KeyError:
print "That symbol is not available in the historical data set."
else:
return bars_list[-N:]


Последний метод — update_bars, это второй абстрактный метод из DataHandler. Он генерирует события (MarketEvent), которые попадают в очередь, как последние бары добавляются в latest_symbol_data:

# data.py

def update_bars(self):
"""
Отправляет последний бар в структуру данных инструментов для всех инструментов в списке.
"""
for s in self.symbol_list:
try:
bar = self._get_new_bar(s).next()
except StopIteration:
self.continue_backtest = False
else:
if bar is not None:
self.latest_symbol_data[s].append(bar)
self.events.put(MarketEvent())


Таким образом, у нас есть DataHandler — выделенный объект, который используется остальными компонентами системы для отслеживания рыночных данных. Для работы объектам Stragety, Portfolio и ExecutionHandler требуется текущая рыночная информация, поэтому имеет смысл работать с ней централизованно, чтобы избежать возможного дублировани хранения.

От информации до торгового сигнала: стратегия

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

Иерархия стратегии относительно проста — она состоит из абстрактного базового класса с единственным виртуальным методом для создания объектов SignalEvents. Для создания иерархии стратегии необходимо импортировать NumPy, pandas, объект Queue, инструмент abstract base tools и SignalEvent:

# strategy.py

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

from abc import ABCMeta, abstractmethod

from event import SignalEvent


Абстрактный базовый класс Strategy определяет виртуальный метод calculate_signals. Он используется для обработки создания объектов SignalEvent на основе обновлений рыночных данных:

# strategy.py

class Strategy(object):
    """
    Strategy — абстрактный базовый класс, предоставляющий интерфейс для подлежащих (наследованных) объектов для обработки стратегии.


    Цель выделенного объекта Strategy заключается в генерировании сигнальных объектов для конкретных инструментов на основе входящих баров (OLHCVI), сгенерированных объектом DataHandler.

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

    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def calculate_signals(self):
        """
        Предоставляет механизмы для вычисления списка сигналов.

        """
        raise NotImplementedError("Should implement calculate_signals()")


Определение абстрактного базового класса Strategy довольно проста. Первый пример использования подклассов в объекте Strategy заключается в использовании стратегий buy и hold и создании соответствующего класса BuyAndHoldStrategy. Он будет покупать конкретную акцию в определенный день и удерживает позицию. Таким образом на одну акцию генерируется только один сигнал.

Конструктор (__init__) требует наличия обработчика рыночных данных bars и объекта очереди событий events:

# strategy.py

class BuyAndHoldStrategy(Strategy):
    """
    Крайне простая стратегия, которая входит в длинную позициию при полуении бара и никогда из нее не выходит.

    Используется в качестве механизма тестирования класса Strategy и бенчмарка для сравнения разных стратегий.
    """

    def __init__(self, bars, events):
        """
        Инициализирует стратегию buy and hold.

        Параметры:
        bars - Объект DataHandler, который предоставляет информацию о барах
        events - Объект очереди событий.
        """
        self.bars = bars
        self.symbol_list = self.bars.symbol_list
        self.events = events

        # Когда получен сигнал на покупку и удержание акции, устанавливается в True 
        self.bought = self._calculate_initial_bought()


При инициализации стратегии BuyAndHoldStrategy в словаре bought содержится набор ключей для каждого инструмента, которые установлены в False. Когда определенный инструмент покупается (открывается длинная позиция), то ключ переводится в положение True. Это позволяет объекту Strategy понимать, открыта ли позиция:

# strategy.py

    def _calculate_initial_bought(self):
        """
        Добавляются ключи в словарь bought и устанавливаются в False.
        """
        bought = {}
        for s in self.symbol_list:
            bought[s] = False
        return bought


Виртуальный метод calculate_signals имплементирован именно в этом классе. Метод проходит по всем инструментам в списке и получает последний бар из обработчика bars. Затем он проверяет, был ли инструмент «куплен» (находимся ли мы в рынке по нему, или нет), а затем создается сигнальный объект SignalEvent. Затем он помещается в очередь событий, а словарь bought обновляется соответствующей информацией (True для купленного инструмента):

# strategy.py

    def calculate_signals(self, event):
        """
       Для "Buy and Hold" генерируем один сигнал на инструмент. Это значит, что мы только открываем длинные позиции с момента инициализации стратегии.

        Параметры:
        event - Объект MarketEvent. 
        """
        if event.type == 'MARKET':
            for s in self.symbol_list:
                bars = self.bars.get_latest_bars(s, N=1)
                if bars is not None and bars != []:
                    if self.bought[s] == False:
                        # (Symbol, Datetime, Type = LONG, SHORT or EXIT)
                        signal = SignalEvent(bars[0][0], bars[0][1], 'LONG')
                        self.events.put(signal)
                        self.bought[s] = True


Это очень простая стратегия, но ее достаточно для того, чтобы продемонстрировать природу иерархии событийно ориентированной стратегии. В следующей статьей мы рассмотрим более сложные стратегии, например, парную торговлю. Также в следующей статье речь пойдет о создании иерархии Portfolio, которая будет отслеживать прибыль и убыток по позициям (profit and loss, PnL).

Сегодня мы опишем объект 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.

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

Иерархия классов для обработки приказов

Компонент ExecutionHandler, который будет описан в этой части статьи, избыточно прост, поскольку он только исполняет приказы по текущей рыночной цене. Это абсолютно нереалистичный сценарий, но он служит неплохой первоначальной точкой для последующих улучшений и усложнения.

Как и с использованными ранее иерархиями абстрактных базовых классов, необходимо импортировать нужные сущности и декораторы из библиотеки abc. Также необходимо импортировать FillEvent и OrderEvent: # execution.py import datetime import Queue from abc import ABCMeta, abstractmethod from event import FillEvent, OrderEvent ExecutionHandler похож на использованные ранее абстрактные базовые классы и содержит один полностью виртуальный метод execute_order:

# execution.py

class ExecutionHandler(object):
    """
    Абстрактный класс ExecutionHandler обрабатывает взаимодействие между набором объектов приказов, сгенерированных Portfolio и полным набором объектов Fill, которые возникают на рынке.

    Обработчики могут быть использованы с симулированными брокерскими системами или интерфейсами реальных брокерских систем. Это позволяет тестировать стратегию аналогично работе над движком для реальной торговли. 
    """

    __metaclass__ = ABCMeta

    @abstractmethod
    def execute_order(self, event):
        """
        Берет событие Order и выполняет его, получая событие Fill, которе помещается в очередь Events. 

        Параметры:
        event - Содержит объект Event с информацией о приказе
        """
        raise NotImplementedError("Should implement execute_order()")


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

Учтем, что событие FillEvent содержит значение fill_cost равное None (см. предпоследнюю строку в execute_order), поскольку мы уже позаботились о цене исполнения в объекте NaivePortfolio (он описан в прошлой статье). В более реалистичной реализации мы бы использовали значение рыночных данных “value”, чтобы получить реальную стоимость сделки.

В нашем примере для тестирования используется биржа ARCA. Для реальной торговли конечная биржа будет оказывать важное влияние.

# execution.py

class SimulatedExecutionHandler(ExecutionHandler):
    """
    Симулированный обработчик конвертирует все объекты приказов в их эквивалентные объекты Fill, автоматически и без задержки, проскальзывания и т.п. Это позволяет быстро протестировать стратегию в первом приближении перед разработкой более сложных реализаций обработчиков.

    """
    
    def __init__(self, events):
        """
        Инициализирует обработчик, устанавливает внутренние очереди событий. 

        Параметры:
        events - Очередь событий Event.
        """
        self.events = events

    def execute_order(self, event):
        """
        Просто наивно конвертирует объекты Order в объекты Fill, то есть не учитывается задержка, проскальзывание или количество акций в приказе, которые можно купить/продать по заданной цене.

        Параметры:
        event - Содержит объект Event с информацией о приказе.
        """
        if event.type == 'ORDER':
            fill_event = FillEvent(datetime.datetime.utcnow(), event.symbol,
                                   'ARCA', event.quantity, event.direction, None)
            self.events.put(fill_event)


На этом мы закончили разработку иерархий классов для нашего бэктестера. Теперь поговорим о том, как высчитывать метрики производительности для тестируемой стратегии.

Метрики производительности

В одной из статей Майк Халлс-Мур останавливался на понятии коэффициента Шарпа. Подсчитать его можно по следующей формуле:

Событийно-ориентированный бэктестинг на Python шаг за шагом


Где Ra это поток возврата кривой капитала, а Rb — это бенчмарк, например показатель интереса или индекс.

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

В нашем бэктестере будут использоваться коэффициент Шарпа и показатели максимальной просадки и ее длительности.

Реализация на Python

Для начала нужно создать файл performance.py, который хранит функции для подсчета коэффициента Шарпа и информацию о просадке. Как и в случае других наших классов, требующих большого объёма вычислений, нужно импортировать NumPy и Pandas:

# performance.py

import numpy as np
import pandas as pd


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

Обычно это число устанавливается на уровне 252 — количество торговых дней в США. Однако, если тестируемая стратегия предполагает торговлю на часовых интервалах, то нужно соответственно изменить коэффициент Шарпа, чтобы получить корректное значение для года. В данном случае нужно установить период так: 252 ∗ 6.5 = 1638 (количество торговых часов в США за год). Если торговля идет на минутном интервале, то нужно еще умножить все на 60: 252∗6.5∗60 = 98280.

Функция create_sharpe_ratio оперирует объектом библиотеки Pandas Series под названием returns и просто подсчитывает отношение среднего значения процента прибыли и стандартного отклонения прибыли в процентах с учетом разного числа торговых периодов.

# performance.py

def create_sharpe_ratio(returns, periods=252):
    """
    Создает коэффициент Шарпа для стратегии, основанной на бенчмарке ноль (нет информации о рисках ).

    Параметры:
    returns -  Series из Pandas представляет процент прибыли за период - Дневной (252), Часовой (252*6.5), Минутный (252*6.5*60) и т.п..
    """
    return np.sqrt(periods) * (np.mean(returns)) / np.std(returns)


Коэффициент Шарпа описывает, насколько большой риск (определенный стандартным отклонение цены активов) берется при работе с единицей инвестирования, а просадка определяется как наибольшее снижение объёма средства с максимума до минимума.

Функция create_drawdowns, представленная ниже, представляет оба показателя — максимальная просадка и максимальная длительность просадки. В определении длительности просадки есть тонкость — при ее определении нельзя оперировать общими понятиями типа «день», учитываются только торговые периоды.

Функция начинает работу с создания двух объектов pandas Series, представляющих просадку и длительность на каждом торговом баре. Затем устанавливается текущий показатель HWM (high water mark) — он означает, что объём капитала превышает предыдущие максимумы.

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

# performance.py

def create_drawdowns(equity_curve):
    """
    Вычисляет крупнейшее падение от пика до минимума кривой PnL и его длительность. Требует возврата  pnl_returns в качестве pandas Series.
 
    Параметры:
    pnl - pandas Series, представляющая процент прибыли за период. 

    Прибыль:
    drawdown, duration - Наибольшая просадка и ее длительность 
    """

    # Подсчет общей прибыли 
    # и установка High Water Mark
    # Затем создаются серии для просадки и длительности
    hwm = [0]
    eq_idx = equity_curve.index
    drawdown = pd.Series(index = eq_idx)
    duration = pd.Series(index = eq_idx)

    # Цикл проходит по диапазону значений индекса
    for t in range(1, len(eq_idx)):
        cur_hwm = max(hwm[t-1], equity_curve[t])
        hwm.append(cur_hwm)
        drawdown[t]= hwm[t] - equity_curve[t]
        duration[t]= 0 if drawdown[t] == 0 else duration[t-1] + 1
    return drawdown.max(), duration.max()


Для того, чтобы применить эти метрики производительности, нужны средства их подсчета после тестирования на исторических данных. Также необходимо связать вычисления с определенным объектом в иерархии. Учитывая, что метрики производительности вычисляются на базе портфолио, имеет смысл применить вычисления для метода в иерархии классов Portfolio, которую мы обсуждали в прошлых статьях.

Прежде всего нужно открыть portfolio.py и импортировать функции производительности:

# portfolio.py

..  # импорт других функций

from performance import create_sharpe_ratio, create_drawdowns


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

Метод довольно прост. Он использует две метрики производительности и применяет их напрямую к кривой капитала в датафрейме pandas, а затем выводит статистику в качестве отформатированного списка:

# portfolio.py

..
..

class NaivePortfolio(object):

    ..
    ..

    def output_summary_stats(self):
        """
        Создает список статистических показателей для портфолио — коэффициент Шарпа и данные по просадке. 
        """
        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)
        max_dd, dd_duration = create_drawdowns(pnl)

        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)]
        return stats


Данный анализ производительности является сильно упрощенным. Он не учитывает аналитику на уровне сделок или другие измерения соотношения риска и прибыли. Однако его довольно просто расширить и добавить дополнительные методы в performance.py с последующим внедрением их в output_summary_stats.

https://iticapital.ru/ (C) Источник
Не является индивидуальной инвестиционной рекомендацией
При копировании ссылка обязательна Нашли ошибку: выделить и нажать Ctrl+Enter