[Перевод] Событийно-ориентированный бэктестинг на Python шаг за шагом. Часть 2
В предыдущей статье мы поговорили о том, что такое событийно-ориентированная система бэктестинга и разобрали иерархию классов, которую необходимо для нее разработать. Сегодня речь пойдет о том, как подобные системы используют рыночные данные как в контексте исторического тестирования, так и для «живой» работы на бирже.
Работа с рыночными данными
Одной из задач при создании событийно ориентированной торговой системы является минимизация необходимости писать разный код для одних и тех же задач в контексте тестирования на исторических данных и для реальной торговли. В идеале, следует использовать единую методологию генерации сигналов и управления портфолио для каждого из этих случаев. Чтобы этого добиться, объект 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).
Продолжение следует…
P.S. Ранее в нашем блоге на Хабре мы уже рассматривали различные этапы разработки торговых систем. Есть и онлайн-курсы по данной тематике.