Структура

Торговый алгоритм в момент времени t−1 считывает все доступные данные вплоть до цены ci(t−1) для каждого актива i и возвращает vi(t−1) долю капитала, которая будет инвестирована в актив i на следующем открытии по цене oi(t).

Если D(t) капитал, находящийся в нашем распоряжении после того, как стали известны цены открытия oi(t) для всех активов, то мы генерируем позиции в соответствии с:

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

Положительные значения соответствуют длинным позициям (мы покупаем и ожидаем получить прибыль от роста цен), vi(t)>0, отрицательные коротким (мы берем акции в долг, обязуясь их вернуть, и ожидаем получить прибыль от падения цены), vi(t) <0. Предполагается, что как длинная, так и короткая позиция требуют одинаковых денежных затрат. Здесь не требуется полного (100%) инвестирования капитала, однако, позиции должны быть нормализованы, то есть:

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

Риск

Согласно стратегии, капитал распределяется в соответствии с риском для каждого актива (волатильностью, или, другими словами, изменчивостью цены): позиции задаются обратно пропорционально оценке волатильности.

Для оценки риска мы используем внутридневные и ежедневные колебания цены за последние 20 дней:

def calc_risk(data, period=20):
  data_copy = data.copy()
  dims = data_copy.dims
    
  time_series = np.sort(data_copy.coords[ds.TIME])
  data_copy = data_copy.transpose(ds.FIELD, ds.TIME,
ds.ASSET).loc[['close', 'high', 'low', 'close_bk',
'high_bk','low_bk'], time_series, :]

  close_lag = data_copy.loc['close'].shift({ds.TIME: 1})
  # цена закрытия вчера и сегодня
  d1 = data_copy.loc['high_bk'] - data_copy.loc['low_bk']
# диапазон цены за сутки    
  d2 = abs(data_copy.loc['close_bk'] -
data_copy.loc['close_bk'].shift({ds.TIME: 1})).drop('field')
    
  # вычисление максимума
  dd = xr.concat([d1, d2], dim='d').max(dim='d')
    
  # относительная доходность
  dv = dd / close_lag
     
  # вычисление среднего за последние 20 дней
  risk = dv.rolling({ds.TIME: period},
min_periods=period).mean(skipna=True).ffill(ds.TIME)
  risk.coords['field'] = 'risk'
    
  return risk.loc[data.coords[ds.TIME], :]

# вычислим риск и добавим его в данные
risk = calc_risk(data, period=20)
data = xr.concat([data, risk], dim='field')

В коде, приведенном ниже, определена стратегия buy-and-hold: веса (распределение долей инвестируемого капитала среди активов) установлены обратно пропорционально риску и нормализованы, что подразумевает полное инвестирование.

Масштабирование по риску приводит к тому, что каждый актив дает величину доходов одного порядка. Дневной доход актива i равен:

Вторая формула работает, поскольку дивиденды начисляются до открытия биржи, здесь:

  • Ni(t) — число позиций, открываемых после определения цен открытия oi(t)
  • D(t) —капитал, имеющийся на момент открытия
  • vi(t−1) — распределение весов, обратно пропорциональное риску:

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

Итак, торговый алгоритм в момент времени t считывает все данные доступные вплоть до цены закрытия текущего дня для каждого актива i

и возвращает vi(t), долю капитала, которую мы хотим вложить в каждый актив на следующем открытии.

def step(data):    
  risk = data.loc[:, "risk"].to_pandas().iloc[0,:]
  weights = 1.0 / risk
  weights_sum = weights.abs().sum()
  weights_norm = weights.dropna() / weights_sum
  assets = weights_norm.index
  return xr.DataArray(weights_norm.values, dims = [ds.ASSET],
        coords = {ds.ASSET:assets})

init_data_length = 20
output = test_strategy(data, step=step, init_data_length=init_data_length) 

Параметр init_data_length — количество дней в начале, которые следует пропустить, потому что для них значения используемых в стратегии величин еще не определено. В нашем примере это 20, поскольку риск рассчитывается на основе предыдущих 20 дней.

В результате получаем output в формате xarray — значение весов стратегии для каждого дня.

Бэктест

Бэктестирование торгового алгоритма — это запуск алгоритма на исторических данных и вычисление доходностей в соответствии с весами, установленными алгоритмом.

Проскальзывание

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

Рассмотрим следующий пример: мы хотим приобрести сейчас 20 000 акций. Однако, по текущей цене 100,00 долларов США предлагаются на продажу только 500 акций. Чтобы выполнить заявку, нам нужно покупать по все более высоким ценам, и мы понесем убытки из-за проскальзывания, например:

  • Buy 5000 at 100.00 USD
  • Buy 5000 at 100.02 USD
  • Buy 5000 at 100.04 USD
  • Buy 3000 at 100.06 USD
  • Buy 2000 at 100.08 USD

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

Где Ni(t) обозначает количество акций, соответствующее активу i, в момент времени t, а ATR14(t) является максимумом между ежедневным (day-to-day) и внутридневным (intraday) ценовыми изменениями за последние 14 дней:

Важные замечания

Решение, принятое в момент закрытия, осуществляется на следующем открытии. При этом по умолчанию реинвестируются весь доступный капитал. Обозначим через E наш капитал при закрытии, D — наш капитал при открытии и div si(t) — дивиденды, приходящиеся на одну акцию. Количество акций, соответствующих активу i в момент времени t, равно Ni(t), тогда:

Из-за ночного изменения цен, и в конце дня, с учетом проскальзывания:

Из-за ночного изменения цен, и в конце дня, с учетом проскальзывания:

Результаты

Функции бэктеста в библиотеке моделируют доходности по сгенерированным позициям. Стоит отметить, что эти доходности, которые также в дальнейшем используются в статистиках, рассчитываются от закрытия биржи к закрытию. Результат записывается в поле relative_return таблицы stat и содержит рассчитанные доходности:

print(stat.to_pandas().tail())

В следующей статье мы расскажем как по доходностям мы вычисляем статистические показатели (в годовом исчислении):

  • mean return — средняя доходность;
  • volatility — волатильность (стандартное отклонение доходностей);
  • Sharpe ratio — коэффициент Шарпа;
  • maximum drawdown — максимальная просадка.

а также данные, необходимые для построения графика доходности PnL, диаграммы underwater chart и скользящего (за год) коэффициента Шарпа rolling Sharpe ratio chart.

Эти статистики позволяют провести сравнительный анализ стратегий.