English 中文 Deutsch 日本語
preview
Применение модели машинного обучения CatBoost в качестве фильтра для трендовых стратегий

Применение модели машинного обучения CatBoost в качестве фильтра для трендовых стратегий

MetaTrader 5Интеграция |
454 12
Zhuo Kai Chen
Zhuo Kai Chen

Введение

CatBoost – это эффективная модель машинного обучения на основе деревьев, которая специализируется на принятии решений на основе статических признаков. Другие модели на основе деревьев, такие как XGBoost и Random Forest, обладают схожими характеристиками в плане надежности, интерпретируемости и способности работать со сложными паттернами. Эти модели имеют широкий спектр применения: от анализа признаков до управления рисками. 

В данной статье мы пройдемся по процедуре использования обученной модели CatBoost в качестве фильтра для классической трендовой стратегии на основе пересечения скользящих средних. Цель данной статьи заключается в том, чтобы дать представление о процессе разработки стратегии, а также предоставить решения задач, с которыми можно при этом столкнуться. Я представлю свой рабочий процесс по выборке данных из MetaTrader 5, обучению модели машинного обучения на языке Python и обратной интеграции в советники MetaTrader 5. К концу данной статьи мы проверим стратегию посредством статистического тестирования и обсудим планы на будущее, основанные на текущем подходе.


Интуиция

В нашей отрасли, когда речь заходит о разработке стратегий для советников по торговле товарными активами (CTA, Commodity Trading Advisor), правило большого пальца заключается в том, что лучше всего, когда за каждой идеей для стратегии стоит четкое, интуитивное объяснение. Именно в таком ключе люди в первую очередь размышляют об идеях для стратегий, не говоря о том, что это позволяет также избежать переобучения. Данный тезис применим даже при работе с моделями машинного обучения. Мы попытаемся объяснить интуитивную основу предлагаемой идеи.

Почему это может сработать:

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

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


Оптимизация базовой стратегии

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

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

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

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

#include <Trade/Trade.mqh>
//XAU - 1h.
CTrade trade;

input ENUM_TIMEFRAMES TF = PERIOD_CURRENT;
input ENUM_MA_METHOD MaMethod = MODE_SMA;
input ENUM_APPLIED_PRICE MaAppPrice = PRICE_CLOSE;
input int MaPeriodsFast = 15;
input int MaPeriodsSlow = 25;
input int MaPeriods = 200;
input double lott = 0.01;
ulong buypos = 0, sellpos = 0;
input int Magic = 0;
int barsTotal = 0;
int handleMaFast;
int handleMaSlow;
int handleMa;

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
   trade.SetExpertMagicNumber(Magic);
   handleMaFast =iMA(_Symbol,TF,MaPeriodsFast,0,MaMethod,MaAppPrice);
   handleMaSlow =iMA(_Symbol,TF,MaPeriodsSlow,0,MaMethod,MaAppPrice);  
   handleMa = iMA(_Symbol,TF,MaPeriods,0,MaMethod,MaAppPrice); 
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {

  }  

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
  int bars = iBars(_Symbol,PERIOD_CURRENT);
  //Beware, the last element of the buffer list is the most recent data, not [0]
  if (barsTotal!= bars){
     barsTotal = bars;
     double maFast[];
     double maSlow[];
     double ma[];
     CopyBuffer(handleMaFast,BASE_LINE,1,2,maFast);
     CopyBuffer(handleMaSlow,BASE_LINE,1,2,maSlow);
     CopyBuffer(handleMa,0,1,1,ma);
     double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
     double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
     double lastClose = iClose(_Symbol, PERIOD_CURRENT, 1);
     //The order below matters
     if(buypos>0&& lastClose<maSlow[1]) trade.PositionClose(buypos);
     if(sellpos>0 &&lastClose>maSlow[1])trade.PositionClose(sellpos);   
     if (maFast[1]>maSlow[1]&&maFast[0]<maSlow[0]&&buypos ==sellpos)executeBuy(); 
     if(maFast[1]<maSlow[1]&&maFast[0]>maSlow[0]&&sellpos ==buypos) executeSell();
     if(buypos>0&&(!PositionSelectByTicket(buypos)|| PositionGetInteger(POSITION_MAGIC) != Magic)){
      buypos = 0;
      }
     if(sellpos>0&&(!PositionSelectByTicket(sellpos)|| PositionGetInteger(POSITION_MAGIC) != Magic)){
      sellpos = 0;
      }
    }
 }

//+------------------------------------------------------------------+
//| Expert trade transaction handling function                       |
//+------------------------------------------------------------------+
void OnTradeTransaction(const MqlTradeTransaction& trans, const MqlTradeRequest& request, const MqlTradeResult& result) {
    if (trans.type == TRADE_TRANSACTION_ORDER_ADD) {
        COrderInfo order;
        if (order.Select(trans.order)) {
            if (order.Magic() == Magic) {
                if (order.OrderType() == ORDER_TYPE_BUY) {
                    buypos = order.Ticket();
                } else if (order.OrderType() == ORDER_TYPE_SELL) {
                    sellpos = order.Ticket();
                }
            }
        }
    }
}

//+------------------------------------------------------------------+
//| Execute sell trade function                                      |
//+------------------------------------------------------------------+
void executeSell() {      
       double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
       bid = NormalizeDouble(bid,_Digits);
       trade.Sell(lott,_Symbol,bid);  
       sellpos = trade.ResultOrder();  
       }   

//+------------------------------------------------------------------+
//| Execute buy trade function                                       |
//+------------------------------------------------------------------+
void executeBuy() {
       double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
       ask = NormalizeDouble(ask,_Digits);
       trade.Buy(lott,_Symbol,ask);
       buypos = trade.ResultOrder();
}

Для проверки вашей базовой стратегии следует учесть следующее:

  1. Достаточный размер выборки. Частота зависит от вашего таймфрейма и ограничений на сигналы, но в общей сложности я бы предложил от 1000 до 10000 сделок. Каждая сделка – это образец.
  2. Стратегия уже должна показывать некоторую прибыльность, пусть и небольшую. Я бы сказал, что достаточно будет профит фактора от 1 до 1,15. Поскольку тестер стратегий MetaTrader 5 уже учитывает спреды, профит фактор, равный единице, означает, что стратегия уже имеет статистическое преимущество. Если профит фактор превышает 1,15, вероятнее всего, стратегия достаточно хороша и сама по себе, и возможно вам не потребуются дополнительные фильтры для улучшения сложности.
  3. У базовой стратегии не должно быть слишком много параметров. Лучше, когда базовая стратегия простая, поскольку использование модели машинного обучения в качестве фильтра уже добавляет вашей стратегии достаточно сложности. Чем меньше фильтр, тем меньше вероятность переобучения.

Вот что я сделал для оптимизации стратегии:

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

В итоге тестирование показало следующие результаты на часовом таймфрейме XAUUSD за период с 1/1/2004 по 1/11/2024:

настройка

параметры

кривая1

результат1


Выборка данных

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

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

excel-отчет

Затем, чтобы сконвертировать double-массив в формат CSV, мы воспользуемся классом CFileCSV, о котором есть статья.

Мы возьмем за основу скрипт нашей базовой стратегии и выполним следующие шаги:

1. Добавим mqh-файл и создадим объект класса.

#include <FileCSV.mqh>

CFileCSV csvFile;

2. Объявим имя сохраняемого файла и заголовки, среди которых будет "index" и названия всех остальных признаков. Здесь "index" используется только для обновления массива в процессе работы тестера и позже будет отброшен, когда мы перейдем к Python.

string fileName = "ML.csv";
string headers[] = {
    "Index",
    "Accelerator Oscillator", 
    "Average Directional Movement Index", 
    "Average Directional Movement Index by Welles Wilder", 
    "Average True Range", 
    "Bears Power", 
    "Bulls Power", 
    "Commodity Channel Index", 
    "Chaikin Oscillator", 
    "DeMarker", 
    "Force Index", 
    "Gator", 
    "Market Facilitation Index", 
    "Momentum", 
    "Money Flow Index", 
    "Moving Average of Oscillator", 
    "MACD", 
    "Relative Strength Index", 
    "Relative Vigor Index", 
    "Standard Deviation", 
    "Stochastic Oscillator", 
    "Williams' Percent Range", 
    "Variable Index Dynamic Average", 
    "Volume",
    "Hour",
    "Stationary"
};

string data[10000][26];
int indexx = 0;
vector xx;

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

//+------------------------------------------------------------------+
//| Execute get data function                                        |
//+------------------------------------------------------------------+
vector getData(){
//23 oscillators
double ac[];        // Accelerator Oscillator
double adx[];       // Average Directional Movement Index
double wilder[];    // Average Directional Movement Index by Welles Wilder
double atr[];       // Average True Range
double bep[];       // Bears Power
double bup[];       // Bulls Power
double cci[];       // Commodity Channel Index
double ck[];        // Chaikin Oscillator
double dm[];        // DeMarker
double f[];         // Force Index
double g[];         // Gator
double bwmfi[];     // Market Facilitation Index
double m[];         // Momentum
double mfi[];       // Money Flow Index
double oma[];       // Moving Average of Oscillator
double macd[];      // Moving Averages Convergence/Divergence
double rsi[];       // Relative Strength Index
double rvi[];       // Relative Vigor Index
double std[];       // Standard Deviation
double sto[];       // Stochastic Oscillator
double wpr[];       // Williams' Percent Range
double vidya[];     // Variable Index Dynamic Average
double v[];         // Volume

CopyBuffer(handleAc, 0, 1, 1, ac);           // Accelerator Oscillator
CopyBuffer(handleAdx, 0, 1, 1, adx);         // Average Directional Movement Index
CopyBuffer(handleWilder, 0, 1, 1, wilder);   // Average Directional Movement Index by Welles Wilder
CopyBuffer(handleAtr, 0, 1, 1, atr);         // Average True Range
CopyBuffer(handleBep, 0, 1, 1, bep);         // Bears Power
CopyBuffer(handleBup, 0, 1, 1, bup);         // Bulls Power
CopyBuffer(handleCci, 0, 1, 1, cci);         // Commodity Channel Index
CopyBuffer(handleCk, 0, 1, 1, ck);           // Chaikin Oscillator
CopyBuffer(handleDm, 0, 1, 1, dm);           // DeMarker
CopyBuffer(handleF, 0, 1, 1, f);             // Force Index
CopyBuffer(handleG, 0, 1, 1, g);             // Gator
CopyBuffer(handleBwmfi, 0, 1, 1, bwmfi);     // Market Facilitation Index
CopyBuffer(handleM, 0, 1, 1, m);             // Momentum
CopyBuffer(handleMfi, 0, 1, 1, mfi);         // Money Flow Index
CopyBuffer(handleOma, 0, 1, 1, oma);         // Moving Average of Oscillator
CopyBuffer(handleMacd, 0, 1, 1, macd);       // Moving Averages Convergence/Divergence
CopyBuffer(handleRsi, 0, 1, 1, rsi);         // Relative Strength Index
CopyBuffer(handleRvi, 0, 1, 1, rvi);         // Relative Vigor Index
CopyBuffer(handleStd, 0, 1, 1, std);         // Standard Deviation
CopyBuffer(handleSto, 0, 1, 1, sto);         // Stochastic Oscillator
CopyBuffer(handleWpr, 0, 1, 1, wpr);         // Williams' Percent Range
CopyBuffer(handleVidya, 0, 1, 1, vidya);     // Variable Index Dynamic Average
CopyBuffer(handleV, 0, 1, 1, v);             // Volume
//2 means 2 decimal places
data[indexx][0] = IntegerToString(indexx);
data[indexx][1] = DoubleToString(ac[0], 2);       // Accelerator Oscillator
data[indexx][2] = DoubleToString(adx[0], 2);      // Average Directional Movement Index
data[indexx][3] = DoubleToString(wilder[0], 2);   // Average Directional Movement Index by Welles Wilder
data[indexx][4] = DoubleToString(atr[0], 2);      // Average True Range
data[indexx][5] = DoubleToString(bep[0], 2);      // Bears Power
data[indexx][6] = DoubleToString(bup[0], 2);      // Bulls Power
data[indexx][7] = DoubleToString(cci[0], 2);      // Commodity Channel Index
data[indexx][8] = DoubleToString(ck[0], 2);       // Chaikin Oscillator
data[indexx][9] = DoubleToString(dm[0], 2);       // DeMarker
data[indexx][10] = DoubleToString(f[0], 2);       // Force Index
data[indexx][11] = DoubleToString(g[0], 2);       // Gator
data[indexx][12] = DoubleToString(bwmfi[0], 2);   // Market Facilitation Index
data[indexx][13] = DoubleToString(m[0], 2);       // Momentum
data[indexx][14] = DoubleToString(mfi[0], 2);     // Money Flow Index
data[indexx][15] = DoubleToString(oma[0], 2);     // Moving Average of Oscillator
data[indexx][16] = DoubleToString(macd[0], 2);    // Moving Averages Convergence/Divergence
data[indexx][17] = DoubleToString(rsi[0], 2);     // Relative Strength Index
data[indexx][18] = DoubleToString(rvi[0], 2);     // Relative Vigor Index
data[indexx][19] = DoubleToString(std[0], 2);     // Standard Deviation
data[indexx][20] = DoubleToString(sto[0], 2);     // Stochastic Oscillator
data[indexx][21] = DoubleToString(wpr[0], 2);     // Williams' Percent Range
data[indexx][22] = DoubleToString(vidya[0], 2);   // Variable Index Dynamic Average
data[indexx][23] = DoubleToString(v[0], 2);       // Volume

    datetime currentTime = TimeTradeServer(); 
    MqlDateTime timeStruct;
    TimeToStruct(currentTime, timeStruct);
    int currentHour = timeStruct.hour;
data[indexx][24]= IntegerToString(currentHour);
    double close = iClose(_Symbol,PERIOD_CURRENT,1);
    double open = iOpen(_Symbol,PERIOD_CURRENT,1);
    double stationary = MathAbs((close-open)/close)*100;
data[indexx][25] = DoubleToString(stationary,2);
  
   vector features(26);    
   for(int i = 1; i < 26; i++)
    {
      features[i] = StringToDouble(data[indexx][i]);
    }
    //A lot of the times positions may not open due to error, make sure you don't increase index blindly
    if(PositionsTotal()>0) indexx++;
    return features;
}

Заметьте, что мы добавили сюда проверку.

if(PositionsTotal()>0) indexx++;

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

4. Мы сохраняем файл при вызове функции OnDeInit(), что означает завершение теста.

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   if (!SaveData) return;
   if(csvFile.Open(fileName, FILE_WRITE|FILE_ANSI))
     {
      //Write the header
      csvFile.WriteHeader(headers);
      //Write data rows
      csvFile.WriteLine(data);
      //Close the file
      csvFile.Close();
     }
   else
     {
      Print("File opening error!");
     }

  }

Запустите этот советник в тестере стратегий, после чего в директории  /Tester/Agent-sth000 должен появиться ваш csv-файл.


Очистка и корректировка данных

Теперь у нас есть два файла с данными, но остается еще множество нерешенных фундаментальных проблем.

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

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

найти строку

Запомните номер строки и учтите его в следующем коде на Python:

import pandas as pd

# Replace 'your_file.xlsx' with the path to your file
input_file = 'ML2.xlsx'

# Load the Excel file and skip the first {skiprows} rows
df = pd.read_excel(input_file, skiprows=10757)

# Save the extracted content to a CSV file
output_file = 'extracted_content.csv'
df.to_csv(output_file, index=False)

print(f"Content has been saved to {output_file}.")

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

import pandas as pd

# Load the CSV file
file_path = 'extracted_content.csv'  # Update with the correct file path if needed
data = pd.read_csv(file_path)

# Select the 'profit' column (assumed to be 'Unnamed: 10') and filter rows as per your instructions
profit_data = data["Profit"][1:-1] 
profit_data = profit_data[profit_data.index % 2 == 0]  # Filter for rows with odd indices
profit_data = profit_data.reset_index(drop=True)  # Reset index
# Convert to float, then apply the condition to set values to 1 if > 0, otherwise to 0
profit_data = pd.to_numeric(profit_data, errors='coerce').fillna(0)  # Convert to float, replacing NaN with 0
profit_data = profit_data.apply(lambda x: 1 if x > 0 else 0)  # Apply condition

# Save the processed data to a new CSV file with index
output_csv_path = 'processed_bin.csv'
profit_data.to_csv(output_csv_path, index=True, header=['bin'])

print(f"Processed data saved to {output_csv_path}")

Результат должен выглядеть примерно так:


bin
0 1
1 0
2 1
3 0
4 0
5 1

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

2. Данные признаков всегда строковые из-за класса CFileCSV, они склеены в один столбец и лишь разделены запятыми.

Вам поможет следующий код на Python:

import pandas as pd

# Load the CSV file with semicolon separator
file_path = 'ML.csv'
data = pd.read_csv(file_path, sep=';')

# Drop rows with any missing or incomplete values
data.dropna(inplace=True)

# Drop any duplicate rows if present
data.drop_duplicates(inplace=True)

# Convert non-numeric columns to numerical format
for col in data.columns:
    if data[col].dtype == 'object':
        # Convert categorical to numerical using label encoding
        data[col] = data[col].astype('category').cat.codes

# Ensure all remaining columns are numeric and cleanly formatted for CatBoost
data = data.apply(pd.to_numeric, errors='coerce')
data.dropna(inplace=True)  # Drop any rows that might still contain NaNs after conversion

# Save the cleaned data to a new file in CatBoost-friendly format
output_file_path = 'Cleaned.csv'
data.to_csv(output_file_path, index=False)

print(f"Data cleaned and saved to {output_file_path}")

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

import pandas as pd

# Load the two CSV files
file1_path = 'processed_bin.csv'  # Update with the correct file path if needed
file2_path = 'Cleaned.csv'  # Update with the correct file path if needed
data1 = pd.read_csv(file1_path, index_col=0)  # Load first file with index
data2 = pd.read_csv(file2_path, index_col=0)  # Load second file with index

# Merge the two DataFrames on the index
merged_data = pd.merge(data1, data2, left_index=True, right_index=True, how='inner')

# Save the merged data to a new CSV file
output_csv_path = 'merged_data.csv'
merged_data.to_csv(output_csv_path)

print(f"Merged data saved to {output_csv_path}")

Чтобы подтвердить корректность слияния двух файлов с данными, мы можем проверить три CSV-файла, которые мы только что сгенерировали, и посмотреть, одинаковый ли в них конечный индекс. Если да, скорее всего, у нас все в порядке.


Обучение модели

Мы не будем слишком глубоко погружаться в технические объяснения всех аспектов машинного обучения. Тем не менее, я настоятельно рекомендую вам ознакомиться с книгой Advances in Financial Machine Learning (автор Маркос Лопез де Прадо), если вы интересуетесь ML-трейдингом в целом.

Наша цель в данном разделе ясна и понятна.

Во-первых, мы воспользуемся библиотекой pandas для чтения данных после слияния и примем бинарный столбец за ось Y, а другой – за ось X.

data = pd.read_csv("merged_data.csv",index_col=0)
XX = data.drop(columns=['bin'])
yy = data['bin']
y = yy.values
X = XX.values

Затем мы разделим данные 80% на 20% для обучения и тестирования, соответственно.

Затем проведем обучение. Сведения о каждом параметре классификатора задокументированы на сайте CatBoost.

from catboost import CatBoostClassifier
from sklearn.ensemble import BaggingClassifier

# Define the CatBoost model with initial parameters
catboost_clf = CatBoostClassifier(
    class_weights=[10, 1],  #more weights to 1 class cuz there's less correct cases
    iterations=20000,             # Number of trees (similar to n_estimators)
    learning_rate=0.02,          # Learning rate
    depth=5,                    # Depth of each tree
    l2_leaf_reg=5,
    bagging_temperature=1,
    early_stopping_rounds=50,
    loss_function='Logloss',    # Use 'MultiClass' if it's a multi-class problem
    random_seed=RANDOM_STATE,
    verbose=1000,                  # Suppress output (set to a positive number if you want to see training progress)
)

fit = catboost_clf.fit(X_train, y_train)

Сохраняем файл с расширением .cbm.

catboost_clf.save_model('catboost_test.cbm')

На этом, увы, еще не все. MetaTrader 5 поддерживает только модель формата ONNX, поэтому воспользуемся следующим кодом из соответствующей статьи, чтобы преобразовать модель в формат ONNX.

model_onnx = convert_sklearn(
    model,
    "catboost",
    [("input", FloatTensorType([None, X.shape[1]]))],
    target_opset={"": 12, "ai.onnx.ml": 2},
)

# And save.
with open("CatBoost_test.onnx", "wb") as f:
    f.write(model_onnx.SerializeToString())


Статистическое тестирование

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

     if (maFast[1]>maSlow[1]&&maFast[0]<maSlow[0]&&sellpos == buypos){   
        xx= getData();
        prob = cat_boost.predict_proba(xx);
        if (prob[1]<max&&prob[1]>min)executeBuy(); 
     }
     if(maFast[1]<maSlow[1]&&maFast[0]>maSlow[0]&&sellpos == buypos){
        xx= getData();
        prob = cat_boost.predict_proba(xx);
        Print(prob);
        if(prob[1]<max&&prob[1]>min)executeSell();
      }

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

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

Помните, что мы разделили данные в соотношении 8 на 2? Теперь мы проведем тестирование за пределами выборки на данных, не представленных для обучения, то есть примерно за период с 1/1/2021 по 1/11/2024.

Сначала запустим тестирование на данных выборки с порогом вероятности от 0,05, чтобы убедиться, что обучили модель на верных данных. Результат должен быть практически безупречен, как здесь:

кривая теста на данных выборки

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

кривая базового варианта

результат базового варианта

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

Результаты для порога = 0,05:

кривая 0,05

результат 0,05

Результаты для порога = 0,1:

кривая 0,1

результат 0,1

Результаты для порога = 0,2:

кривая 0,2

результат 0,2

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

При пороге 0,2 модель отфильтровывает примерно 70% первоначальных сделок, при этом оставшиеся сделки в совокупности более прибыльны чем первоначальные. Статистический анализ показывает, что в рамках данного диапазона пороговых значений общая доходность положительно коррелирует с пороговым значением. Это говорит о том, что по мере того, как повышается уверенность модели в сделке, растет и общая ее эффективность, что является благоприятным результатом.

Я провел десятикратную перекрестную проверку на Python, чтобы убедиться, что модель работает с одинаковой точностью. 

{'score': array([-0.97148655, -1.25263677, -1.02043177, -1.06770248, -0.97339545, -0.88611439, -0.83877111, -0.95682533, -1.02443847, -1.1385681 ])}

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

Кроме того, при среднем значении логистической функции потерь, равном приблизительно -1, результаты модели можно считать умеренно эффективными.

Чтобы еще больше увеличить точность модели, можно воспользоваться одной из следующих идей:

1. Отбор признаков

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

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

важность признаков

2. Настройка по гиперпараметрам

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

3. Подбор модели

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

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


Заключение

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


Таблица прикрепленных файлов

Имя файла Назначение
 ML-Momentum Data.mq5  Советник для выборки данных по признакам
 ML-Momentum.mq5  Конечный исполняемый советник
 CB2.ipynb Рабочий процесс для обучения и тестирования модели CatBoost 
handleMql5DealReport.py Извлечение полезных строк из отчета о сделках
getBinFromMql5.py Конвертация извлеченного формата в бинарную таблицу
clean_mql5_csv.py Очистка CSV-файла с признаками, извлеченного из Mt5
merge_data2.py Слияние признаков и результатов сделок в единый CSV-файл
OnnxConvert.ipynb Конвертация модели .cbm в формат .onnx
Classic Trend Following.mq5
Советник с базовой стратегией

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/16487

Прикрепленные файлы |
ML-TF-Project.zip (186.72 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (12)
johnboy85
johnboy85 | 7 июн. 2025 в 10:01

Здравствуйте. Я играю с CatBoost и дошел до того, что стратегия, обученная на данных (всех) 2024 года, дает >300% прибыли при бэктесте (в MetaTrader) на 2024 году, но плохо работает на других годах. Есть ли у кого-нибудь подобный опыт? Интуитивно кажется, что это перебор, но даже если я тренируюсь с гораздо меньшим количеством итераций (например, 1k), я получаю тот же результат.

Я обучаюсь с ~40-50 признаками на минутных данных, так что получается что-то около 250 000 строк в год. Размер файла .cbm, как правило, получается в 1000 раз больше количества итераций (например, 1000 итераций = 1 МБ, 10 000 итераций = 10 МБ и так далее). Бэктестирование в Metatrader ограничивает меня примерно 100 000 МБ, после чего бэктестер останавливается. Я могу проводить бэктесты на C++ до произвольно больших размеров, но мои результаты в metatrader и C++ дико отличаются.

Zhuo Kai Chen
Zhuo Kai Chen | 8 июн. 2025 в 10:23
johnboy85 CatBoost и дошел до того, что стратегия, обученная на данных (всех) 2024 года, дает >300% прибыли при бэктесте (в MetaTrader) на 2024 году, но плохо работает на других годах. Есть ли у кого-нибудь подобный опыт? Интуитивно кажется, что это перебор, но даже если я тренируюсь с гораздо меньшим количеством итераций (например, 1k), я получаю тот же результат.

Я обучаюсь с ~40-50 признаками на минутных данных, так что получается что-то около 250 000 строк в год. Размер файла .cbm, как правило, получается в 1000 раз больше количества итераций (например, 1000 итераций = 1 МБ, 10 000 итераций = 10 МБ и так далее). Бэктестирование в Metatrader ограничивает меня примерно 100 000 МБ, после чего бэктестер останавливается. Я могу проводить бэктесты на C++ до произвольно больших размеров, но мои результаты в metatrader и C++ дико отличаются.

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

johnboy85
johnboy85 | 8 июн. 2025 в 11:29

Спасибо, что так быстро ответили на тему, которой уже более 6 месяцев. Здесь есть над чем подумать. Я привыкаю к огромному пространству параметров и пытаюсь найти способы уменьшить перебор.

Еще раз спасибо!

Zhuo Kai Chen
Zhuo Kai Chen | 8 июн. 2025 в 11:40
johnboy85 #:

Спасибо, что так быстро ответили на тему, которой уже более 6 месяцев. Здесь есть над чем подумать. Я привыкаю к огромному пространству параметров и пытаюсь найти способы уменьшить перебор.

Еще раз спасибо!

Удачи вам в ваших исследованиях!

Maxim Dmitrievsky
Maxim Dmitrievsky | 4 июл. 2025 в 11:19
Хайп на МО и качество материала просто удручает.
Нейросети в трейдинге: Вероятностное прогнозирование временных рядов (K2VAE) Нейросети в трейдинге: Вероятностное прогнозирование временных рядов (K2VAE)
Предлагаем ознакомиться с оригинальной реализацией фреймворка K²VAE — гибкой модели, способной линейно аппроксимировать сложную динамику в латентном пространстве. В статье показано, как реализовать ключевые компоненты на языке MQL5, включая параметризованные матрицы и их управление вне стандартных нейросетевых слоёв. Материал будет полезен тем, кто ищет практический подход к созданию интерпретируемых моделей временных рядов.
Анализ временных разрывов цен в MQL5 (Часть II): Создаем тепловую карту распределения ликвидности во времени Анализ временных разрывов цен в MQL5 (Часть II): Создаем тепловую карту распределения ликвидности во времени
Подробное руководство по созданию индикатора тепловой карты для MetaTrader 5, который визуализирует временное распределение цены в виде тепловой карты. Статья раскрывает математическую основу анализа временной плотности, где каждый ценовой уровень окрашивается от красного (минимальное время пребывания) до синего (максимальное время пребывания).
Разработка советника для анализа новостных событий о пробоях на основе календаря на MQL5 Разработка советника для анализа новостных событий о пробоях на основе календаря на MQL5
Волатильность, как правило, достигает пика во время важных новостных событий, создавая значительные возможности для пробоя. В настоящей статье мы расскажем о процессе реализации основанной на календаре стратегии прорыва. Мы рассмотрим все, начиная с создания класса для интерпретации и хранения календарных данных, разработки реалистичных бэк-тестов на основе этих данных и, наконец, реализации кода исполнения для реальной торговли.
Возможности Мастера MQL5, которые вам нужно знать (Часть 47): Обучение с подкреплением (алгоритм временных различий) Возможности Мастера MQL5, которые вам нужно знать (Часть 47): Обучение с подкреплением (алгоритм временных различий)
Temporal Difference (TD, временные различия) — еще один алгоритм обучения с подкреплением, который обновляет Q-значения на основе разницы между прогнозируемыми и фактическими вознаграждениями во время обучения агента. Особое внимание уделяется обновлению Q-значений без учета их пар "состояние-действие" (state-action). Как обычно, мы рассмотрим, как этот алгоритм можно применить в советнике, собранном с помощью Мастера.