Битва за портфель: Python против финконсультантов

в 19:49, , рубрики: python, бэктестинг, инвестиции, портфель, Статистика в IT, финансы в IT

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

Во всём этом задействуем Pandas и минимизируем количество циклов. Погруппируем времянные ряды и порисуем графиков. Познакомимся с мультииндексами и их поведением. И всё это в Jupyter на Python 3.6.

Если хочешь сделать что-то хорошо, сделай это сам.
Фердинанд Порше

Описанный инструмент позволит подобрать оптимальные активы для портфеля и исключить инструменты, навязываемые консультантами. Но мы увидим лишь общую картину — без учёта ликвидности, времени набора позиций, комиссий брокера и стоимости одной акции. В целом, при ежемесячной или ежегодной ребалансировке у крупных брокеров это будут незначительные затраты. Однако перед применением выбранную стратегию всё же стоит проверить в event-driven бэктестере, например, Quantopian (QP), дабы исключить потенциальные ошибки.

Почему не сразу в QP? Время. Там самый простой тест длится около 5 минут. А текущее решение позволит вам за минуту проверить сотни разных стратегий с уникальными условиями.

Загрузка сырых данных

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

Код загрузки истории цен из БД доступен в репозитории. Ссылка будет в конце статьи.

Структура DataFrame

При работе с историей цен, для удобной группировки и доступа ко всем данным, лучшим решением является использование мильтииндекса (MultiIndex) с датой и тикерами.

df = df.set_index(['dt', 'symbol'], drop=False).sort_index()
df.tail(len(df.index.levels[1]) * 2)

image

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

Вот пример, как можно легко группировать историю по неделям, месяцам и годам. И всё это показать на графиках силами Pandas:

# Правила обработки колонок при группировке
agg_rules = {
    'dt': 'last', 'symbol': 'last',
    'open': 'first', 'high': 'max', 'low': 'min', 'close': 'last',
    'volume': 'sum', 'adj': 'last'
}
level_values = df.index.get_level_values
# Графики
fig = plt.figure(figsize=(15, 3), facecolor='white')
df.groupby([pd.Grouper(freq='W', level=0)] + [level_values(i) for i in [1]]).agg(
    agg_rules).set_index(['dt', 'symbol'], drop=False
    ).close.unstack(1).plot(ax=fig.add_subplot(131), title="Weekly")
df.groupby([pd.Grouper(freq='M', level=0)] + [level_values(i) for i in [1]]).agg(
    agg_rules).set_index(['dt', 'symbol'], drop=False
    ).close.unstack(1).plot(ax=fig.add_subplot(132), title="Monthly")
df.groupby([pd.Grouper(freq='Y', level=0)] + [level_values(i) for i in [1]]).agg(
    agg_rules).set_index(['dt', 'symbol'], drop=False
    ).close.unstack(1).plot(ax=fig.add_subplot(133), title="Yearly")
plt.show()

image

Для корректного отображения области с легендой графика мы переносим уровень индекса с тикерами на второй уровень над колонками, используя команду Series().unstack(1). С DataFrame() такой номер не пройдёт, но решение есть ниже.

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

monthly = df.groupby([pd.Grouper(freq='M', level=0), level_values(1)]).agg(agg_rules) 
          .set_index(['dt', 'symbol'], drop=False)

Пример получения истории цен определённого актива (берём все даты, тикер QQQ и все колонки):

monthly.loc[(slice(None), ['QQQ']), :]  # история одно актива

Ежемесячная волатильность активов

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

monthly = df.groupby([pd.Grouper(freq='M', level=0), level_values(1)]).agg(
              agg_rules).set_index(['dt', 'symbol'], drop=False)
# Ежемесячные изменения цены в процентах. Первое значение обнулим.
monthly['pct_close'] = monthly.groupby(level=1)['close'].pct_change().fillna(0)

# График
ax = monthly.pct_close.unstack(1).plot(title="Monthly", figsize=(15, 4))
ax.axhline(0, color='k', linestyle='--', lw=0.5)
plt.show()

image

Сравним доходность активов

Теперь воспользуемся оконным методом Series().rolling() и выведем доходность активов за определённый период:

Код на Python

rolling_prod = lambda x: x.rolling(len(x), min_periods=1).apply(np.prod)  # кумулятивный доход

monthly = df.groupby([pd.Grouper(freq='M', level=0), level_values(1)]).agg(
              agg_rules).set_index(['dt', 'symbol'], drop=False)
# Ежемесячные изменения цены в процентах. Первое значение обнулим. И прибавим 1.
monthly['pct_close'] = monthly.groupby(level=1)['close'].pct_change().fillna(0) + 1

# Новый DataFrame без данных старше 2007 года
fltr = monthly.dt >= '2007-01-01'
test = monthly[fltr].copy().set_index(['dt', 'symbol'], drop=False)  # обрежем dataframe и обновим индекс

test.loc[test.index.levels[0][0], 'pct_close'] = 1  # устанавливаем первое значение 1
# Получаем кумулятивный доход
test['performance'] = test.groupby(level=1)['pct_close'].transform(rolling_prod) - 1

# График
ax = test.performance.unstack(1).plot(title="Performance (Monthly) from 2007-01-01", figsize=(15, 4))
ax.axhline(0, color='k', linestyle='--', lw=0.5)
plt.show()

# Доходность каждого инструмента в последний момент
test.tail(len(test.index.levels[1])).sort_values('performance', ascending=False)

image

Методы ребалансировки портфелей

Вот мы и подобрались к самому вкусному. В примерах мы посмотрим результаты портфлеля при распределении капитала по заранее определённым долям между несколькими активами. А также добавим уникальные условия, по которым будем отказываться от некоторых активов в момент распределения капитала. Если подходящих активов не будет, то будем считать, что капитал лежит у брокера в кэше.

Для того чтобы при ребалансировке использовать методы Pandas, нам необходимо хранить доли распределения и условия ребалансировки в DataFrame с группированными данными. Теперь рассмотрим функции ребалансировок, которые будем передавать в метод DataFrame().apply():

Код на Python

def rebalance_simple(x):
    # Простая ребалансировка по долям
    data = x.unstack(1)
    return (data.pct_close * data['size']).sum() / data['size'].sum()

def rebalance_sma(x):
    # Ребалансировка по активам, у которых SMA50 > SMA200
    data = x.unstack(1)
    fltr = data['sma50'] > data['sma200']
    if not data[fltr]['size'].sum():
        return 1  # Баланс без изменений, если нет подходящих
    return (data[fltr].pct_close * data[fltr]['size']).sum() / data[fltr]['size'].sum()

def rebalance_rsi(x):
    # Ребалансировка по активам, у которых RSI100 > 50
    data = x.unstack(1)
    fltr = data['rsi100'] > 50
    if not data[fltr]['size'].sum():
        return 1  # Баланс без изменений, если нет подходящих
    return (data[fltr].pct_close * data[fltr]['size']).sum() / data[fltr]['size'].sum()

def rebalance_custom(x, df=None):
    # Медленная ребалансировка с уникальными условиями и внешними данными
    data = x.unstack(1)
    for s in data.index:
        if data['dt'][s]:
            fltr_dt = df['dt'] < data['rebalance_dt'][s]  # исключим будущее
            values = df[fltr_dt].loc[(slice(None), [s]), 'close'].values
            data.loc[s, 'custom'] = 0  # обнулим значение фильтра
            if len(values) > len(values[np.isnan(values)]):                
                # Получим RSI за 100 дней
                data.loc[s, 'custom'] = talib.RSI(values, timeperiod=100)[-1]
    
    fltr = data['custom'] > 50
    if not data[fltr]['size'].sum():
        return 1  # Баланс без изменений, если нет подходящих
    return (data[fltr].pct_close * data[fltr]['size']).sum() / data[fltr]['size'].sum()

def drawdown(chg, is_max=False):
    # Максимальная просадка доходности
    total = len(chg.index)
    rolling_max = chg.rolling(total, min_periods=1).max()
    daily_drawdown = chg/rolling_max - 1.0
    if is_max:
        return daily_drawdown.rolling(total, min_periods=1).min()
    return daily_drawdown

По порядку:

  • rebalance_simple — самая простая функция, которая будет распределять доходность каждого актива по долям.
  • rebalance_sma — функция, распределяющая капитал по активам, у которых скользящая средняя за 50 дней выше значения за 200 дней на момент ребалансировки.
  • rebalance_rsi — функция, распределяющая капитал по активам, у которых значение индикатора RSI за 100 дней выше 50.
  • rebalance_custom — самая медленная и самая универсальная функция, где мы будем высчитывать значения индикатора из дневной истории цен актива на момент ребалансировки. Здесь можно использовать любые условия и данные. Даже загружать каждый раз из внешних источников. Но без цикла уже не обойтись.
  • drawdown — вспомогательная фукция, показывающая максимальную просадку по портфелю.

В функциях ребалансировки нам необходим массив всех данных на дату в разрезе активов. Метод DataFrame().apply(), которым мы будем рассчитывать результаты портфелей, передаст в нашу функцию массив, где колонки станут индексом строк. А если мы сделаем мультииндекс, где нулевым уровнем будут тикеры, то к нам придёт мультииндекс. Этот мультииндекс мы сможем развернуть в двумерный массив и получить на каждой строке данные соответствующего актива.

image

Ребалансировка портфелей

Теперь достаточно подготовить необходимые условия и сделать в цикле расчёт для каждого портфеля. Первым делом рассчитаем индикаторы на дневной истории цен:

# Смещаем данные на 1 день вперед, чтобы не заглядывать в будущее
df['sma50'] = df.groupby(level=1)['close'].transform(lambda x: talib.SMA(x.values, timeperiod=50)).shift(1)
df['sma200'] = df.groupby(level=1)['close'].transform(lambda x: talib.SMA(x.values, timeperiod=200)).shift(1)
df['rsi100'] = df.groupby(level=1)['close'].transform(lambda x: talib.RSI(x.values, timeperiod=100)).shift(1)

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

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

Код на Python

# Условия портфелей: доли активов, функция ребалансировки, название
portfolios = [
    {'symbols': [('SPY', 0.8), ('AGG', 0.2)], 'func': rebalance_sma, 'name': 'Portfolio 80/20 SMA50x200'},
    {'symbols': [('SPY', 0.8), ('AGG', 0.2)], 'func': rebalance_rsi, 'name': 'Portfolio 80/20 RSI100>50'},
    {'symbols': [('SPY', 0.8), ('AGG', 0.2)], 'func': partial(rebalance_custom, df=df), 'name': 'Portfolio 80/20 Custom'},
    {'symbols': [('SPY', 0.8), ('AGG', 0.2)], 'func': rebalance_simple, 'name': 'Portfolio 80/20'},
    {'symbols': [('SPY', 0.4), ('AGG', 0.6)], 'func': rebalance_simple, 'name': 'Portfolio 40/60'},
    {'symbols': [('SPY', 0.2), ('AGG', 0.8)], 'func': rebalance_simple, 'name': 'Portfolio 20/80'},
    {'symbols': [('DIA', 0.2), ('QQQ', 0.3), ('SPY', 0.2), ('IWM', 0.2), ('AGG', 0.1)], 
     'func': rebalance_simple, 'name': 'Portfolio DIA & QQQ & SPY & IWM & AGG'},
]

for p in portfolios:
    # Обнуляем размер долей
    rebalance['size'] = 0.
    for s, pct in p['symbols']:
        # Устанавливаем свои доли для каждого актива
        rebalance.loc[(slice(None), [s]), 'size'] = pct
    # Подготовим индекс для корректной ребалансировки и получим доходность за каждый период
    rebalance_perf = rebalance.stack().unstack([1, 2]).apply(p['func'], axis=1)
    # Кумулятивная доходность портфеля
    p['performance'] = (rebalance_perf.rolling(len(rebalance_perf), min_periods=1).apply(np.prod) - 1)
    # Максимальная просадка портфеля
    p['drawdown'] = drawdown(p['performance'] + 1, is_max=True)

В этот раз нам потребуется провернуть хитрость с индексами колонок и строк, чтобы получить нужный мультииндекс в функции ребалансировки. Добьёмся этого, вызвав последовательно методы DataFrame().stack().unstack([1, 2]). Данный код перенесет колонки в строчный мультииндекс, а затем вернет обратно мультииндекс с тикерами и колонками в нужном порядке.

Готовые портфели на графики

Теперь осталось всё нарисовать. Для этого ещё раз запустим цикл по портфелям, который выведет данные на графики. В конце нарисуем SPY в качестве бенчмарка для сравнения.

Код на Python

fig = plt.figure(figsize=(15, 4), facecolor='white')

ax_perf = fig.add_subplot(121)
ax_dd = fig.add_subplot(122)
for p in portfolios:
    p['performance'].rename(p['name']).plot(ax=ax_perf, legend=True, title='Performance')
    p['drawdown'].rename(p['name']).plot(ax=ax_dd, legend=True, title='Max drawdown')
    # Вывод доходности и просадки перед графиками
    print(f"{p['name']}: {p['performance'][-1]*100:.2f}% / {p['drawdown'][-1]*100:.2f}%")

# SPY, как бенчмарк
rebalance.loc[(slice(None), ['SPY']), :].set_index('dt', drop=False).performance. 
    rename('SPY').plot(ax=ax_perf, legend=True)
drawdown(rebalance.loc[(slice(None), ['SPY']), :].set_index('dt', drop=False).performance + 1, 
         is_max=True).rename('SPY').plot(ax=ax_dd, legend=True)

ax_perf.axhline(0, color='k', linestyle='--', lw=0.5)
ax_dd.axhline(0, color='k', linestyle='--', lw=0.5)

plt.show()

image

Заключение

Рассмотренный код позволяет подбирать различные структуры портфелей и условия ребалансировок. С его помощью можно быстро проверить, стоит ли, например, держать в портфеле золото (GLD) или развивающиеся рынки (EEM). Попробуйте его сами, добавьте свои условия индикаторов или подберите параметры уже описанных. (Но помните об ошибке выжившего и о том, что подгонка под прошлые данные, может не оправдать ожидания в будущем.) А после этого решите, кому вы доверите свой портфель — Python-у или финкосультантy?

Репозиторий: rebalance.portfolio

Автор: Александр Румянцев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js