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