четверг, 28 мая 2020 г.

Введение в тестирование торговых стратегий на исторических данных в Python


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

Что такое тестирование на истории?

Давайте начнем с торговой стратегии. Ее можно определить как метод (основанный на заранее определенных правилах) покупки и/или продажи активов на рынках. Эти правила могут основываться, например, на техническом анализе или моделях машинного обучения.


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

Есть несколько доступных фреймворков для тестирования в Python, в этой статье я решил использовать zipline.

Почему zipline?

Некоторые из приятных функций, предлагаемых средой zipline:
  • Простота использования - существует четкая структура того, как создать тест и какого результата мы можем ожидать, поэтому большую часть времени можно потратить на разработку современных торговых стратегий :).
  • Реалистичность - включает в себя транзакционные издержки, проскальзывание, задержки ордеров и т. д.
  • Основана на потоке - обрабатывает каждое событие индивидуально, таким образом избегая смещения вперед.
  • Поставляется со многими легкодоступными статистическими показателями, такими как скользящее среднее, линейная регрессия и т. д. - нет необходимости кодировать их с нуля.
  • Интеграция с экосистемой PyData - zipline использует Pandas DataFrames для хранения входных данных, а также показателей эффективности.
  • Легко интегрировать другие библиотеки, такие как matplotlib, scipy, statsmodels и sklearn, в рабочий процесс создания и оценки стратегий.
  • Разрабатывается и обновляется Quantopian, который предоставляет веб-интерфейс для zipline, исторических данных и даже возможности для реальной торговли.

Я считаю, что эти аргументы говорят сами за себя. Давайте начнем кодировать!

Настройка виртуальной среды с использованием conda

Самый удобный способ установить zipline - это использовать виртуальную среду. В этой статье я использую для этого conda. Я создаю новую среду с Python 3.5 (у меня возникли проблемы с использованием 3.6 или 3.7), а затем устанавливаю zipline. Вы также можете установить ее.

# создаем новую виртуальную среду
conda create -n env_zipline python=3.5

# активируем ее
conda activate env_zipline

# устанавливаем zipline
conda install -c Quantopian zipline

Для того, чтобы все работало правильно, вы также должны установить jupyter и другие пакеты, используемые в этой статье (см. распечатку watermark ниже).

Импорт библиотек

Во-первых, нам нужно загрузить расширения IPython, используя магию % load_ext.

%load_ext watermark
%load_ext zipline

Затем мы импортируем остальные библиотеки:

import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import zipline
from yahoofinancials import YahooFinancials
import warningsplt.style.use('seaborn')
plt.rcParams['figure.figsize'] = [16, 9]
plt.rcParams['figure.dpi'] = 200
warnings.simplefilter(action='ignore', category=FutureWarning)

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


Импорт пользовательских данных

zipline поставляется с данными, загруженными из Quandl (база данных WIKI). Вы всегда можете проверить уже загруженные данные, выполнив команду:

!zipline bundles

Проблема с этим подходом состоит в том, что в середине 2018 года выдача данных была прекращена, и данные за последний год отсутствуют. Кроме того, набор данных Quandl охватывает только американские акции. В этой статье мы хотим построить простые стратегии, основанные на акциях ЕС. Вот почему мы должны вручную получить данные из другого источника. Для этого я использую yahoofinancialslibrary. Для загрузки в zipline данные должны быть в CSV-файле и в определенном формате. Для этой статьи я загружаю две ценные бумаги: цены на акции ABN AMRO (голландский банк) и AEX (индекс фондового рынка, составленный из голландских компаний, торгующих на Euronext Amsterdam). Мы используем последний в качестве бенчмарка.

Во-первых, мы определяем короткую функцию для загрузки данных с использованием yahoofinancials и подготовки DataFrame таким образом, чтобы он мог быть принят zipline. После подготовки данных мы сохраняем данные в виде файла CSV в папке с именем daily (или в другой папке по вашему выбору).

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

def download_csv_data(ticker, start_date, end_date, freq, path):
    
    yahoo_financials = YahooFinancials(ticker)

    df = yahoo_financials.get_historical_price_data(start_date, end_date, freq)
    df = pd.DataFrame(df[ticker]['prices']).drop(['date'], axis=1) \
            .rename(columns={'formatted_date':'date'}) \
            .loc[:, ['date','open','high','low','close','volume']] \
            .set_index('date')
    df.index = pd.to_datetime(df.index)
    df['dividend'] = 0
    df['split'] = 1

    # save data to csv for later ingestion
    df.to_csv(path, header=True, index=True)

    # plot the time series
    df.close.plot(title='{} prices --- {}:{}'.format(ticker, start_date, end_date));

Мы начнем с загрузки цен на акции ABN AMRO.

download_csv_data(ticker='ABN.AS', 
                  start_date='2017-01-01', 
                  end_date='2017-12-31', 
                  freq='daily', 
                  path='european/daily/abn.csv')



И продолжим с индексом AEX.

download_csv_data(ticker='^AEX', 
                  start_date='2017-01-01', 
                  end_date='2017-12-31', 
                  freq='daily', 
                  path='european/daily/aex.csv')



Давайте проверим загруженные пакеты данных:

!zipline bundles


Теперь мы добавим пользовательский пакет с именем eu_stocks. Для этого нам нужно изменить файл extension.py, расположенный в каталоге zipline. Нам нужно добавить следующее:

start_session = pd.Timestamp('2017-01-02', tz='utc')
end_session = pd.Timestamp('2017-12-29', tz='utc')

# register the bundle
register(
    'eu_stocks',  # name we select for the bundle
    csvdir_equities(
        # name of the directory as specified above (named after data frequency)
        ['daily'],
        # path to directory containing the
        '/.../medium_articles/Quantitative Finance/european',
    ),
    calendar_name='XAMS',  # Euronext Amsterdam
    start_session=start_session,
    end_session=end_session
)

В отличие от функции загрузки данных, нам нужно передать точный диапазон дат загружаемых данных. В этом примере мы начинаем с 2017–01–02, поскольку это первый день, за который у нас есть данные о ценах.

Наконец, мы запускаем следующую команду для загрузки пакета:

!zipline ingest --bundle eu_stocks

Стратегия "купи и держи"

Мы начнем с самой основной стратегии - Buy и Hold. Идея заключается в том, что мы покупаем определенный актив и ничего не делаем в течение всего срока инвестиционного горизонта. Эту простую стратегию также можно считать эталоном для более продвинутых - потому что нет смысла использовать очень сложную стратегию, которая генерирует меньше денег (например, из-за транзакционных издержек), чем покупать и ничего не делать.

В этом примере мы рассматриваем акции ABN AMRO и выбираем 2017 год в качестве периода тестирования. Мы начинаем с капитала 250 €. Я выбрал это число, так как мы покупаем только 10 акций, поэтому нет необходимости в начальном балансе в пару тысяч. Кроме того, zipline по умолчанию работает с долларами США, однако, когда все активы находятся в одной валюте, нет проблем с использованием акций и индексов, котируемых в евро. Как и в BUX Zero, мы не берем на себя никаких операционных издержек (0 евро за акцию, без минимальной цены за сделку).

Существует два подхода к использованию zipline - использование командной строки или Jupyter Notebook. Чтобы использовать последний вариант, мы должны написать алгоритм в ячейке Notebook и указать, что zipline должен его запускать. Это делается с помощью магической команды IPython %%zipline. Она принимает те же аргументы, что и команда, упомянутая выше.

Также важно отметить, что все импорты библиотек, необходимые для запуска алгоритма (такие как numpy, sklearn и т.д.), должны быть указаны в ячейке алгоритма, даже если они были ранее импортированы в другом месте.

%%zipline --start 2017-1-2 --end 2017-12-29 --capital-base 250 --bundle eu_stocks -o buy_and_hold.pkl --trading-calendar XAMS

# imports
from zipline.api import order, symbol, record, set_benchmark

# parameters
selected_stock = 'ABN'
n_stocks_to_buy = 10

def initialize(context):
    set_benchmark(symbol('AEX'))
    context.asset = symbol('ABN')
    context.has_ordered = False  

def handle_data(context, data):
    # record price for further inspection
    record(price=data.current(symbol(selected_stock), 'price'))
    
    # trading logic
    if not context.has_ordered:
        # placing order, negative number for sale/short
        order(symbol(selected_stock), n_stocks_to_buy)
        # setting up a flag for holding a position
        context.has_ordered = True

Поздравляем, мы написали наш первый тест. Так что же на самом деле произошло?

Каждый алгоритм zipline содержит (как минимум) две функции, которые мы должны определить:
  • initialize(context)
  • handle_data(context, data)

Перед запуском алгоритма вызывается функция initialize() и передается переменная context. context - это глобальная переменная, в которой мы можем хранить дополнительные переменные, которые нам нужны для доступа от одной итерации алгоритма к следующей. Здесь также необходимо изменить эталонный тест на AEX, поскольку эталонный эталон по умолчанию - SP500, торгуемый на NYSE.

После инициализации алгоритма функция handle_data() вызывается один раз для каждого события. При каждом вызове она передает одну и ту же переменную context и фрейм события, называемый data. Он содержит текущий торговый бар с ценами открытия, максимума, минимума и закрытия (OHLC) вместе с объемом.

Мы создаем заказ, используя order(asset, number_of_units), где мы указываем, что покупать и сколько акций. Положительное число указывает на покупку такого количества акций, 0 означает продажу всего, что у нас есть, а отрицательное число используется для коротких продаж. Другой полезный тип ордера - order_target, который заказывает столько акций, сколько необходимо для достижения желаемого числа в портфеле.

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

Давайте проанализируем эффективность стратегии. Во-первых, нам нужно загрузить DataFrame performance из файла pickle.

# читаем итоговый фрейм данных об эффективности
buy_and_hold_results = pd.read_pickle('buy_and_hold.pkl')

И теперь мы можем построить некоторые из сохраненных метрик:

fig, ax = plt.subplots(3, 1, sharex=True, figsize=[16, 9])

# portfolio value
buy_and_hold_results.portfolio_value.plot(ax=ax[0])
ax[0].set_ylabel('portfolio value in €')

# asset
buy_and_hold_results.price.plot(ax=ax[1])
ax[1].set_ylabel('price in €')

# mark transactions
perf_trans = buy_and_hold_results.loc[[t != [] for t in buy_and_hold_results.transactions]]
buys = perf_trans.loc[[t[0]['amount'] > 0 for t in perf_trans.transactions]]
sells = perf_trans.loc[[t[0]['amount'] < 0 for t in perf_trans.transactions]]
ax[1].plot(buys.index, buy_and_hold_results.price.loc[buys.index], '^', markersize=10, color='g', label='buy')
ax[1].plot(sells.index, buy_and_hold_results.price.loc[sells.index], 'v', markersize=10, color='r', label='sell')

# daily returns
buy_and_hold_results.returns.plot(ax=ax[2])
ax[2].set_ylabel('daily returns')

fig.suptitle('Buy and Hold Strategy - ABN AMRO', fontsize=16)
plt.legend()
plt.show()

print('Final portfolio value (including cash): {}€'.format(np.round(buy_and_hold_results.portfolio_value[-1], 2)))



С первого взгляда мы видим, что портфель генерировал деньги за инвестиционный горизонт и очень близко следовал цене ABN AMRO (что имеет смысл, поскольку это единственный актив в портфеле).

Для просмотра транзакций нам нужно преобразовать столбец транзакций из DataFrame performance.

pd.DataFrame.from_records([x[0] for x in buy_and_hold_results.transactions.values if x != []])


Изучив столбцы эффективности `DataFrame`, мы можем увидеть все доступные метрики.

buy_and_hold_results.columns


Некоторые из заслуживающих внимания:
  • starting/ending cash - проверка наличия денег в данный день;
  • starting/ending value - проверка активов, их стоимость в данный день;
  • orders - используется для проверки ордеров. Существуют разные события для создания ордера, когда торговая стратегия генерирует сигнал, и отдельное событие, когда оно фактически выполняется на следующий торговый день.
  • pnl - ежедневные прибыли и убытки.

Простая стратегия скользящего среднего

Вторая стратегия, которую мы рассматриваем, основана на простой скользящей средней (SMA). «Механика» стратегии может быть обобщена следующим образом:
  • когда цена пересекает 20-дневную SMA вверх - покупаем x акций;
  • когда цена пересекает 20-дневную SMA вниз - продаем акции;
  • мы можем иметь только максимум x акций в любой момент времени;
  • в стратегии нет коротких продаж (хотя их легко реализовать).

Остальные компоненты бэк-теста, такие как рассматриваемый актив, горизонт инвестиций или стартовый капитал, такие же, как в примере Buy и Hold.

%%zipline --start 2017-1-2 --end 2017-12-29 --capital-base 250 --bundle eu_stocks -o sma_strategy.pkl --trading-calendar XAMS

# imports 
from zipline.api import order_target, record, symbol, set_benchmark
from zipline.finance import commission
import matplotlib.pyplot as plt
import numpy as np

# parameters 
ma_periods = 20
selected_stock = 'ABN'
n_stocks_to_buy = 10

def initialize(context):
    context.time = 0
    context.asset = symbol(selected_stock)
    set_benchmark(symbol('AEX'))
    # 1. manually setting the commission
    context.set_commission(commission.PerShare(cost=0.0, min_trade_cost=0))

def handle_data(context, data):
    # 2. warm-up period
    context.time += 1
    if context.time < ma_periods:
        return

    # 3. access price history
    price_history = data.history(context.asset, fields="price", bar_count=ma_periods, frequency="1d")

    # 4. calculate moving averages
    ma = price_history.mean()
    
    # 5. trading logic
    
    # cross up
    if (price_history[-2] < ma) & (price_history[-1] > ma):
        order_target(context.asset, n_stocks_to_buy)
    # cross down
    elif (price_history[-2] > ma) & (price_history[-1] < ma):
        order_target(context.asset, 0)

    # save values for later inspection
    record(price=data.current(context.asset, 'price'),
           moving_average=ma)
    
# 6. analyze block
def analyze(context, perf):
    fig, ax = plt.subplots(3, 1, sharex=True, figsize=[16, 9])

    # portfolio value
    perf.portfolio_value.plot(ax=ax[0])
    ax[0].set_ylabel('portfolio value in €')
    
    # asset
    perf[['price', 'moving_average']].plot(ax=ax[1])
    ax[1].set_ylabel('price in €')
    
    # mark transactions
    perf_trans = perf.loc[[t != [] for t in perf.transactions]]
    buys = perf_trans.loc[[t[0]['amount'] > 0 for t in perf_trans.transactions]]
    sells = perf_trans.loc[[t[0]['amount'] < 0 for t in perf_trans.transactions]]
    ax[1].plot(buys.index, perf.price.loc[buys.index], '^', markersize=10, color='g', label='buy')
    ax[1].plot(sells.index, perf.price.loc[sells.index], 'v', markersize=10, color='r', label='sell')
    ax[1].legend()
    
    # daily returns
    perf.returns.plot(ax=ax[2])
    ax[2].set_ylabel('daily returns')

    fig.suptitle('Simple Moving Average Strategy - ABN AMRO', fontsize=16)
    plt.legend()
    plt.show()
    
    print('Final portfolio value (including cash): {}€'.format(np.round(perf.portfolio_value[-1], 2)))


Код для этого алгоритма немного сложнее, но мы рассмотрим все новые аспекты кода. Для простоты я обозначил контрольные точки в приведенном выше фрагменте кода (sma_strategy.py) и буду ссылаться на них по номерам ниже.

1. Я показываю, как вручную установить комиссию. В этом случае я использую значение по умолчанию для сравнения.
2. «warm-up period» - это уловка, используемая для того, чтобы у алгоритма было достаточно дней, чтобы вычислить скользящее среднее. Если мы используем несколько метрик с разной длиной окна, мы всегда должны брать самую длинную из них для warm-up.
3. Мы получаем доступ к историческим (и текущим) точкам данных, используя data.history. В этом примере мы получаем доступ за последние 20 дней. Данные (в случае одного актива) хранятся как pandas.Series, проиндексированные по времени.
4. SMA - это очень простая мера, поэтому для расчета я просто беру среднее из ранее полученных данных.
5. Я инкапсулирую логику торговой стратегии в операторе if. Для доступа к текущим и предыдущим точкам данных я использую price_history [-2] и price_history [-1], соответственно. Чтобы увидеть, не было ли пересечения, я сравниваю текущие и предыдущие цены с МА и определяю, в каком случае я имею сигнал (на покупку/продажу). В случае отсутствия сигнала алгоритм ничего не делает.
6. Вы можете использовать оператор analyze(context, perf), чтобы выполнить дополнительный анализ (например, построение графиков), когда тестирование завершено. Объект perf - это просто DataFrame эффективности, который мы также храним в файле pickle. Но когда он используется в алгоритме, мы должны ссылаться на него как на perf, и нет необходимости его загружать.

По сравнению со стратегией покупки и удержания вы могли заметить периоды, когда стоимость портфеля не изменилась. Это потому, что когда мы продаем актив (и до того, как купим снова), мы держим только наличные.

В нашем случае стратегия Buy и Hold превзошла простую скользящую среднюю. Конечная стоимость портфеля (включая денежные средства) для стратегии B & H составляет 299,37 евро, а в случае более сложной - 267,68 евро.

Заключение

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

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

Вы можете найти код, используемый для этой статьи, в моем GitHub.

Комментариев нет:

Отправить комментарий