How-to: Объектно-ориентированная система бэктестинга на Python
Известный британский трейдер и разработчик Майк Халлс-Мур написал в своем блоге статью о том, как создать объектно-ориентированную систему бэктестинга финансовых стратегий торговли на бирже. Мы представляем вашему вниманию главные мысли этого материала.
Что такое бэктестинг
Под бэктестингом понимают процесс применения конкретной торговой стратегии к историческим дата, чтобы оценить ее возможную производительность в прошлом. Это, однако, не дает никакой гарантии того, что система будет успешна в будущем. Тем не менее существуют способы «отфильтровать» стратегии, которые совершенно точно не достойны того, чтобы тратить на них время и деньги в ходе реальной торговли.
Создать надежную систему бэктестинга нелегко, поскольку она должна уметь успешно моделировать поведение различных компонентов, влияющих на производительность алгоритмической торговой стратегии. Недостаточность данных, проблемы на каналах связи между клиентом и брокером, задержки исполнения заявки — это лишь несколько факторов, которые могут повлиять на то, будет сделка успешной или нет.
Не все эти факторы известны заранее, поэтому по мере того, как трейдер выясняет, что еще влияет на процесс торговли на бирже, система бэктестинга обычно дописывается, чтобы отражать эти новые знания. В данном материале мы рассмотрим пример создания такой простой системы с пмощью Python.
Типы систем бэктестинга
Существуют два главных типа систем бэктестинга. Один из них называется «исследовательский» (research-based) и используется по большей части на ранних стадиях оценки стратегий, когда необходимо выбрать наиболее перспективные для дальнейшей работы. Такие системы часто пишут на Python, R или Matlab, поскольку в данном случае скорость разработки важнее скорости работы.
Следующий тип — событийно-ориентированные бэктестеры (event-based). В их случае процесс бэктестинга проходит по сценарию максимально приближенному (если не идентичному) к реальной торговле. Система реалистично моделирует рынчоные данные и процесс выполнения приказов, что дает возможность более глубокой оценки анализируемой стратегии.
Часто такие системы пишут на C++, потому что скорость их работы уже игрет важную роль. Хотя для тестирования стратегий, которые не предполагают крайне высокой скорости работы все еще можно использовать Python (здесь мы рассматривали создание событийно-ориентированного бэктестера на этом языке).
Объектно-ориентированный бэктестер на Python
Объектно-ориентированный подход к разработке бэктестера имеет свои плюсы:
- Интерфейсы каждого компонента системы можно спроектировать заранее, но внутреннее их содержание может быть модифицировано или заменено позднее в ходе проекта.
- Он позволяет эффективно проводить юнит-тестирование.
- С помощью наследования и композиции можно конструировать новые компоненты, расширяющие систему.
В нашем примере мы создадим простой бэктестер, который сможет работать со стратегией, которая использует всего один финансовый инструмент (например, акцию). Для такой системы потребуются следующие компоненты:
- Strategy — класс Strategy с определенной частотой получает дата-фрейм Pandas, содержащий бары, то есть список точек данных о цене открытия, максимуму, минимуме, цене закрытия и объеме торгов за торговый период (Open-High-Low-Close-Volume, OHLCV). Затем Strategy создает список сигналов, которые состоят из временной метке и элемента из набора {1,0, -1}, обозначающие длинную позицию, удержание позиции или короткую продажу.
- Portfolio — большая часть непосредственно бэктестинга будет осуществляться классом Portfolio. Он будет получать набор сигналов и создавать из них ряды позиций, сопоставляя из показателем доступных средств. Задача объекта Portfolio заключается в создании кривой капитала, анализ базовых издержек транзакций и отслеживание сделок.
- Performance — этот объект использует портфолио для получения статистики о его эффективности. В частности, он рассчитывает характеристики риска и возврата капитала, прибыльность или убыточность сделок, а также информацию о просадке объёма доступных средств.
Как нетрудно заметить, в этом случае мы исключаем объекты, связанные с риск-менеджментов, обработкой заявок (то есть система не умеет работать с лимитными приказами) или сложным моделированием издержек транзакций. Задача здесь в том, чтобы создать базовый бэктестер, который можно улучшить впоследствии.
Реализация
Теперь рассмотрим реализацию каждого используемого объекта: Strategy
Объекту Strategy предстоит обрабатывать стратегии прогноирования цен, возврата к среднему (mean-reversion), моментума и волатильности. Стратегии, которые рассматриваются в данном примере, всегда основаны на временных рядах, то есть «движутся ценой» (price driven). В частности это подразумевает, что объект будет получать на вход не тики информации о торгах, а набор показателей OHLCV. Таким образом максимальная возможная детализация здесь — это 1-секундные бары.
Кроме того класс Strategy будет генерировать сигнальные рекомендации. Это значит, что он будет советовать объекту Portfolio, какое действие лучше предпинять. Класс Portfolio уже затем будет анализировать данные наряду с этими рекомендациями, для того, чтобы сгенерировать набор сигналов для входа или выхода из позиции.
Интерфейс классов будет реализован с помощью методологии абстрактных базовых классов. Python-код будет находиться в файле backtest.py. Класс Strategy требует, чтобы любой реализованный подкласс использовал метод generate_signals.
Для того, чтобы для Strategy не создавался экземпляр (он же асбтрактный) необходимо использовать объекты ABCMeta и abstractmethod из модуля abc. Установим свойство класса _metaclass_
равным ABCMeta и задекорируем метод generate_signals
с помощью декоратора abstractmethod
.
# backtest.py
from abc import ABCMeta, abstractmethod
class Strategy(object):
"""Strategy — это абстрактный базовый класс, предоставляющий интерфейсы для наследованных торговых стратегий
Цель наличия отедльно объекта Strategy заключается в выводе списка сигналов, которые формируют временной ряд индексированных дата-фреймов pandas.
В данной реализации поддерживается лишь работа с одним финансовым инструментов."""
__metaclass__ = ABCMeta
@abstractmethod
def generate_signals(self):
"""Необходимо вернуть датафрейм с символами, содержащий сигналы для открытия длинной или короткой позиции, или удержания таковой (1, -1 or 0)."""
raise NotImplementedError("Should implement generate_signals()!")
Portfolio
В классе Portfolio содержится большая часть торговой логики. Для данного бэктестера этот объект будет отвечать за определения размера позиции, анализ рисков и транзакционных издержек. В ходе дальнейшей разработки эти задачи необходимо разнести по отдельным компонентам, но сейчас их можно совместить в одном классе.
Для реализации этого класса мы будем использовать pandas — эта библиотека может сэкономить здесь огромное количество времени. Единственный момент — необходимо избегать итерации набора данных с помощью синтаксиса for d in …
. Дело в том, что NumPy оптимизирует петли с помощью векторизированных операций. Поэтому при использовании pandas прямых итераций почти не встретить.
Задача класса Portfolio заключается в том, чтобы в конечном итоге сгенерировать последовательность сделок и кривую капитала, которые затем будут анализироваться классом Performance. Для того, чтобы это сделать, класс должен получить ряд рекомендаций от объекта Strategy (в более сложных случаях, таких объектов может быть много).
Классу Portfolio нужно сказать, как применить капитал к конкретному набору торговых сигналов, как учесть транзакционные издержки и какие типы биржевых приказов следует использовать. Объект Strategy работает с барами данных, так что предположения должны делаться на основе цены, которая существует в момент исполнения приказа. Поскольку максимум и минимум цены любого текущего бара априори неизвестен, возможно лишь использование цены открытия и закрытия (предыдущего бара). В реальнсти, однако при использовании рыночных приказов (market) невозможно гарантировать исполнение приказа по конкретной цене, поэтому цена здесь будет не более чем предположением.
В данном случае также бэктестер будет игнорировать все, что связано с понятием гарантийного обеспечения и ограничений со стороны брокера, предполагая, что можно открывать длинные и короткие позиции по любому финансовому инструменту без каких-либо ограничений ликвидности. Безусловно, это нереалистичное предположение, поэтому в ходе развития проекта оно должно быть снято.
Продолжим изучать код:
# backtest.py
class Portfolio(object):
"""Абстрактный базовый класс представляет портфолио позиций (инструменты и доступные средства), определенное на основе набора сигналов от объекта Strategy."""
__metaclass__ = ABCMeta
@abstractmethod
def generate_positions(self):
"""Обеспечивает логику для опеределения того, как распределяются позиции в портфолио на основе доступных средств и выданных сигналов. """
raise NotImplementedError("Should implement generate_positions()!")
@abstractmethod
def backtest_portfolio(self):
"""Обеспечивается логика генерирования торговых сигналов и построения на освное датафрейма с позициями кривой капитала (то есть роста активов) — суммы позиций и доступных средств, доходов/убытков во временной период бара..
Produces a portfolio object that can be examined by
other classes/functions."""
raise NotImplementedError("Should implement backtest_portfolio()!")
Это базовые описания абстрактных базовых классов Strategy и Portfolio. Теперь пришло время создать выделенные реализации этих классов для того, чтобы система могла эффективнее обработать тестовую стратегию.
Начнем с создания подкласса Strategy под названием RandomForecastStrategy
— его единственная задача заключается в генерировании случайных сигналов на покупку или короткую продажу акций. На первый взгляд в этом нет никакого смысла, однако такая простейшая стратегия позволит проиллюстрировать работу объектно-ориентированного фреймворка бэктестинга.
Создаем новый файл random_forecast.py
с кодом модуля со случайными рекомендациями:
# random_forecast.py
import numpy as np
import pandas as pd
import Quandl # Necessary for obtaining financial data easily
from backtest import Strategy, Portfolio
class RandomForecastingStrategy(Strategy):
"""Выделен из Strategy для генерирования набора случайных сигналов для открытия позиций long или short. Единственный смысл этого в демонстрации работы бэктестера"""
def __init__(self, symbol, bars):
"""Requires the symbol ticker and the pandas DataFrame of bars"""
self.symbol = symbol
self.bars = bars
def generate_signals(self):
"""Создает датафрейм pandas DataFrame, содержащий набор случайных сигналов."""
signals = pd.DataFrame(index=self.bars.index)
signals['signal'] = np.sign(np.random.randn(len(signals)))
# Первые пять элементов устанавливают в ноль для минимизации NaN-ошибок:
signals['signal'][0:5] = 0.0
return signals
Теперь, получив тестовую систему для создания рекомендаций, необходимо создать реализацию объекта Portfolio. Этот объект включит в себя большую часть кода бэктестинга. Он будет создавать два отдельных датафрейма — в первом будут содержаться позиции (positions), он будет использоваться для хранение количество купленных или проданных инструментов за время бара. Следующий — portfolio содержит рыночные цены всех позиций для каждого бара, также как объём доступных средств. Это позволяет построить кривую капитала для оценки производительности стратегии.
Реализация объекта Portfolio требует принятия решений о том, как обрабатывать транзакционные издержки, рыночные приказы и т.д. В данном примере предполагается, что возможно открывать длинные и короткие позиции без ограничений гарантийного обеспечения, покупать и продавать четко по цене открытия бара, транзакционные издержки равны нулю (отбрасывается проскальзывание цены, комиссии брокера и биржи и т.п.), а количество акций для покупку или продажи указывается напрямую для каждой сделки.
Далее идет продолжение файла random_forecast.py
:
# random_forecast.py
class MarketOnOpenPortfolio(Portfolio):
"""Наследует Portfolio для создания системы, которая покупает 100 единиц конкретной акции, следуя сигналу по рыночной цене открытия бара.
Кроме того, транзакционные издержки равны нулю, а средства для короткой продажи можно привлечь моментально без ограничений.
Требования:
symbol - Акция, формирующая основу портфолио.
bars - Датафрейм баров для набора акций.
signals - Датафрейм pandas сигналов (1, 0, -1) для каждой акции.
initial_capital - Объём средств на старте торговли."""
def __init__(self, symbol, bars, signals, initial_capital=100000.0):
self.symbol = symbol
self.bars = bars
self.signals = signals
self.initial_capital = float(initial_capital)
self.positions = self.generate_positions()
def generate_positions(self):
"""Создается датафрейм 'positions' в котором содержится информация о длинных или коротких сделках со 100 акциями, основанные на рекомендациях
{1, 0, -1} из датафрейма с сигналами."""
positions = pd.DataFrame(index=signals.index).fillna(0.0)
positions[self.symbol] = 100*signals['signal']
return positions
def backtest_portfolio(self):
"""На основе датафрейма с позициями конструироуется портфолио — учитывается возможность ведения торговли по рыночным ценам открытия каждого бара (нереалистичное предположение). Вычисляется общий объём доступных средств и отвлеченных на позиции — эти данные используются для построения кривой капитала. Возвращается объект portfolio, который может быть использован далее."""
# В объекте 'pos_diff' конструируется датафрейм portfolio для использования того же индекса, что и у датафрейма с позициями, наряду с набором торговых приказов
portfolio = self.positions*self.bars['Open']
pos_diff = self.positions.diff()
# С помощью прохода по сделки и добавления в них данных создаются ряды 'holdings' и 'cash'
portfolio['holdings'] = (self.positions*self.bars['Open']).sum(axis=1)
portfolio['cash'] = self.initial_capital - (pos_diff*self.bars['Open']).sum(axis=1).cumsum()
# На основе информации о доступных средствах ('cash') и отвлеченных на сделки вычисляется общая и побаровая прибыль
portfolio['total'] = portfolio['cash'] + portfolio['holdings']
portfolio['returns'] = portfolio['total'].pct_change()
return portfolio
Теперь нужно связать все воедино с помощью функции _main_
:
if __name__ == "__main__":
# Получить дневные бары SPY (ETF, который обычно следует за индексом S&P500) из Quandl (необходимо выполнить в командной строке 'pip install Quandl'
symbol = 'SPY'
bars = Quandl.get("GOOG/NYSE_%s" % symbol, collapse="daily")
# Создается набор случайных сигналов на покупку или продажу для SPY
rfs = RandomForecastingStrategy(symbol, bars)
signals = rfs.generate_signals()
# Создается портфолио SPY
portfolio = MarketOnOpenPortfolio(symbol, bars, signals, initial_capital=100000.0)
returns = portfolio.backtest_portfolio()
print returns.tail(10)
Вывод программы представлен ниже (в каждом конкретном случае он будет отличаться в виду разных выбранных диапазонов дат и использования рандомизации):
В данном случае видно, что стратегия приводит к убыткам, что и неудивительно, принимая во внимание ее особенности. Для того, чтобы превратить этот тестовый пример в более работоспособный бэктестер, необходимо также создаать объект Performance, которые будет принимать ввод от Portfolio и выдавать набор метрик производительность, на основе которых должно приниматься решение о фильтрации стратегии.
Кроме того, можно улучшить объект Portfolio так, чтобы он более реалистично учитывал информацию о транзакционных издержках (например, проскальзывание или комиссия брокера). Также можно включить «предсказательный движок» напрямую в объект Strategy, что также должно позволить добитья лучших результатов.
На сегодня все! Спасибо за внимание, и не забывайте подписываться на наш блог.
Другие статьи о создании торговых роботов: