Статистический арбитраж (Stat Arb) - это торговые стратегии, которые обычно используют либо возврат к среднему значению в цене акций, либо возможности, создаваемые аномалиями микроструктуры рынка. Это высокоэффективный количественный аналитический подход с использованием хорошо диверсифицированного портфеля ценных бумаг. Однако, чтобы проиллюстрировать концепцию, я возьму пример парной торговли с участием только двух коинтегрированных ценных бумаг.

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

  1. Фондовый мир и идентификация коинтегрированных пар акций.
  2. Выполните стационарный тест для выбранной пары.
  3. Создавайте торговые сигналы, используя z-оценку.
  4. Расчет прибылей и убытков портфеля.

Вселенная акций и идентификация коинтегрированных пар акций

Наш первый шаг - определить вселенную акций и определить пары с высокой корреляцией. Очень важно, чтобы это основывалось на экономических отношениях, таких как компании с аналогичным бизнесом, иначе это может быть ложным. Я изучил все составляющие NSE-100, которые относятся к категории компаний ФИНАНСОВЫЕ УСЛУГИ. Это дает нам список из 25 компаний для начала. Однако мы отфильтровываем компании с ежедневными данными о ценах менее 10 лет и оставляем только последние 15 акций. Мы берем дневную цену закрытия для этих 15 акций и разбиваем фреймворк на тестовый и обучающий наборы. Это сделано для того, чтобы наше решение о выборе коинтегрированной пары было основано на наборе обучающих данных, а тестирование на истории выполняется с использованием набора тестовых данных, не входящего в выборку. В качестве первого шага мы будем использовать коэффициент корреляции Пирсона, чтобы получить базовое представление о взаимосвязи между этими акциями, а затем работать над идентификацией коинтегрированных акций с помощью функции coint form statsmodels.tsa.stattools . Функция coint, вернет p-значения теста коинтеграции для каждой пары. Мы будем хранить эти p-значения в массиве и визуализировать его как тепловую карту. Если значение p ниже 0,05, это означает, что мы можем отклонить нулевую гипотезу и два временных ряда разных символов могут быть коинтегрированы.

Давайте сразу перейдем к коду:

# make the necessary imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import datetime
idx = pd.IndexSlice
import statsmodels.api as sm
from statsmodels.regression.linear_model import OLS
from statsmodels.tsa.stattools import adfuller
from statsmodels.tsa.stattools import coint
from sklearn.model_selection import train_test_split
%matplotlib inline
%config InlineBackend.figure_format = ‘retina’
# read the matadata csv
nifty_meta = pd.read_csv('data/nifty_meta.csv')
nifty_meta.head(2)
# get the ticker list with industry is equal to FINANCIAL SERVICES
tickers = list(nifty_meta[nifty_meta.Industry=='FINANCIAL SERVICES'].Symbol)
print(tickers)
print(len(tickers))
# start and end dates for backtesting
fromdate = datetime.datetime(2010, 1, 1)
todate = datetime.datetime(2020, 6, 15)
# read back the pricing data
prices = pd.read_csv('data/prices.csv', index_col=['ticker','date'], parse_dates=True)
prices.head(2)
# remove tickers where we have less than 10 years of data.
min_obs = 2520
nobs = prices.groupby(level='ticker').size()
keep = nobs[nobs>min_obs].index
prices = prices.loc[idx[keep,:], :]
prices.info()
# final tickers list
TICKERS = list(prices.index.get_level_values('ticker').unique())
print(len(TICKERS))
print(TICKERS)
# unstack and take close price
close = prices.unstack('ticker')['close'].sort_index()
close = close.dropna()
close.head(2)
# train test split 
train_close, test_close = train_test_split(close, test_size=0.5, shuffle=False)
# quick view of head and tail of train set
train_close.head(2).append(train_close.tail(2))
# Pearson correlation to get the basic idea about the relationship
fig, ax = plt.subplots(figsize=(10,7))
sns.heatmap(train_close.pct_change().corr(method ='pearson'), ax=ax, cmap='coolwarm', annot=True, fmt=".2f") #spearman
ax.set_title('Assets Correlation Matrix')
plt.savefig('images/chart1', dpi=300)
# function to find cointegrated pairs
def find_cointegrated_pairs(data):
    n = data.shape[1]
    pvalue_matrix = np.ones((n, n))
    keys = data.keys()
    pairs = []
    for i in range(n):
        for j in range(i+1, n):
            result = coint(data[keys[i]], data[keys[j]])
            pvalue_matrix[i, j] = result[1]
            if result[1] < 0.05:
                pairs.append((keys[i], keys[j]))
    return pvalue_matrix, pairs
# calculate p-values and plot as a heatmap
pvalues, pairs = find_cointegrated_pairs(train_close)
print(pairs)
fig, ax = plt.subplots(figsize=(10,7))
sns.heatmap(pvalues, xticklabels = train_close.columns,
                yticklabels = train_close.columns, cmap = 'RdYlGn_r', annot = True, fmt=".2f",
                mask = (pvalues >= 0.99))
ax.set_title('Assets Cointregration Matrix p-values Between Pairs')
plt.tight_layout()
plt.savefig('images/chart2', dpi=300)

Комментарий к коду:

  1. Делаем необходимый импорт библиотек Python.
  2. Затем мы читаем nifty_meta.csv и создаем список тикеров, где отраслью "ФИНАНСОВЫЕ УСЛУГИ", и устанавливаем даты начала и окончания.
  3. Прочтите ежедневные данные о ценах из prices.csv и удалите тикеры, если у нас есть данные менее чем за 10 лет.
  4. Разделите фрейм ценовых данных, чтобы иметь только данные закрытия за день, и разделите их на 50% каждый на наборы данных для поездов и тестирования.
  5. Постройте корреляцию Пирсона ежедневных доходов, чтобы получить общее представление об отношениях.
  6. Определите функцию find_cointegrated_pairs для поиска коинтегрированных пар и соответствующих p-значений.
  7. Постройте тепловую карту p-значений.

Коэффициент корреляции Пирсона варьируется от +1 до -1 и является линейной мерой взаимосвязи между двумя переменными. Значение +1 указывает на сильную положительную корреляцию, ноль указывает на отсутствие связи, а -1 указывает на сильную отрицательную связь. На приведенной выше тепловой карте видно, что существует несколько пар с сильной положительной корреляцией.

Давайте также проанализируем результат теста на коинтеграцию. На приведенной выше тепловой карте видно, что существует много пар с p-значением менее 0,05. Это означает, что для этих пар мы можем отклонить нулевую гипотезу, и они могут быть коинтегрированы.

Выполните стационарный тест для выбранной пары

Теперь у нас есть много кандидатов пар для стратегии, в которой значение p меньше 0,05. Выбор правильной пары имеет первостепенное значение, поскольку стратегия не будет работать, если цены движутся точно вместе. Чтобы наша стратегия была прибыльной, они должны расходиться и возвращаться к среднему.

Давайте перейдем к тикерам BANKBARODA и SBIN и дополнительно проверим стационарность спреда с помощью расширенного теста Дики-Фуллера. Важно, чтобы разброс был стационарным. Временной ряд считается стационарным, если такие параметры, как среднее значение и дисперсия, не меняются с течением времени и отсутствует единичный корень. Сначала мы рассчитаем коэффициент хеджирования между этими двумя тикерами, используя регрессию OLS. Затем, используя коэффициент хеджирования, мы рассчитаем спред и запустим расширенный тест Дики-Фуллера.

Давайте рассмотрим код:

# final pair to test strategy
asset1 = ‘BANKBARODA’
asset2 = ‘SBIN’
# create a train dataframe of 2 assets
train = pd.DataFrame()
train['asset1'] = train_close[asset1]
train['asset2'] = train_close[asset2]
# visualize closing prices
ax = train[['asset1','asset2']].plot(figsize=(12, 6), title = 'Daily Closing Prices for {} and {}'.format(asset1,asset2))
ax.set_ylabel("Closing Price")
ax.grid(True);
plt.savefig('images/chart3', dpi=300)
# run OLS regression
model=sm.OLS(train.asset2, train.asset1).fit()
# print regression summary results
plt.rc('figure', figsize=(12, 7))
plt.text(0.01, 0.05, str(model.summary()), {'fontsize': 16}, fontproperties = 'monospace')
plt.axis('off')
plt.tight_layout()
plt.subplots_adjust(left=0.2, right=0.8, top=0.7, bottom=0.1)
plt.savefig('images/chart4', dpi=300);
print('Hedge Ratio = ', model.params[0])
# calculate spread
spread = train.asset2 - model.params[0] * train.asset1
# Plot the spread
ax = spread.plot(figsize=(12, 6), title = "Pair's Spread")
ax.set_ylabel("Spread")
ax.grid(True);
# conduct Augmented Dickey-Fuller test
adf = adfuller(spread, maxlag = 1)
print('Critical Value = ', adf[0])
# probablity critical values
print(adf[4])

Комментарий к коду:

  1. Выберите asset1 как BANKBARODA и asset2 как SBIN.
  2. Создайте фрейм данных цен закрытия двух вышеуказанных акций, используя набор обучающих данных, и визуализируйте его.
  3. Запустите регрессию OLS и получите коэффициент наклона, который также является нашим коэффициентом хеджирования.
  4. Рассчитайте распространение и постройте его для визуализации.
  5. Запустите расширенный тест Дики-Фуллера, чтобы проверить стационарность распространения и наличие единичного корня.

Из приведенного выше графика видно, что цены закрытия между этими двумя акциями движутся совершенно вместе.

Высокое значение R-квадрата и близкое к нулю значение p из регрессии OLS предполагают очень высокую корреляцию между этими двумя акциями. Распределение выглядит стационарным, и критическое значение расширенного теста Дики-Фуллера составляет -3,459, что меньше значения на уровне значимости 1% (-3,435). Следовательно, мы можем отвергнуть нулевую гипотезу о том, что спред имеет единичный корень, и сделать вывод, что это стационарность по своей природе.

Генерация торговых сигналов с использованием z-показателя

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

Давайте рассмотрим код:

# calculate z-score
def zscore(series):
 return (series — series.mean()) / np.std(series)
# create a dataframe for trading signals
signals = pd.DataFrame()
signals['asset1'] = test_close[asset1] 
signals['asset2'] = test_close[asset2]
ratios = signals.asset1 / signals.asset2
# calculate z-score and define upper and lower thresholds
signals['z'] = zscore(ratios)
signals['z upper limit'] = np.mean(signals['z']) + np.std(signals['z'])
signals['z lower limit'] = np.mean(signals['z']) - np.std(signals['z'])
# create signal - short if z-score is greater than upper limit else long
signals['signals1'] = 0
signals['signals1'] = np.select([signals['z'] > \
                                 signals['z upper limit'], signals['z'] < signals['z lower limit']], [-1, 1], default=0)
# we take the first order difference to obtain portfolio position in that stock
signals['positions1'] = signals['signals1'].diff()
signals['signals2'] = -signals['signals1']
signals['positions2'] = signals['signals2'].diff()
# verify datafame head and tail
signals.head(3).append(signals.tail(3))
# visualize trading signals and position
fig=plt.figure(figsize=(14,6))
bx = fig.add_subplot(111)   
bx2 = bx.twinx()
#plot two different assets
l1, = bx.plot(signals['asset1'], c='#4abdac')
l2, = bx2.plot(signals['asset2'], c='#907163')
u1, = bx.plot(signals['asset1'][signals['positions1'] == 1], lw=0, marker='^', markersize=8, c='g',alpha=0.7)

Комментарий к коду:

  1. Определите функцию zscore для вычисления z-оценки.
  2. Создайте фрейм данных сигналов для наших двух акций с ценой закрытия из набора данных тестирования и рассчитайте их соотношение цен.
  3. Рассчитайте z-оценку для отношения и определите верхний и нижний пороги с помощью плюс и минус одно стандартное отклонение.
  4. Создайте столбец сигналов со следующей логикой: если z-оценка больше верхнего порога, то у нас будет -1 (короткий сигнал), однако если z-оценка меньше нижнего порога, то +1 (длинный сигнал) и значение по умолчанию равен нулю при отсутствии сигнала.
  5. Возьмите разницу первого порядка в сигнальном столбце, чтобы получить позицию по акции. Если +1, значит, у нас длинная позиция, -1 - тогда короткая и 0, если позиции нет.
  6. Второй сигнал будет прямо противоположным первому, что означает, что мы открываем длинную позицию по одной акции и одновременно короткие по другой. Аналогичным образом возьмите разность первого порядка для второго сигнала и вычислите столбец второй позиции.
  7. Затем мы визуализируем цены на акции вместе с их длинными и короткими позициями в портфеле.

Расчет прибылей и убытков портфеля

Мы начнем с начального капитала в 100 000 и рассчитаем максимальное количество позиций для каждой акции, используя начальный капитал. В любой день общая прибыль и убыток от первой акции будут соответствовать сумме владения этой акцией и денежной позицией по этой акции. Точно так же прибыль и убыток для второй акции будут складываться из общей суммы акций и денежных средств для этой акции. Вы должны иметь в виду, что у нас нейтральная рыночная позиция, что означает, что мы открываем длинные и короткие позиции одновременно с примерно одинаковым капиталом. Наконец, чтобы получить общую прибыль и убыток, мы должны объединить эти два значения. Основываясь на позиции для акций 1 и 2, мы рассчитываем их дневную доходность. Мы также добавим столбец z-оценки с столбцами верхнего и нижнего порогового значения в окончательный фрейм данных портфолио для целей визуализации.

Перейдем к коду:

# initial capital to calculate the actual pnl
initial_capital = 100000

# shares to buy for each position
positions1 = initial_capital// max(signals['asset1'])
positions2 = initial_capital// max(signals['asset2'])

# since there are two assets, we calculate each asset Pnl 
# separately and in the end we aggregate them into one portfolio
portfolio = pd.DataFrame()
portfolio['asset1'] = signals['asset1']
portfolio['holdings1'] = signals['positions1'].cumsum() * signals['asset1'] * positions1
portfolio['cash1'] = initial_capital - (signals['positions1'] * signals['asset1'] * positions1).cumsum()
portfolio['total asset1'] = portfolio['holdings1'] + portfolio['cash1']
portfolio['return1'] = portfolio['total asset1'].pct_change()
portfolio['positions1'] = signals['positions1']
# pnl for the 2nd asset
portfolio['asset2'] = signals['asset2']
portfolio['holdings2'] = signals['positions2'].cumsum() * signals['asset2'] * positions2
portfolio['cash2'] = initial_capital - (signals['positions2'] * signals['asset2'] * positions2).cumsum()
portfolio['total asset2'] = portfolio['holdings2'] + portfolio['cash2']
portfolio['return2'] = portfolio['total asset2'].pct_change()
portfolio['positions2'] = signals['positions2']

# total pnl and z-score
portfolio['z'] = signals['z']
portfolio['total asset'] = portfolio['total asset1'] + portfolio['total asset2']
portfolio['z upper limit'] = signals['z upper limit']
portfolio['z lower limit'] = signals['z lower limit']
portfolio = portfolio.dropna()

# plot the asset value change of the portfolio and pnl along with z-score
fig = plt.figure(figsize=(14,6),)
ax = fig.add_subplot(111)
ax2 = ax.twinx()
l1, = ax.plot(portfolio['total asset'], c='g')
l2, = ax2.plot(portfolio['z'], c='black', alpha=0.3)
b = ax2.fill_between(portfolio.index,portfolio['z upper limit'],\
                portfolio['z lower limit'], \
                alpha=0.2,color='#ffb48f')
ax.set_ylabel('Asset Value')
ax2.set_ylabel('Z Statistics',rotation=270)
ax.yaxis.labelpad=15
ax2.yaxis.labelpad=15
ax.set_xlabel('Date')
ax.xaxis.labelpad=15
plt.title('Portfolio Performance with Profit and Loss')
plt.legend([l2,b,l1],['Z Statistics',
                      'Z Statistics +-1 Sigma',
                      'Total Portfolio Value'],loc='upper left');
plt.savefig('images/chart8', dpi=300);

# calculate CAGR
final_portfolio = portfolio['total asset'].iloc[-1]
delta = (portfolio.index[-1] - portfolio.index[0]).days
print('Number of days = ', delta)
YEAR_DAYS = 365
returns = (final_portfolio/initial_capital) ** (YEAR_DAYS/delta) - 1
print('CAGR = {:.3f}%' .format(returns * 100))

Комментарий к коду:

  1. Мы начинаем с начального капитала в 100 000 и рассчитываем количество акций, которые нужно купить для каждой акции.
  2. Затем мы вычисляем долю владения первой акцией, взяв кумулятивную сумму ее позиции, умноженную на цену акции, и на общее количество акций.
  3. Затем мы рассчитываем денежную позицию, вычитая холдинг из начальной денежной позиции. Общая позиция акций в портфеле - это сумма наличных денег плюс холдинговые.
  4. Рассчитайте дневную доходность, используя общую позицию по запасам.
  5. Затем мы выполняем шаги с 1 по 4 для второй акции и суммируем позиции двух активов для общей стоимости портфеля.
  6. Добавьте z-оценку с верхним и нижним порогами для визуализации.
  7. Визуализируйте производительность портфеля вместе с z-оценкой, верхним и нижним порогами.
  8. Рассчитайте CAGR портфеля.

Среднегодовой темп роста (CAGR) для стратегии составляет 16,5%, что выглядит многообещающим, однако есть много вещей, которые следует учитывать, прежде чем делать какие-либо выводы. Следует учитывать несколько важных факторов:

  1. Поскольку это рыночно-нейтральный подход, многое зависит от нашей способности делать короткие продажи, которые могут быть ограничены по разным причинам, включая правила.
  2. Мы не учитывали затраты, связанные с торговлей, проскальзыванием рынка и заимствованием ценных бумаг. Обычно нейтральная к рынку стратегия приводит к большому количеству сделок.
  3. Всегда существует ограничение на использование исторических данных для прогнозирования будущего.

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

Удачного инвестирования и оставляйте свои комментарии к статье!

Обратите внимание: этот анализ предназначен только для образовательных целей, и автор не несет ответственности за какие-либо ваши инвестиционные решения.

Использованная литература:

  1. Изучите алгоритмическую торговлю: создавайте и развертывайте алгоритмические торговые системы и стратегии с использованием Python и расширенного анализа данных Себастьяна Донадио и Сурав Гош.
  2. Https://je-suis-tm.github.io/quant-trading/.
  3. Пожалуйста, ознакомьтесь с моими другими статьями / сообщениями о количественных финансах на моей странице Linkedin или на Medium.