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 ticker symbol 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['повернення'] = 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, які буде приймати enter від Portfolio і видавати набір метрик продуктивність, на основі яких має прийматися рішення про фільтрації стратегії.

Крім того, можна поліпшити об'єкт Portfolio так, щоб він більш реалістично враховував інформацію про транзакційних витратах (наприклад, проскакування або комісія брокера). Також можна включити «предсказательная движок» безпосередньо в об'єкт Strategy, що також повинно дозволити добитья кращих результатів.

На сьогодні все! Спасибі за увагу, і не забувайте підписуватися на наш блог.

Інші статті про створення торгових роботів:



Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.