English Русский Español Deutsch 日本語 Português
preview
MQL5 中的组合对称交叉验证

MQL5 中的组合对称交叉验证

MetaTrader 5交易系统 | 1 七月 2024, 11:36
155 0
Francis Dube
Francis Dube

概述

有时,在创建自动策略时,我们一开始会根据某些指标制定规则大纲,但这些规则需要以某种方式加以完善。这一完善过程包括对所选指标的不同参数值进行多次测试。通过这样的处理,我们就能找到能使利润或我们关心的其他标准最大化的指标值。这种做法的问题在于,由于金融时间序列中普遍存在噪声,我们会引入一定的乐观偏差。这种现象被称为过度拟合。

虽然过度拟合是无法避免的,但不同的策略会有不同的过度拟合程度。因此,确定这种情况发生的程度将很有帮助。组合对称交叉验证(CSCV,Combinatorially Symmetrical Cross Validation)是由 David H. Bailey 等人撰写的学术论文 "The Probability of Backtest Overfitting(回测过度拟合的概率)" 中提出的一种方法。在优化策略参数时,它可用于估计过度拟合的程度。

在本文中,我们将演示使用 MQL5 实现 CSCV,并通过一个示例说明如何将其应用于 EA 交易。


CSCV 方法

在本节中,我们将逐步介绍 CSCV 的精确方法,首先介绍根据所选性能标准需要收集的数据的初步情况。

CSCV 方法可应用于策略制定和分析之外的不同领域,但本文仍以策略优化为背景。我们有一套由参数定义的策略,需要通过运行大量不同参数配置的测试来进行微调。

在进行任何计算之前,我们首先需要决定用什么性能标准来评估策略。CSCV 方法非常灵活,可以使用任何性能标准。从简单的利润到基于比率的标准,对 CSCV 都没有影响。

所选的性能标准还将确定将在计算中使用的基础数据,这是将从所有测试运行中收集的原始粒度数据。例如,如果我们决定使用夏普比率作为我们选择的性能度量,我们将需要收集每次测试运行的每个柱的收益。如果使用简单利润,则需要每个柱计算盈亏。重要的是确保每次运行收集的数据量保持一致。这样,我们就能确保所有测试运行中的每个相应数据点都有一个测量值。

  1. 第一步是在优化过程中收集数据,对不同的参数变化进行测试。 
  2. 优化完成后,我们将从测试运行中收集到的所有数据汇集到一个矩阵中。该矩阵的每一行都将包含所有每个柱的性能值,用于计算相应测试运行的某些交易性能指标。
  3. 矩阵的行数与试验的参数组合数相同,列数与整个试验期的柱数相同。然后将这些列分成确定的偶数个集合。比如说,N 个集合。
  4. 这些集合是子矩阵,将用来组成大小为 N/2 的集合的组合。从组合的角度看,每次取 N/2 个组合,即 N C n /2。我们将 N/2 个子矩阵组合在一起,从每个组合中构建出一个样本内集合(ISS,In-Sample-Set),并从 ISS 中未包含的其余子矩阵中构建出一个相应的样本外集合(OOSS,Out-Of-Sample-Set)。
  5. 对于 ISS 和 OOSS 矩阵的每一行,我们都会计算相应的性能指标。并注意 ISS 矩阵中性能最好的一行,它代表了最佳参数配置。OOSS 矩阵中的相应行用于计算相对排名,方法是计算与使用最优参数配置相比性能较差的样本外参数试验的次数,并将这一计数作为所有测试参数集的一部分。
  6. 当我们遍历所有组合时,我们会累计相对等级值小于或等于 0.5 的数量。这是样本外参数配置的数量,其性能低于使用最优参数集观察到的性能。处理完所有组合后,该数字将以所有组合 + 1 的分数形式呈现。表示回测过拟合概率 (PBO,Probability of Backtest Overfitting)。

 下面是 N = 4 时上述步骤的直观图。

数据矩阵可视化

子矩阵

样本内和样本外数据集

组合

在接下来的章节中,我们将看看如何用代码实现刚才描述的步骤。我们主要讨论 CSCV 的核心方法,而与数据收集有关的代码则留待文章末尾的示例中演示。


CSCV 的 MQL5 实现

CSCV.mqh 中的 Ccsvc 类封装了 CSCV 算法。CSCV.mqh 首先包含了 MQL5 数学标准库的子函数。

//+------------------------------------------------------------------+
//|                                                         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 "应为假。
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" 等于或优于 "oos_perf" 的次数。

//--- calculate the relative rank
      rel_rank = double (count)/double (m_trials+1);
      //--- cumulate num_less
      if(rel_rank<=0.5)
         ++num_less;

count 用于计算相对排名。最后,如果计算出的相对排名小于 0.5,则累加 "num_less"。

//---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 代码块通过将 "num_less" 除以 "ncombo" 来确定何时跳出主外循环,然后返回 PBO 的最终值。

在了解如何应用 Ccscv 类之前,我们先看一个例子。我们需要花一些时间来了解这种算法对特定策略的启示。


解读结果

我们采用的 CSCV 算法只输出一个衡量标准,即 PBO。根据 David H. Bailey 等人的观点,PBO 定义了这样一种概率,即在样本内数据集优化过程中产生最佳性能的参数集,其性能低于在样本外数据集上使用非最佳参数集的性能结果的中位数。

该值越大,说明过度拟合的程度越严重。换句话说,该策略在样本外应用时表现不佳的可能性更大。理想的 PBO 值应低于 0.1。

获得的 PBO 值主要取决于优化过程中试用的各种参数集。必须确保所选的参数集能够代表实际应用中的参数集。刻意加入不可能被选中的参数组合,或者被接近或远离最佳值的组合所支配,只会玷污最终结果。


示例

在本节中,我们将介绍 Ccscv 类在 EA 交易中的应用。我们将会对所有 MetaTrader 5 安装程序中都附带的移动平均 EA 交易进行修改,以启用 PBO 计算。为了有效实施 CSCV 方法,我们将采用 "优化结果帧"来收集每个柱的数据。优化完成后,每次优化的数据都将整理成一个矩阵。这意味着至少应在 EA 的代码中添加 OnTester 事件处理函数OnTesterDeinit 事件处理函数。最后,应使用策略测试器中的慢速完整算法选项对选定的 EA 进行全面优化。 

//+------------------------------------------------------------------+
//|                                    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()" 事件处理函数中,我们调用了 CReturns 的 "OnNewtick()" 方法。

//+------------------------------------------------------------------+
//| 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()" 中,我们发现了对 EA 的大部分新增功能。在这里,我们将声明一个 Ccscv 实例以及矩阵和向量类型变量。我们循环遍历所有帧,并将其数据传入矩阵。该向量被用作中间媒介,为每个帧添加新的数据行。

在将结果输出到终端的 "专家" 选项卡之前,会调用 Ccscv 的 "CalculateProbability()" 方法。在本例中,我们将 "MathSum()" 函数传递给了该方法,这意味着总收益被用来确定最佳参数集。输出结果还会显示已处理的帧数,以确认已捕获所有数据。

以下是运行我们修改过的 EA(设置各不相同)的一些结果。在不同的时间框架内。PBO 结果将输出到终端的"专家"选项卡。

MovingAverage_CSCV_DemoEA (EURUSD,H1)   Number of passes processed: 23520 Probability: 0.3333333333333333
分区数
时间框架
回溯测试过度拟合概率
4
每周
0.3333
4
每日
0.6666
4
每 12 小时
0.6666
8
每周
0.2
8
每日
0.8
8
每 12 小时
0.6
16
每周
0.4444
16
每日
0.8888
16
每 12 小时
0.6666

我们得到的最佳结果是 PBO 为 0.2,其余的则更差。这表明,如果将该 EA 应用于任何样本外数据集,其性能很可能会更差。我们还可以看到,在不同的时间框架内,这些较差的 PBO 分数也持续存在。调整分析中使用的分区数量并没有改善最初的糟糕得分。

策略测试器设置


选定输入参数


结论

我们展示了组合对称交叉验证技术的实施,用于评估优化程序后的过拟合情况。与使用蒙特卡罗替换量化过度拟合相比,CSCV  
具有相对快速的优势。它还能有效利用现有的历史数据。尽管如此,使用者还是应该注意一些潜在的陷阱。这种方法的可靠性完全取决于所使用的数据,

特别是试验参数变化的程度。使用较少的参数变化会导致过拟合估计不足,同时,包含大量不切实际的参数组合又会产生过度估算。同样需要注意的是优化期间所选择的时间框架。这可能会影响策略参数的选择。这意味着最终的 PBO 在不同的时间框架内可能会有所不同。一般来说,测试中应考虑尽可能多的可行的参数配置。

这种测试的一个显著缺点是,它不能轻易地应用于源代码无法访问的 EA。从理论上讲,可以对每种可能的参数配置进行单独的回溯测试,但这与采用蒙特卡罗方法同样繁琐。
 
关于 CSCV 和 PBO 解释的更详尽说明,读者应参阅原文,链接见本文第二段。文章中提到的所有程序的源代码附在下面。

文件名称
描述
Mql5\Include\Returns.mqh
定义 CReturns 类,用于实时收集收益或净值数据
Mql5\Include\CSCV.mqh
包含实现组合对称交叉验证的 Ccscv 类的定义
Mql5\Experts\MovingAverage_CSCV_DemoEA.mq5
演示 Ccscv 类应用的修正版移动平均 EA


本文由MetaQuotes Ltd译自英文
原文地址: https://www.mql5.com/en/articles/13743

附加的文件 |
CSCV.mqh (6.9 KB)
Returns.mqh (9.58 KB)
Mql5.zip (7.37 KB)
软件开发和 MQL5 中的设计模式(第 2 部分):结构模式 软件开发和 MQL5 中的设计模式(第 2 部分):结构模式
在了解了设计模式适用于 MQL5 和其他编程语言,并且对于开发人员开发可扩展、可靠的应用程序有多么重要之后,我们将在本文中继续介绍设计模式。我们将学习另一种类型的设计模式,即结构模式,了解如何利用我们所拥有的类组成更大的结构来设计系统。
您应当知道的 MQL5 向导技术(第 09 部分):K-Means 聚类与分形波配对 您应当知道的 MQL5 向导技术(第 09 部分):K-Means 聚类与分形波配对
“K-均值”聚类采用数据点分组的方式,该过程最初侧重于数据集的宏观视图,使用随机生成的聚类质心,然后放大并调整这些质心,从而准确表示数据集。我们将对此进行研究,并开拓一些它的用例。
群体优化算法:螺旋动态优化 (SDO) 算法 群体优化算法:螺旋动态优化 (SDO) 算法
文章介绍了一种基于自然界螺旋轨迹构造模式(如软体动物贝壳)的优化算法 - 螺旋动力学优化算法(Spiral Dynamics Optimization,SDO)。我对作者提出的算法进行了彻底的修改和完善,本文将探讨这些修改的必要性。
掌握 MQL5:从入门到精通(第一部分):开始编程 掌握 MQL5:从入门到精通(第一部分):开始编程
本文是有关编程的系列文章的概述。这里假设的是读者之前从未接触过编程,因此,本系列从最基础的地方开始。编程知识水平:绝对的新手。