Уважаемые участники сообщества Хабр, добрый день!

Представлюсь, я Алексей Волков, руководитель нескольких IT проектов. И сейчас в данной коротенькой статье будет вам представлена проблематика тестирования торговых стратегий с приближением к реальности и мои пути решения. Также поделюсь личным опытом в выявлении перспективности какой-либо торговой стратегии. (это не машинное обучение, не нейросети, не гадание и не астрология).

Главным нашим инструментом будет Python и Jupiter Notebook. Этого достаточно. Все по простому.

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

Итак, начнем.

Когда приходится программировать в три руки
Когда приходится программировать в три руки
Описываю проблематику

Сегодня, как всем известно, популярность алготрейдинга очень высока. Возможно кто-то уже нашел свой "золотой грааль". Но мы здесь будем в поисках...

Как это происходит: смотрите обзор общих стратегий, модифицируете ее, проводите риск-менеджмент, выходите на рынок с новым алгоритмом и .... сливаете депо. Затем алгоритм "пожертвований" повторяется.

Почему делается идет упор на важность изучения ваших торговых стратегий? А все потому что из-за изменчивости конъюнктуры рынка ваша стратегия может просто перестать работать как прежде. Поэтому с изменением рынка, нужно подстраиваться подстраиваться под него = менять свое поведение на нем.

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

Перечисляю ошибки с которыми столкнулся автор

0. Быть полностью уверенным что ваша стратегия точно работает.
Излишний оптимизм не дает холоднокровному разуму
применить хотя бы методы статистического анализа.

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

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

3. Отход от модели предполагаемой перспективной стратегии.
Как правило в момент уже тестирования появляются мысли как можно "улучшить" стратегию, добавить что-то новое, поэксперементировать с дополнительными паттернами, желая забрать все движения с рынка.

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

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

Соберем базовый симулятор, с заранее обозначенными условиями.

Учтем следующие условия:

1. Возьмем за гипотезу, что рынок это случайность (где все игроки с равноценными кошельками)
2. Используем минутный таймфрейм, чтобы его преобразовать в другой тайм для торговой стратегии, а симмулятор будет двигаться с шагом в 1 минуту.
3. Введем понятие максимального и минимального наблюдаемого профита. Пригодится для измерения потенциала стратегии. (оценка глубины роста и просадки)
4. Добавим значение стоплосса (либо процент либо конкретная цена)
5. Вводим фактор проскальзывания цены в 0.1%
6. Учитываем комиссию на бирже с умножительным коэффициентом (я использую запас 100% комиссии)
7. Нам понадобится также записывать все сделанные сделки симулятором в отдельный журнал. Очень пригодится, для того чтобы отдельные сделки можно было построить и вручную проверить работу симулятора, а также изучить статистические величины.
8. Введем генератор стратегий на основе регулируемого количества полос боллинджера с генерацией случайных параметров.
9. Добавим случайный перебор take profit и stop loss

Затем построим симулятор с генератором случайной стратегии на основе линий боллинджера. Кстати, вы можете использовать свою стратегию, или/и добавить другие составляющие индикаторов, добавить дополнительные иные аспекты поведения алгоритма на рынке (час торгов, звезды, число упоминаний в новостях какого то слова, и т.д.). Но суть хочу показать, что можно легко строить очень сложные математические модели поведения и тут же тестировать их с перебором огромного количества параметров. Абсурдность в этом вся в том, сложность системы требует больше времени и исторического контента.

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

Первые две серии итераций будут по коротким дистанциям. По одной неделе исторических данных. Затем эти интервалы будут увеличены с ростом фиксированных параметров.

Код симулятора с генератором стратегий
# Импорты
import warnings
warnings.filterwarnings('ignore')
import pandas as pd
import os
import numpy as np
import matplotlib.pyplot as plt
import random
# Загружаем датасет предварительно подгруженный с биржи
symbols_data_close = {}

path = 'history4/'
for symbol in os.listdir(path)[0:1]:
    data = pd.read_csv(f'{path}{symbol}')
    symbols_data_close.update({symbol: data['4'].values})
# Берем фрагмет датасета и выведем что он представляет из себя
symbols = list(symbols_data_close.keys())
closdata = symbols_data_close[symbols[0]][:10000]
plt.figure(figsize=(20,10))
plt.plot(closdata)
Визуализация графика датасета SNXUSDT, промежуток 1 неделя
Визуализация графика датасета SNXUSDT, промежуток 1 неделя
# Функция генерации слчайных параметров
def get_generate_params(count=None):

    params = {'ma_window': [], 'std_window': [], 
              'std_rate_up': [], 'std_rate_dw': [], 
              'take_profit': [], 'stop_profit': []}
    
    params['ma_window'] = np.asarray(np.random.random(count) * 1000 + 1, 'int32')
    params['std_window'] = np.asarray(np.random.random(count) * 1000 + 1, 'int32')
    params['std_rate_up'] = np.random.random(count) * 8 - 4
    params['std_rate_dw'] = np.random.random(count) * 8 - 4
    
    params['take_profit'] = np.random.random(count) * 10
    params['stop_profit'] = -np.random.random(count) * 10
    
    return params
# Функция случайной стратегии по сгенерированным случайным параметрам
def get_my_strategy(p=None, cdata=None, count=None):
    # p -> params
    
    bb_up = []
    bb_dw = []
    
    for i in np.arange(count):
        ma = pd.Series(cdata).rolling(window=p['ma_window'][i]).mean().values
        std = pd.Series(cdata).rolling(window=p['std_window'][i]).std().values
        
        bb_up.append((ma + std * p['std_rate_up'][i])[1000:])
        bb_dw.append((ma - std * p['std_rate_dw'][i])[1000:])
    
    return {'bb_up': bb_up, 'bb_dw': bb_dw, 'closdata': cdata[1000:]}
# Функция поведения симулятора
def get_rules_result(p=None, strategy=None, operation=None,
                     profit=None, max_profit=None, min_profit=None, 
                     y_index=None, y_index_last=None, x_index=None):
    
    signal = {'in_long': False, 'end_long': False}
    
    if operation is None:
        for y_line in strategy['bb_up']:
            if y_index_last < y_line[x_index] < y_index:
                signal['in_long'] = True
                return signal
    
    
    if operation == 'long':
        
        for ind, (y_line_up, y_line_dw) in enumerate(zip(strategy['bb_up'], strategy['bb_dw'])):
            
            rule_1 = y_index > y_line_up[x_index] and profit > p['take_profit'][ind] and profit > 0
            rule_2 = y_index > y_line_up[x_index] and max_profit - profit > p['take_profit'][ind] and profit > 0
            rule_3 = y_index > y_line_up[x_index] and max_profit < -p['take_profit'][ind] and profit > 0
            
            rule_4 = y_index < y_line_dw[x_index] and profit < p['stop_profit'][ind]
            
            if rule_1 or rule_2 or rule_3 or rule_4:
                signal['end_long'] = True
                return signal 
            
    return signal

Выведем как выглядит наша одна случайная стратегия (график цены и линии боллинджера)

countData = 15 # количество линий боллинджера, которые к учиту в стратегии

my_strategy = get_my_strategy(
    p=get_generate_params(count=countData), 
    cdata=closdata, 
    count=countData)

plt.figure(figsize=(20,10))
plt.plot(my_strategy['closdata'], color='black')

for n in np.arange(countData):
    plt.plot(my_strategy['bb_up'][n])
    plt.plot(my_strategy['bb_dw'][n])
Случайно сгенерированные линии боллинджера на ценовом графике
Случайно сгенерированные линии боллинджера на ценовом графике

Основной движок симулятора. Здесь используем только сделки в лонг.

countData = 10 

leverage = 10 # плечо
params_history = []

for cnt in np.arange(9999999):

    # генерируем случайные параметры
    generate_params = get_generate_params(count=countData)

    # генерируем стратерию
    my_strategy = get_my_strategy(p=generate_params, cdata=closdata, count=countData)

    depo = [1000]

    operation=None
    profit=0
    max_profit=0
    min_profit=0

    # по шагам идем по ценовому графику
    for x, (closedata_last, closedata) in enumerate(zip(my_strategy['closdata'][:-1], my_strategy['closdata'][1:])):
        x_index = x + 1

        # вход в сделку лонг
        if rules_result['in_long']:
            rules_result['in_long'] = False

            operation = 'long'

            size_buy = depo[-1]
            price_buy = closedata

        # оценка профита, максимальной просадки и роста
        if operation is not None:

            profit = (closedata / price_buy - 1) * 100 - 0.3

            if profit > max_profit: max_profit = profit
            if profit < min_profit: min_profit = profit

        # выясняем наше поведение на каждом шаге
        rules_result = get_rules_result(p=generate_params, strategy=my_strategy, operation=operation,
                         profit=profit, max_profit=max_profit, min_profit=min_profit, 
                         y_index=closedata, y_index_last=closedata_last, x_index=x_index)

        depo.append(depo[-1])

        # выход из сделки по сигналу
        if rules_result['end_long']:
            rules_result['end_long'] = False

            depo[-1] = depo[-1] + size_buy * profit / 100 * leverage

            operation=None
            profit=0
            max_profit=0
            min_profit=0
            
    depo[-1] = depo[-1] + size_buy * profit / 100 * leverage

    # сохраняем результаты на каждой итерации случайной генерации параметров
    params_history.append({'generate_params': generate_params, 'profit': depo[-1]})
    if depo[-1] > 1000: print(round(depo[-1], 1), end=' ')
1276.3 1681.9 1072.2 1185.5 1013.5 1419.1 1206.4 1842.5 1260.9 1222.8
1016.5 1084.9 1478.2 1384.9 1022.9 1016.2 1490.1 2235.1 1100.1 1164.0
1076.1 1079.4 1070.4 1245.3 1007.7 1291.8 1101.4 1003.6 1002.8 1125.8
1010.9 1115.4 1197.5 1353.4 1037.5 1179.6 1204.8 1070.2 1334.8 1138.2
1301.0 1490.7 1036.8 1030.5 1400.4 1150.7 1146.6 1218.0 1362.6 1098.4
1695.6 1006.0 1149.5 1126.4 1048.8 1086.0 1137.5 1577.9 1266.4 1246.5
1005.0 1015.9 1031.1 1087.9 1311.0 1389.6 1019.0 1049.1 1210.9 1146.3
1127.8 1366.6 1360.8 1465.9 1101.6 1076.6 1060.4 1044.1 1281.3 1073.3
1047.7 1041.7 1322.0 1012.2 1301.1 1568.1 1056.8 1024.1 1485.3 1106.6
1008.8 1186.8 1007.3 1007.9 1100.6 1834.9 1142.1 1806.9 1352.7 1052.4
1284.5 1045.4 1017.0 1083.4 1229.2 1214.1 1012.2 1398.4 1000.3 1152.4
1003.1 2073.1 1301.3 2235.9 1223.4 1091.3 1011.6 1163.9 1006.3 1084.5
1514.4 1125.3 1334.6 1038.8 1375.3 1136.9 1211.9 1194.3 1746.2 1131.6
1029.2 1132.5 1311.5 1018.0 1355.9 1264.4 1069.1 .............

Генерируем как можно больше результатов. Оставляем генерацию на 96 часов.

# Наконец, выводим результаты
result = pd.DataFrame(params_history).sort_values(by='profit')
print('количество итераций', result.shape[0])
result = result[result['profit'] > 2500]
result

количество итераций 4326132

generate_params

profit

1355114

{'ma_window': [934, 829, 385, 291, 901, 489, 8...

2509.731530

2748242

{'ma_window': [583, 786, 272, 374, 993, 322, 4...

2515.246686

1168352

{'ma_window': [444, 256, 466, 165, 640, 298, 3...

2528.583217

1056488

{'ma_window': [173, 174, 148, 888, 122, 361, 7...

2570.601539

4090771

{'ma_window': [690, 930, 603, 720, 432, 411, 7...

2600.638982

3174694

{'ma_window': [885, 970, 293, 296, 358, 586, 8...

2604.829127

326805

{'ma_window': [873, 6, 478, 957, 263, 717, 682...

2605.651078

301489

{'ma_window': [372, 474, 59, 65, 545, 820, 969...

2665.182816

3733749

{'ma_window': [176, 31, 183, 29, 92, 9, 552, 5...

2688.979172

863146

{'ma_window': [634, 463, 554, 237, 36, 637, 74...

2707.697915

4280661

{'ma_window': [764, 908, 824, 165, 911, 682, 4...

2749.313573

817925

{'ma_window': [13, 252, 12, 571, 959, 376, 12,...

3259.250233

989229

{'ma_window': [401, 259, 980, 609, 254, 927, 7...

3725.547182

Самый максимальный профит при случайном подборе параметров получился
(3725 - 1000) / 1000 * 100% = 272,5%

Итак. С помощью метода перебора параметров, можно найти интересные результаты, экспериментируя применяя возможность перебора параметров модели.

В принципе на этом можно остановиться. Но нужно продолжить, чтобы поставить стратегию в неловкое положение. Сделаем следующее: для фиксации извлечем попарные параметры, найдем самые "популярные" параметры, и построим график изменчивости.

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

raw_counts = pd.Series(raw_col[0]).value_counts()
raw_popul = raw_counts[raw_counts > 5].index.values

col_counts = pd.Series(raw_col[1]).value_counts()
col_popul = raw_counts[raw_counts > 5].index.values

popul_params = np.unique(np.concatenate([raw_popul, col_popul]))
popul_params

array([14, 41, 42, 44, 45, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59])

col_params = table_populParameters.columns

fig, axs = plt.subplots(nrows=4, ncols=4, figsize=(16,12))

for n, ax in enumerate(axs.flatten()):
    ax.plot(table_populParameters[col_params[n]].rolling(window=5000).mean())

    ax.set_title(f'# param: {col_params[n]}')
    ax.set_yticks(())
    ax.set_xticks(())


plt.suptitle('Зависимость роста профита (ось оу) от значения параметра (ось ох)', y = 0.95)
plt.subplots_adjust(wspace=0.05)
plt.show()
Изменение значений выбранных параметров с ростом профита
Изменение значений выбранных параметров с ростом профита

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

dict_selectParams = {}
for _param in col_params:
    dict_selectParams.update(
        {_param: table_populParameters[_param].rolling(window=5000).mean().values[-10000:]})
df_selectParams = pd.DataFrame(dict_selectParams)

df_selectParams = pd.DataFrame(dict_selectParams)
df_selectParams.loc['mean'] = df_selectParams.mean()

df_selectParams
Таблица выбранных параметров
Таблица выбранных параметров

Затем добавим функцию с фиксированными нашими параметрами. Сделаем второй прогон на 96 часов.

# Функция генерации слчайных параметров с фиксацией параметров
def get_generate_params_withFix(count=None):

    params = {'ma_window': [], 'std_window': [], 
              'std_rate_up': [], 'std_rate_dw': [], 
              'take_profit': [], 'stop_profit': []}
    
    #0-9
    params['ma_window'] = np.asarray(np.random.random(count) * 1000 + 1, 'int32')
    
    #10-19
    params['std_window'] = np.asarray(np.random.random(count) * 1000 + 1, 'int32')
    params['std_window'][4] = 526
    
    #20-29
    params['std_rate_up'] = np.random.random(count) * 8 - 4
    
    #30-39
    params['std_rate_dw'] = np.random.random(count) * 8 - 4
    
    #40-49
    params['take_profit'] = np.random.random(count) * 10
    params['take_profit'][[1, 2, 4, 5, 9]] = 5.6
    
    #50-59
    params['stop_profit'] = -np.random.random(count) * 10
    params['stop_profit'][[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]] = -5.9
    
    return params

#................................................
  
countData = 10

leverage = 10
params_history = []

for cnt in np.arange(9999999):
#     generate_params = get_generate_params(count=countData)
    generate_params = get_generate_params_withFix(count=countData)

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

Визуализация графика датасета BNBUSDT, промежуток 1 неделя
Визуализация графика датасета BNBUSDT, промежуток 1 неделя

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

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

Комментарии (13)


  1. lair
    19.12.2023 14:39

    Я не очень понимаю, как можно иметь константное время при переборе параметров? Оно же как минимум должно зависеть от числа параметров и от множества перебора, нет?


    1. DjMpegAlex Автор
      19.12.2023 14:39

      О применении метода отжига во второй части


      1. lair
        19.12.2023 14:39

        То есть заголовок нас обманывает?..


        1. DjMpegAlex Автор
          19.12.2023 14:39

          можно написать очень длинную статью, но читать мало кто будет. поэтому частями выкладки


          1. lair
            19.12.2023 14:39

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


            1. DjMpegAlex Автор
              19.12.2023 14:39

              спасибо. название к статье поправил. теперь будет честно


    1. DjMpegAlex Автор
      19.12.2023 14:39

      Итак, из-за необходимости очень длительного вычисления комбинации параметров для получения наилучшего результата здесь как раз и используется случайный перебор параметров. Хотя вероятность найти лучший вариант снижается с увеличением количества параметров. Но цель найти приемлимый допустимый вариант. Зафиксировав ключевые парамеры повторяете процедуру подбора параметров. Если вы зафиксировали параметры которые лучше всего приближают вас к результату, то количество перебираемых параметров становится меньше. Соответственно зачем перебирать все подряд, если можно провести "эксперемент" ограниченное количество раз и выявить значимые параметры, сокращая на каждом этапе перебираемые параметры (или ограничив область поиска у выбранных параметров). В этом и константное время перебора. Алгоритм сильно упрощается. Например пять итераций сокращения перебираемых параметров с фиксацией.

      К заголовку статьи прилагается и сама статья


      1. lair
        19.12.2023 14:39

        Если мы "повторяем процедуру подбора", то время должно быть O(n), где n - число повторов. А если внутри подбора больше одного параметра, то домножаем на число параметров. И это при условии, что у вас время перебора не зависит от диапазона значений, во что я тоже слабо верю.

        Как у вас константа-то получается?


        1. DjMpegAlex Автор
          19.12.2023 14:39

          если параметр меняется в диапазоне (1, 10) и с шагом 1, то количество возможных принимаемых значений параметр занимает восемь значений ,то время перебора сильно зависит от количества возможных принимаемых значений. в статье , если вы на это обратите внимание, я использую контроль над количеством возможных принимаемых значений параметра.


          1. lair
            19.12.2023 14:39

            время перебора сильно зависит от количества возможных принимаемых значений

            Это значит, что оно не константно.


            1. DjMpegAlex Автор
              19.12.2023 14:39

              признателен вам за ваше неравнодушие! благодарю, очень полезная обратная связь.


  1. OlegUV
    19.12.2023 14:39

    gambling meets python


  1. akakoychenko
    19.12.2023 14:39

    ох... 10 лет назад занимался подобным (и даже вещами на пару порядков продвинутее, но все равно очень рад, что это в прошлом)

    во-первых, что мой личный вывод, что серьезных алготрейдеров с командами разрабов, что стратегии на основе анализа одного числового ряда, мягко говоря, хрень полная. Могут еще как-то работать либо вещи, основанные на микроструктуре рынка (стакан, HFT, код на FPGA), на предоставлении ликвидности, на технологическом превосходстве, на анализе дополнительной информации (новости читать нейросетью), но вот эта магия на свечах сделала богатыми только продавцов лопат. К слову, продавцы лопат неплохо постарались, - сейчас куча библиотек (в том числе, бесплатных), которые любой индикатор вроде MACD или линий Боллинжера рассчитают из коробки без необходимости программировать их с 0 через pandas и numpy

    во-вторых, если уж так хочется в этом участвовать, то инструменты давно придуманы и запрограммированы. 10 лет назад была удивительная прожка Amibroker, имеющая свой векторный язык, интерпретирующийся написанным на С++ движком, что давало поистине реактивную скорость. И там были из коробки реализованы очень неплохие алгоритмы оптимизации, позволяющие реально сократить время оптимизации на порядки без потери точности, например, CMA-ES. Конкретно он настолько хорошо работал, что я потом кажется, запрограммировал его сам на C#, чтобы в свой самопичный бектестер залить.

    ну и, последнее, - оптимизировать по профиту, не имея защиты от дурака в виде статистических тестов стат значимости гипотез - очень плохая идея. Втупую оптимизировать по профиту можно, разве что, HFT с 10000+ сделок за один прогон бектеста. Во всех остальных случаях это будет просто подгонка под историю. Недаром люди придумали всякие штуки вроде Sharpe ratio. Скачайте тот же Amibroker ради любопытства, посмотрите, какие там, помимо профита, цели оптимизации из коробки дает выбрать, почитайте их формулы на википедии, задайтесь вопросом, а зачем это придумано?