Комбинаторно-симметричная перекрестная проверка в MQL5
Введение
Иногда при создании автоматизированной стратегии мы начинаем с описания правил, основанных на произвольных индикаторах, которые необходимо каким-то образом уточнить. Этот процесс включает проведение нескольких тестов с разными значениями параметров выбранных индикаторов. Поступая таким образом, мы можем найти значения индикатора, которые максимизируют прибыль или любой другой показатель, который нас интересует. Проблема здесь заключается в том, что мы вносим определенную долю оптимистической предвзятости из-за преобладающего шума в финансовых временных рядах. Это явление называется подгонкой, или переобучением (overfitting).
Хотя подгонки нельзя избежать, степень ее проявления может варьироваться от одной стратегии к другой. Поэтому было бы полезно иметь возможность определить ее степень. Комбинаторно-симметричная перекрестная проверка (Combinatorially Symmetrical Cross Validation, CSCV) — это метод, представленный в научной статье "The Probability of Backtest Overfitting" (вероятность подгонки при тестировании на истории) Дэвида Бэйли (David H. Bailey) и др. Проверку можно использовать для оценки степени подгонки при оптимизации параметров стратегии.
В этой статье мы продемонстрируем реализацию CSCV в MQL5 и на примере покажем, как ее можно применить к советнику.
Метод CSCV
В этом разделе мы шаг за шагом опишем метод CSCV, начиная с предварительных аспектов, касающихся данных, которые необходимо собрать в соответствии с выбранными критериями эффективности.
Метод CSCV может применяться в различных областях помимо разработки и анализа стратегии, но в этой статье мы придерживаемся контекста оптимизации стратегии. То есть у нас есть некая стратегия, определяемая набором параметров, которые необходимо точно настроить путем запуска многочисленных тестов с различными конфигурациями параметров.
Прежде чем приступить к каким-либо расчетам, нам сначала нужно решить, какие критерии эффективности мы будем использовать для оценки стратегии. Метод CSCV является гибким, поскольку можно использовать любые показатели производительности, начиная простой прибылью и заканчивая показателями на основе коэффициентов.
Выбранные критерии производительности также будут определять базовые данные, которые будут использоваться в расчетах. Это необработанные детальные данные, которые будут собираться во время всех тестовых прогонов. Например, если мы решим использовать коэффициент Шарпа в качестве показателя производительности, нам нужно будет получать значения доходности по барам при каждом запуске теста. Если бы мы использовали простую прибыль, нам понадобилась бы прибыль или убыток по барам. Необходимо убедиться, что объем данных, собранных для каждого запуска, является постоянным. Таким образом, мы обеспечиваем наличие меры для каждой соответствующей точки данных при всех тестовых запусках.
- Первый шаг начинается со сбора данных во время оптимизации, когда проверяются различные варианты параметров.
- После завершения оптимизации мы объединяем все данные, собранные в ходе тестовых прогонов, в матрицу. Каждая строка этой матрицы будет содержать все значения производительности по барам, которые будут использоваться для расчета некоторых показателей эффективности торговли для соответствующего тестового запуска.
- Матрица будет иметь столько строк, сколько опробованных комбинаций параметров, а количество столбцов будет равно числу столбцов, составляющих весь период тестирования. Эти столбцы затем делятся на произвольное четное количество наборов. Скажем, N наборов.
- Эти наборы представляют собой подматрицы, которые будут использоваться для формирования комбинаций групп размера N/2. Создаются в общей сложности N комбинаций, взятых N/2 за раз, то есть N C n/2 . Из каждой из этих комбинаций мы создаем набор в выборке (In-Sample-Set, ISS), объединяя подматрицы N/2, а также соответствующий набор вне выборки (Out-Of-Sample-Set, OOSS) из оставшихся подматриц, не включенных в ISS.
- Для каждой строки матриц ISS и OOSS мы рассчитываем соответствующую метрику производительности, а также обращаем внимание на строку в матрице ISS с лучшими показателями. Это и есть оптимальная конфигурация параметров. Соответствующая строка в матрице OOSS используется для вычисления относительного ранга путем подсчета количества испытаний параметров за пределами выборки с более низкой производительностью по сравнению с той, которая достигается с использованием оптимальной конфигурации параметров, и представления этого количества как доли от всех протестированных наборов параметров.
- Проходя все комбинации, мы накапливаем количество значений относительного ранга, меньшее или равное 0,5. Это количество конфигураций параметров, выходящих за пределы выборки, производительность которых ниже той, которая наблюдается при использовании оптимального набора параметров. После обработки всех комбинаций это число представляется как доля всех комбинаций + 1, представляя вероятность подгонки тестирования на истории (Probability of Backtest Overfitting, PBO).
Ниже приведена визуализация только что описанных шагов при N = 4.
В следующем разделе мы рассмотрим, как мы можем реализовать только что описанные шаги в коде. Мы рассмотрим в первую очередь основной метод CSCV, а код, связанный со сбором данных, оставим для примера, который будет продемонстрирован в конце статьи.
Реализация CSCV в MQL5
Класс Ccsvc, содержащийся в CSCV.mqh, инкапсулирует алгоритм CSCV. CSCV.mqh начинается с включения подфункций стандартной библиотеки MQL5 Mathematics.
//+------------------------------------------------------------------+ //| CSCV.mqh | //| Copyright 2023, MetaQuotes Ltd. | //| https://www.mql5.com | //+------------------------------------------------------------------+ #property copyright "Copyright 2023, MetaQuotes Ltd." #property link "https://www.mql5.com" #include <Math\Stat\Math.mqh>
Указатель функции Criterion определяет тип функции для расчета производительности с использованием массива как входного параметра.
#include <Math\Stat\Math.mqh> typedef double (*Criterion)(const double &data[]); // function pointer for performance criterion
У Ccscv есть только один метод, с которым пользователям необходимо ознакомиться. Его можно вызвать после инициализации экземпляра класса. Этот метод CalculateProbabilty() возвращает значение PBO в случае успеха. Если обнаружена ошибка, метод возвращает -1. Ниже приводится описание его входных параметров:
//+------------------------------------------------------------------+ //| combinatorially symmetric cross validation class | //+------------------------------------------------------------------+ class Cscv { ulong m_perfmeasures; //granular performance measures ulong m_trials; //number of parameter trials ulong m_combinations; //number of combinations ulong m_indices[], //array tracks combinations m_lengths[], //points to number measures for each combination m_flags []; //tracks processing of combinations double m_data [], //intermediary holding performance measures for current trial is_perf [], //in sample performance data oos_perf []; //out of sample performance data public: Cscv(void); //constructor ~Cscv(void); //destructor double CalculateProbability(const ulong blocks, const matrix &in_data,const Criterion criterion, const bool maximize_criterion); };
- Первый входной параметр — blocks. Он соответствует количеству наборов (N наборов), на которые будут разбиты столбцы матрицы.
- in_data — это матрица с количеством строк, равным общему числу вариантов параметров, опробованных в ходе оптимизации, и количеством столбцов, равным количеству столбцов, составляющих всю историю, выбранную для оптимизации.
- criterion — это указатель на функцию, которая будет использоваться для расчета выбранного показателя производительности. Функция должна возвращать значение типа double и принимать в качестве входных данных массив типа double.
- maximize_criterion связан с criterion тем, что позволяет указать, определяется ли лучший из выбранных показателей производительности максимальным или минимальным значением. Например, если использовать просадку в качестве критерия производительности, лучше использовать наименьшее значение, поэтому maximize_criterion должно быть равен false.
double Cscv::CalculateProbability(const ulong blocks, const matrix &in_data,const Criterion criterion, const bool maximize_criterion) { //---get characteristics of matrix m_perfmeasures = in_data.Cols(); m_trials = in_data.Rows(); m_combinations=blocks/2*2; //---check inputs if(m_combinations<4) m_combinations = 4; //---memory allocation if(ArrayResize(m_indices,int(m_combinations))< int(m_combinations)|| ArrayResize(m_lengths,int(m_combinations))< int(m_combinations)|| ArrayResize(m_flags,int(m_combinations))<int(m_combinations) || ArrayResize(m_data,int(m_perfmeasures))<int(m_perfmeasures) || ArrayResize(is_perf,int(m_trials))<int(m_trials) || ArrayResize(oos_perf,int(m_trials))<int(m_trials)) { Print("Memory allocation error ", GetLastError()); return -1.0; } //---
В ComputeProbability мы начинаем с получения количества столбцов и строк матрицы in_data, а также проверки blocks, чтобы убедиться, что это четное число. Получение размеров входной матрицы необходимо для определения размера внутренних буферов экземпляра.
int is_best_index ; //row index of oos_best parameter combination double oos_best, rel_rank ; //oos_best performance and relative rank values //--- ulong istart = 0 ; for(ulong i=0 ; i<m_combinations ; i++) { m_indices[i] = istart ; // Block starts here m_lengths[i] = (m_perfmeasures - istart) / (m_combinations-i) ; // It contains this many cases istart += m_lengths[i] ; // Next block } //--- ulong num_less =0; // Will count the number of time OOS of oos_best <= median OOS, for prob for(ulong i=0; i<m_combinations; i++) { if(i<m_combinations/2) // Identify the IS set m_flags[i]=1; else m_flags[i]=0; // corresponding OOS set } //---
Как только память успешно выделена для внутренних буферов, мы начинаем готовиться к разбиению столбцов в соответствии с m_combinations. Массив m_indices заполняется индексами начальных столбцов для определенного раздела, а m_lengths будет содержать соответствующее количество столбцов, содержащихся в каждом из них. num_less поддерживает подсчет количества раз, когда производительность лучшего теста в выборке меньше, чем производительность остальных тестов за пределами выборки. m_flags — это целочисленный массив, значения которого могут содержать либо 1, либо 0. Это помогает идентифицировать подмножества, обозначенные как входящие и невходящие в состав выборки, при переборе всех возможных комбинаций.
ulong ncombo; for(ncombo=0; ; ncombo++) { //--- in sample performance calculated in this loop for(ulong isys=0; isys<m_trials; isys++) { int n=0; for(ulong ic=0; ic<m_combinations; ic++) { if(m_flags[ic]) { for(ulong i=m_indices[ic]; i<m_indices[ic]+m_lengths[ic]; i++) m_data[n++] = in_data.Flat(isys*m_perfmeasures+i); } } is_perf[isys]=criterion(m_data); } //--- out of sample performance calculated here for(ulong isys=0; isys<m_trials; isys++) { int n=0; for(ulong ic=0; ic<m_combinations; ic++) { if(!m_flags[ic]) { for(ulong i=m_indices[ic]; i<m_indices[ic]+m_lengths[ic]; i++) m_data[n++] = in_data.Flat(isys*m_perfmeasures+i); } } oos_perf[isys]=criterion(m_data); }
В этот момент начинается основной цикл, который перебирает все комбинации наборов в выборке и вне выборки. Два внутренних цикла используются для расчета смоделированной производительности в выборке и вне выборки путем вызова функции criterion и сохранения этого значения в массивах is_perf и oos_perf соответственно.
//--- get the oos_best performing in sample index is_best_index = maximize_criterion?ArrayMaximum(is_perf):ArrayMinimum(is_perf); //--- corresponding oos performance oos_best = oos_perf[is_best_index];
Индекс наилучшего значения производительности в массиве is_perf рассчитывается в соответствии с maximize_criterion. Соответствующее значение производительности за пределами выборки сохраняется в переменной oos_best.
//--- count oos results less than oos_best int count=0; for(ulong isys=0; isys<m_trials; isys++) { if(isys == ulong(is_best_index) || (maximize_criterion && oos_best>=oos_perf[isys]) || (!maximize_criterion && oos_best<=oos_perf[isys])) ++count; }
Пройдемся по массиву oos_perf и подсчитаем, сколько раз значение oos_best было равным или лучше.
//--- calculate the relative rank rel_rank = double (count)/double (m_trials+1); //--- cumulate num_less if(rel_rank<=0.5) ++num_less;
Подсчет используется для расчета относительного ранга. Наконец, num_less суммируется, если вычисленный относительный ранг меньше 0,5.
//---move calculation on to new combination updating flags array along the way int n=0; ulong iradix; for(iradix=0; iradix<m_combinations-1; iradix++) { if(m_flags[iradix]==1) { ++n; if(m_flags[iradix+1]==0) { m_flags[iradix]=0; m_flags[iradix+1]=0; for(ulong i=0; i<iradix; i++) { if(--n>0) m_flags[i]=1; else m_flags[i]=0; } break; } } }
Последний внутренний цикл используется для перехода к следующим наборам данных в выборке и вне выборки.
if(iradix == m_combinations-1) { ++ncombo; break; } } //--- final result return double(num_less)/double(ncombo); }
Последний блок if определяет, когда следует выйти из основного внешнего цикла перед возвратом окончательного значения PBO, разделив num_less на ncombo.
Прежде чем мы рассмотрим пример применения класса Ccscv, нам нужно понять, что этот алгоритм говорит о конкретной стратегии.
Интерпретация результатов
Алгоритм CSCV, который мы реализовали, выводит одну метрику - PBO. Дэвид Бэйли с соавторами отмечает, что PBO определяет вероятность того, что набор параметров, который обеспечил наилучшую производительность во время оптимизации на наборе данных в выборке, достигнет производительности, которая ниже медианы результатов производительности с использованием неоптимальных наборов параметров на наборе данных за пределами выборки.
Чем больше это значение, тем значительнее степень подгонки. Другими словами, существует большая вероятность того, что стратегия будет неэффективной, если ее применять вне выборки. Идеальный PBO должен быть ниже 0,1.
Достигнутое значение PBO будет в основном зависеть от разнообразия наборов параметров, опробованных в ходе оптимизации. Важно убедиться, что выбранные наборы параметров репрезентативны для тех, которые могут быть применены в реальных условиях. Намеренное включение комбинаций параметров, которые вряд ли будут выбраны или в которых доминируют комбинации, близкие или далекие от оптимального, только исказит конечный результат.
Пример
В этом разделе мы покажем применение класса Ccscv для советника. Изменим штатный советник Moving Average, включив в него расчет PBO. Для эффективной реализации метода CSCV мы будем использовать frames для сбора данных по барам. По завершении оптимизации данные каждого прохода будут собраны в матрицу. Это значит, что в код советника нужно добавить как минимум обработчики и OnTesterDeinit(). Наконец, выбранный советник должен быть подвергнут полной оптимизации с использованием опции медленного полного алгоритма в тестере стратегий.
//+------------------------------------------------------------------+ //| MovingAverage_CSCV_DemoEA.mq5 | //| Copyright 2023, MetaQuotes Ltd. | //| https://www.mql5.com | //+------------------------------------------------------------------+ #property copyright "Copyright 2023, MetaQuotes Ltd." #property link "https://www.mql5.com" #property version "1.00" #include <Returns.mqh> #include <CSCV.mqh> #include <Trade\Trade.mqh>
Начнем с включения файлов CSCV.mqh и Returns.mqh, которые содержат определение класса CReturns. CReturns будет полезен для сбора доходности по барам, с помощью которой мы можем вычислить коэффициент Шарпа, среднюю доходность или общую доходность. Мы можем использовать любой из этих параметров в качестве критерия для определения оптимальной производительности. Как уже говорилось в начале статьи, выбранная метрика производительности не имеет значения, можно использовать любую.
sinput uint NumBlocks = 4;
Добавлен новый неоптимизируемый параметр под названием NumBlocks, который определяет количество разделов, которые будут использоваться алгоритмом CSCV. Позже мы увидим, что изменение этого параметра повлияет на PBO.
CReturns colrets;
ulong numrows,numcolumns;
Экземпляр CReturns объявляется глобально. Здесь также объявляются numrows и numcolumns, которые мы будем использовать для инициализации матрицы.
//+------------------------------------------------------------------+ //| TesterInit function | //+------------------------------------------------------------------+ void OnTesterInit() { numrows=1; //--- string name="MaximumRisk"; bool enable; double par1,par1_start,par1_step,par1_stop; ParameterGetRange(name,enable,par1,par1_start,par1_step,par1_stop); if(enable) numrows*=ulong((par1_stop-par1_start)/par1_step)+1; //--- name="DecreaseFactor"; double par2,par2_start,par2_step,par2_stop; ParameterGetRange(name,enable,par2,par2_start,par2_step,par2_stop); if(enable) numrows*=ulong((par2_stop-par2_start)/par2_step)+1; //--- name="MovingPeriod"; long par3,par3_start,par3_step,par3_stop; ParameterGetRange(name,enable,par3,par3_start,par3_step,par3_stop); if(enable) numrows*=ulong((par3_stop-par3_start)/par3_step)+1; //--- name="MovingShift"; long par4,par4_start,par4_step,par4_stop; ParameterGetRange(name,enable,par4,par4_start,par4_step,par4_stop); if(enable) numrows*=ulong((par4_stop-par4_start)/par4_step)+1; }
Добавим обработчик OnTesterInit(), в котором подсчитываем количество тестируемых наборов параметров.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- colrets.OnNewTick(); //--- if(SelectPosition()) CheckForClose(); else CheckForOpen(); //--- }
В обработчике событий OnTick() вызываем метод OnNewtick() из CReturns.
//+------------------------------------------------------------------+ //| Tester function | //+------------------------------------------------------------------+ double OnTester() { //--- double ret=0.0; double array[]; //--- if(colrets.GetReturns(ENUM_RETURNS_ALL_BARS,array)) { //--- ret = MathSum(array); if(!FrameAdd(IntegerToString(MA_MAGIC),long(MA_MAGIC),double(array.Size()),array)) { Print("Could not add frame ", GetLastError()); return 0; } //--- } //---return return(ret); }
Внутри OnTester() собираем массив возвратов с помощью нашего глобально объявленного экземпляра CReturns. И, наконец, добавляем эти данные в кадр с помощью вызова FrameAdd().
//+------------------------------------------------------------------+ //| TesterDeinit function | //+------------------------------------------------------------------+ void OnTesterDeinit() { //---prob value numcolumns = 0; double probability=-1; int count_frames=0; matrix data_matrix=matrix::Zeros(numrows,1); vector addvector=vector::Zeros(1); Cscv cscv; //---calculate if(FrameFilter(IntegerToString(MA_MAGIC),long(MA_MAGIC))) { //--- ulong pass; string frame_name; long frame_id; double passed_value; double passed_data[]; //--- while(FrameNext(pass,frame_name,frame_id,passed_value,passed_data)) { //--- if(!numcolumns) { numcolumns=ulong(passed_value); addvector.Resize(numcolumns); data_matrix.Resize(numrows,numcolumns); } //--- if(addvector.Assign(passed_data)) { data_matrix.Row(addvector,pass); count_frames++; } //--- } } else Print("Error retrieving frames ", GetLastError()); //---results probability = cscv.CalculateProbability(NumBlocks,data_matrix,MathSum,true); //---output results Print("cols ",data_matrix.Cols()," rows ",data_matrix.Rows()); Print("Number of passes processed: ", count_frames, " Probability: ",probability); //--- }
Именно в OnTesterDeinit() мы находим основную часть дополнений, внесенных в советник. Здесь мы объявляем экземпляр Ccscv вместе с переменными матричного и векторного типа. Мы перебираем все кадры и передаем их данные в матрицу. Вектор используется как посредник для добавления новой строки данных для каждого кадра.
Метод CalculateProbability() Ccscv вызывается перед выводом результатов на вкладку "Эксперты" терминала. В этом примере мы передали методу функцию MathSum(), что означает, что общий доход используется для определения оптимального набора параметров. Вывод также дает представление о количестве обработанных кадров, чтобы подтвердить, что все данные были захвачены.
Вот некоторые результаты запуска нашего модифицированного советника с различными настройками на разных таймфреймах. Результат PBO выводится на вкладку "Эксперты" терминала.
MovingAverage_CSCV_DemoEA (EURUSD,H1) Number of passes processed: 23520 Probability: 0.3333333333333333
NumBlocks | Таймфрейм | Вероятность подгонки тестирования на истории (PBO) |
---|---|---|
4 | W1 | 0,3333 |
4 | D1 | 0,6666 |
4 | H12 | 0,6666 |
8 | W1 | 0,2 |
8 | D1 | 0,8 |
8 | H12 | 0,6 |
16 | W1 | 0,4444 |
16 | D1 | 0,8888 |
16 | H12 | 0,6666 |
Лучший результат, который мы получили, — 0,2. Остальные были намного хуже. Это указывает на высокую вероятность того, что советник будет показывать плохую производительность при применении к любому набору данных, выходящему за пределы выборки. Мы также видим, что плохие показатели PBO сохраняются на разных таймфреймах. Изменение количества разделов, использованных в анализе, не улучшило изначально плохую оценку.
Заключение
Мы продемонстрировали реализацию метода комбинаторно-симметричной перекрестной проверки для оценки подгонки после процедуры оптимизации. По сравнению с использованием перестановок Монте-Карло для количественной оценки подгонки, CSCV отличается относительной быстротой. Метод также позволяет эффективно использовать доступные исторические данные. Однако существуют потенциальные ловушки, о которых необходимо знать. Надежность этого метода зависит исключительно от используемых данных.
В частности, опробована степень изменения параметров. Использование меньшего количества вариаций параметров может привести к заниженной оценке подгонки. В то же время включение большого количества нереалистичных комбинаций параметров может привести к завышенным оценкам. Также следует принять во внимание временные рамки, выбранные для периода оптимизации. Это может повлиять на выбор параметров, применяемых к стратегии. Подразумевается, что окончательное значение PBO может варьироваться в разные периоды времени. При тестировании следует учитывать как можно больше возможных конфигураций параметров.
Одним из заметных недостатков этого теста является то, что его нелегко применить к советникам, исходный код которых недоступен. Теоретически можно было бы провести отдельные бэктесты для каждой возможной конфигурации параметров, но это так же трудоемко, как и использование методов Монте-Карло.
Подробное описание CSCV и интерпретацию PBO можно найти в оригинальной статье. Ссылка приведена во втором абзаце этой статьи. Исходный код всех программ, упомянутых в статье, приложен ниже.
Название файла | Описание |
---|---|
Mql5\Include\Returns.mqh | Определяет класс CReturns для сбора данных о доходности или эквити в реальном времени. |
Mql5\Include\CSCV.mqh | Содержит определение класса Ccscv, который реализует комбинаторно-симметричную перекрестную проверку. |
Mql5\Experts\MovingAverage_CSCV_DemoEA.mq5 | Модифицированный советник Moving Average, демонстрирующий применение класса Ccscv. |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/13743
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования
Ознакомьтесь с новой статьей: Комбинаторно симметричная перекрестная проверка на MQL5.
Автор: Фрэнсис Дюбе
Просто интересно, повезло ли кому-нибудь с этим методом? Я попробовал реализовать его на бэктесте m5 за 10 лет с форвардом 1/2 и это безумно медленно, хотелось бы знать, нашел ли кто-нибудь способ закодировать его так, чтобы он был немного быстрее? Конечно, было бы интересно попробовать этот метод.