C---市场交易系统算法测试和调优-二-

57 阅读1小时+

C++ 市场交易系统算法测试和调优(二)

原文:Testing and Tuning Market Trading Systems Algorithms in C++

协议:CC BY-NC-SA 4.0

四、后优化问题

便宜的偏差估计

在第 121 页,我们将详细介绍训练偏差,在第 286 页,我们将看到处理这个严重问题的有力方法。但是现在,我们将提供一个训练偏差的粗略概述,并展示如果一个人使用差分进化或一些类似的随机算法训练了一个交易系统,我们如何能够得到一个训练偏差的粗略但有用的估计,作为参数优化的廉价副产品。

当我们着手开发一个交易系统时,我们拥有一组历史数据,我们将根据这些数据优化我们的系统。这就是通常所说的 in-sampleIS 数据。当系统在不同的数据集上测试或投入使用时,该数据被称为样本外OOS 数据。我们实际上总是期望信息系统的性能将优于 OOS 的性能。这可能是由于几个因素,其中最重要的是,我们的 is 数据中存在的不可避免的噪声将在某种程度上被我们的训练算法误认为合法模式。当(根据定义)相同的噪声没有出现在 OOS 数据中时,性能将受到影响。

负责任的交易系统开发的一个关键方面是估计训练偏差,其表现超过 OOS 表现的程度。稍后,我们将看到一些相当精确的复杂方法。但是,当我们已经测试了大量随机参数组合作为随机优化过程的初步总体时,我们可以使用这些参数集和相关的逐棒回报来快速生成训练偏差的估计,虽然远没有使用更复杂的方法可获得的精度,但对于粗略的初步估计来说往往足够好。这给我们一个早期的想法,我们是否在正确的轨道上,它可能会节省我们更多的工作在一个方向,导致一个死胡同。

StocBias 类

文件 STOC _ 偏见。CPP 包含一个类的代码,让我们截取初步的人口生成,并使用这些数据来粗略估计训练偏差。要做到这一点,我们需要在初始群体生成期间访问每个试交易系统的逐棒回报。

随机或通过确定性网格搜索产生试验参数估计值是至关重要的。它们不能由任何智能引导搜索产生。因此,我们将检查用于构建差异进化初始种群的所有合法案例,但我们不能使用任何由突变和交叉产生的案例。

该算法的动机是这样的:假设我们预先选择一些棒作为单个 OOS 棒。当我们处理每个试验参数集时,我们将从所有试验中找到使所有其他棒线的总回报最大化的参数集——除了我们预先选择的 OOS 棒线之外的所有棒线。我们可以称之为设定。我们选择的 OOS 棒线在选择最佳性能参数集时不起作用,因为它在 is 回报的计算过程中被忽略。在我们检查了用于创建初始群体的所有参数集之后,我们的最佳参数集的每根棒线的返回减去我们的单个 OOS 棒线的返回,将是对训练偏差的粗略但真实的估计。

如果我们只对单个选择的 OOS 棒线进行这样的操作,我们的训练偏差估计将太容易受到随机变化的影响而没有用。但是很容易对每根棒线同时进行这种操作,然后合并单个的回报。对于任何参数集,我们只计算所有棒线的总回报。如果我们从总数中减去任何单个棒线的回报,差值就是该参数集的 is 回报,我们移除的棒线就是相应的 OOS 回报。当我们处理试验参数集时,我们分别跟踪每个棒线的最大值和对应于该上级的 OOS 回报,这样我们可以在以后减去。

主要的限制是,为了给出训练偏差的真正好的估计,我们需要为每个 is 集找到真正最优的参数集,并且历史数据中有多少条就有多少个 is 集。这显然不切实际。因为我们的“最优”仅仅是基于随机选择的试验参数集,所以我们不能期望很高的精确度。事实上,除非试验人群很大,也许至少有几千人,否则我们的偏倚评估将毫无价值。但是,通过在差异进化中使用大量的过度初始化,我们可以实现这一点,并提供一个很大的启动种群!

该类声明如下:

class StocBias {
public:
   StocBias::StocBias ( int ncases ) ;
   StocBias::~StocBias () ;

   int ok ;          // Flags if memory allocation went well

   void collect ( int collect_data ) ;
   void process () ;
   void compute ( double *IS_mean , double *OOS_mean , double *bias ) ;
   double *expose_returns () ;

private:
   int nreturns ;              // Number of returns
   int collecting ;             // Are we currently collecting data?
   int got_first_case ;     // Have we processed the first case (set of returns)?
   double *returns ;        // Returns for currently processed case
   double *IS_best ;       // In-sample best total return
   double *OOS ;           // Corresponding out-of-sample return
} ;

我们的StocBias类的构造函数分配内存并初始化一些标志。collecting标志表示我们是否正在收集和处理案例。当我们构建初始群体时,这必须打开(非零),在优化期间关闭。我省略了验证成功内存分配和设置ok标志的代码。

StocBias::StocBias (
   int nc
   )
{
   nreturns = nc ;
   collecting = 0 ;
   got_first_case = 0 ;

   IS_best = (double *) malloc ( nreturns * sizeof(double) ) ;
   OOS = (double *) malloc ( nreturns * sizeof(double) ) ;
   returns = (double *) malloc ( nreturns * sizeof(double) ) ;
}

当我们想要开始收集试验参数集并返回时,调用下面的普通例程(用collect_data =1 ),当我们完成收集时,再次调用它(用collect_data =0 ):

void StocBias::collect ( int collect_data )
{
   collecting = collect_data ;
}

我们可以让returns成为公共的,但是 C++ 纯粹主义者希望它保持私有,并将其暴露给 criterion 例程,所以这就是我在这里所做的:

double *StocBias::expose_returns ()
{
   return returns ;
}

每次调用参数评估例程时,该例程负责将逐条返回放置在此returns中,然后调用process()

void StocBias::process ()
{
   int i ;
   double total , this_x ;

   if (! collecting)
      return ;

   total = 0.0 ;
   for (i=0 ; i<nreturns ; i++)
      total += returns[i] ;

   // Initialize if this is the first call
   if (! got_first_case) {
      got_first_case = 1 ;
      for (i=0 ; i<nreturns ; i++) {
         this_x = returns[i] ;
         IS_best[i] = total - this_x ;
         OOS[i] = this_x ;
         }
      }

   // Keep track of best if this is a subsequent call
   else {
      for (i=0 ; i<nreturns ; i++) {
         this_x = returns[i] ;
         if (total - this_x > IS_best[i]) {
            IS_best[i] = total - this_x ;
            OOS[i] = this_x ;
            }
         }
      }
}

process()程序从对所有棒线的回报求和开始,以获得该试验参数集的总回报。如果这是第一次调用(got_first_case为假),我们通过将IS_best[]中的“目前为止最好的”is 返回设置为 is 返回来进行初始化,并且我们还初始化相应的 OOS 返回。回想一下,任何 OOS 棒线的 IS 回报率是除 OOS 棒线以外的所有回报率的总和。这很容易通过从所有回报的总和中减去 OOS 棒线的回报得到。

如果这是一个后续调用,过程是类似的,除了不是初始化IS_best[],如果这个返回大于运行最佳,我们更新它。如果我们做这个更新,我们也必须更新相应的 OOS 回报。

剩下的只是最终结果的琐碎计算。我们返回的值基于整个市场历史的总回报。IS_best[]的每个元素都是nreturns–1 棒线回报的总和,所以我们用这个量除总和,使总和与 OOS 回报的总和相称。

void StocBias::compute (
   double *IS_return ,
   double *OOS_return ,
   double *bias
   )
{
   int i ;

   *IS_return = *OOS_return = 0.0 ;

   for (i=0 ; i<nreturns ; i++) {
      *IS_return += IS_best[i] ;
      *OOS_return += OOS[i] ;
      }

   *IS_return /= (nreturns - 1) ;     // Each IS_best is the sum of nreturns-1 returns
   *bias = *IS_return - *OOS_return ;
}

计算出偏差后,我们该怎么处理它呢?孤立地看,它的价值有限。此外,我们必须记住,这是一个粗略的估计。不过,从交易系统的总回报中减去偏差还是有用的,交易系统的总回报是从差分进化或其他优化算法产生的最优参数集中获得的。如果去掉近似的训练偏差,对参数集在样本之外的表现产生了不太好的估计,我们应该停下来,重新考虑我们的交易系统。

将此处计算的IS_return与优化程序产生的最佳值进行比较非常重要。自然,它实际上总是更少;否则优化算法会很差!但理想情况下,它会相当接近。如果我们发现我们的IS_return比最优回报小得多,我们应该得出结论,我们使用的试验参数集太少,因此我们的偏差估计会非常差。

在实际交易系统中,这个例程的完整例子将出现在第 112 页对 DEV_MA 程序的讨论中。

廉价的参数关系

在前面的部分中,我们看到了如何从随机优化例程(如差分进化)中借用初始群体来提供对训练偏差的快速而粗略的估计。在这一节中,我们将看到在优化完成后,如何使用最终群体来快速生成一些有趣的参数相互关系的度量。就像廉价的训练偏差一样,这些都是粗略的估计,有时会非常不准确。然而,通常情况下,它们会被证明是有趣和有用的,特别是如果使用了大量的样本,并且优化一直持续到获得稳定性。此外,作为本次演示的一部分,我们将指出如何修改算法以产生更可靠的估计,尽管代价是更多的计算时间。

这一发展中的一些数学超出了本书的范围,将作为陈述的事实提出,读者必须相信这一过程。此外,这种表述简化了许多权利要求,尽管从未达到不正确的程度。另一方面,这里没有什么真正深奥的东西;所有这些结果都是标准材料,广泛存在于标准统计参考资料中。考虑到这些警告...

多元函数的 Hessian 是函数对变量的二阶偏导数的矩阵。换句话说,海森矩阵的 i,j 元素是函数对第 i 个和第 j 个变量的偏导数。假设函数是概率密度的负对数似然;变量是概率密度函数的参数,我们计算了参数的最大似然估计。然后,对于一大类概率密度函数,包括古老的正态分布,参数估计的估计标准误差是 Hessian 矩阵的逆的相应对角线的平方根。事实上,Hessian 的逆是参数估计的协方差矩阵。

在任何统计学家开始尖叫之前,让我强调一下,交易系统的性能最大值与统计分布的对数可能性是非常不同的动物,所以类似地对待它们有点牵强。另一方面,一个优化的交易系统在其最大值附近的一般行为(或任何多元函数,就此而言)遵循相同的原则。其 Hessian 在最佳值附近的倒数描述了参数水平曲线的方向变化率。只要我们不谈论估计的标准误差,而是保持一切的相对性,我们就可以用一些相对简单的技术收集许多关于参数之间关系的信息。

该算法的完整源代码在 PARAMCOR 文件中。CPP 和 DEV_MA。CPP 程序将在第 112 页展示,它将在一个实际的交易系统中进行说明。我们现在一次处理代码的一部分。该例程的调用如下:

int paramcor (
   int ncases ,        // Number of cases
   int nparams ,     // Number of parameters
   double *data      // Ncases (rows) by nparams+1 input of trial pts and f vals
   )

data矩阵的结构与 DIFF_EV 中的结构相同。CPP 计划。每个个体(完整的参数集和性能指标)占据一行,参数排在前面,性能排在最后。这意味着优化完成后,可以用最终群体调用paramcor()。就像我们在估计训练偏倚时所做的那样,用初始人群来称呼它是没有意义的。这是因为我们希望整个群体接近全局最优,事实上我们希望这个最优成为群体的一部分。

计算 Hessian 矩阵的一种快速简单的方法,也就是我们在这里要做的,是在最优值附近拟合一个最小二乘二次函数,然后直接计算 Hessian 矩阵。我们需要这个拟合中的参数数量:

   if (nparams < 2)
      return 1 ;

   ncoefs = nparams                               // First-order terms
          + nparams * (nparams + 1) / 2     // Second-order terms
          + 1 ;                                              // Constant

在继续之前,重要的是要强调,至少有两种计算 Hessian 的替代方法,这两种方法都需要更多的工作,但在精度方面可能更胜一筹。在本节描述的技术中发现价值的读者将很好地探索这些替代方法,每种方法都有自己的优点和缺点。以下是对它们的简单比较:

  • 这里使用的方法是差异进化的廉价副产品。我们不需要重复评估各种参数集的性能,因为我们已经有了一个群体,其大多数成员相对接近最优。最小平方拟合的使用倾向于消除噪声。这种方法的最大缺点是,远非最佳的试验参数集可能会让计算变得麻烦。当我们有一个非常大的群体时,这种方法最有效,我们优化直到收敛是可靠的。

  • 我们可以在最佳参数集附近抽取大量随机样本,评估每个样本的性能,然后像第一种方法一样进行最小二乘拟合。这具有显著的优点,即不会出现野参数集,也不会干扰计算。但是它确实需要大量的性能评估,如果进行大量的评估,会使代码变得复杂并增加大量的计算时间。更重要的是,选择一个适当程度的随机变异不是一件小事,而差异进化往往倾向于适当的值。

  • 我们可以使用标准的数值方法,扰动每个参数并直接数值计算偏导数。同样,找到一个合适的扰动可能是困难的,误判会对准确性产生深远的影响。但如果小心行事,这几乎肯定是一个好方法。

在这里,我们处理一个恼人的启发式决策。为了将群体限制在接近最优的那些参数集,我们只保留最终群体的一部分,即那些与最优具有最小欧几里德距离的群体。我们有多少病例?我自己的启发是保留比要估计的系数多 50%的案例。如果这个值太小,我们可能得不到足够的变化来精确计算每一个干扰系数。如果它太大,我们可能会受到野生参数集的污染,远离最佳,我们无法获得准确的局部行为。但是根据我自己的经验,这个因素是相当可靠的,特别是如果人口很多的话(至少几百)。如果群体有数百人,增加该因子可能有利于增加能够模拟所有参数相互作用的可能性。

   nc_kept = (int) (1.5 * ncoefs) ;  // Keep this many individuals

   if (nc_kept > ncases) {
      return 1 ;
      }

我们需要分配很多工作区域。我们将使用SingularValueDecomp对象进行最小平方二次拟合。它的源代码可以在 SVDCMP.CPP 中找到,不熟悉这项技术的读者会发现很容易找到有关奇异值分解的更多信息,奇异值分解是一种标准而可靠的最小二乘拟合方法。我们还打开一个日志文件,来自该算法的信息将为用户写入其中。

   sptr = new SingularValueDecomp ( nc_kept , ncoefs , 0 ) ;
   coefs = (double *) malloc ( ncoefs * sizeof(double) ) ;
   hessian = (double *) malloc ( nparams * nparams * sizeof(double) ) ;
   evals = (double *) malloc ( nparams * sizeof(double) ) ;
   evect = (double *) malloc ( nparams * nparams * sizeof(double) ) ;
   work1 = (double *) malloc ( nparams * sizeof(double) ) ;
   dwork = (double *) malloc ( ncases * sizeof(double) ) ;
   iwork = (int *) malloc ( ncases * sizeof(int) ) ;
   fopen_s ( &fp , "PARAMCOR.LOG" , "wt" ) ;

我们找到群体中最好的个体,并得到一个指向它的指针。

   for (i=0 ; i<ncases ; i++) {
      pptr = data + i * (nparams+1) ;
      if (i==0  ||  pptr[nparams] > best_val) {
         ibest = i ;
         best_val = pptr[nparams] ;
         }
      }

   bestptr = data + ibest * (nparams+1) ;   // This is the best individual

我们将希望使用仅由那些最接近最佳参数集的个体组成的群体子集。这使我们能够专注于本地信息,而不会被远离最佳状态的性能变化所迷惑。要做到这一点,需要计算最优群体和群体中每个成员之间的欧几里德距离。对这些距离进行排序,同时移动它们的索引,这样我们最终得到排序后的个体的索引。使用欧几里德距离的一个含义是,我们必须以这样一种方式定义交易系统的参数,它们至少是大致相称的。否则,某些参数在计算距离时可能会权重过大或不足。稍后,我们将看到这一点之所以重要的另一个原因。子程序qsortdsi()的源代码在 QSORTD.CPP 中。

   for (i=0 ; i<ncases ; i++) {
      pptr = data + i * (nparams+1) ;
      sum = 0.0 ;
      for (j=0 ; j<nparams ; j++) {
         diff = pptr[j] - bestptr[j] ;
         sum += diff * diff ;
         }
      dwork[i] = sum ;
      iwork[i] = i ;
      }

   qsortdsi ( 0 , ncases-1 , dwork , iwork ) ; // Closest to most distant

这里我们使用奇异值分解来计算性能曲线的最小二乘拟合二次曲面的系数。这是一个二次方程,它提供了性能的最小平方误差估计,作为系数的函数,至少在最佳值附近。为了帮助数值稳定性,我们从每个其他个体中减去最佳个体的系数和参数值,从而将计算集中在最佳参数集周围。这在数学上是不必要的;如果不这样做,任何差异都会被吸收到常数偏移中。然而,它确实提供了一种快速简便的方法来提高数值稳定性。源代码文件 SVDCMP 开头的注释。CPP 对这里发生的事情提供了一些解释,更多的细节可以很容易地在网上或许多标准回归教科书中找到。

   aptr = sptr->a ;                                            // Design matrix goes here
   best = data + ibest * (nparams+1) ;            // Best individual, parameters and value

   for (i=0 ; i<nc_kept ; i++) {                            // Keep only the nearby subset of population
      pptr = data + iwork[i] * (nparams+1) ;
      for (j=0 ; j<nparams ; j++) {
         d = pptr[j] - best[j] ;                                // This optional centering slightly aids stability
         *aptr++ = d ;                                          // First-order terms
         for (k=j ; k<nparams ; k++) {
            d2 = pptr[k] - best[k] ;
            *aptr++ = d * d2 ;                               // Second-order terms
            }
         }
      *aptr++ = 1.0 ;                                                // Constant term
      sptr->b[i] = best[nparams] - pptr[nparams] ;  // RHS is function values, also centered
      }

   sptr->svdcmp () ;
   sptr->backsub ( 1.e-10 , coefs ) ;                      // Computes optimal weights

此时我们有了coefs中的二次函数系数。常数 1 . e–10 是启发式的,并不十分重要。它只是控制在接近奇点的情况下计算系数的程度,这在本应用中几乎不可能获得。如果用户感兴趣,我们在这里省略了打印系数的冗长代码。

在刚刚显示的代码中应该注意到一些微妙但至关重要的事情:我们翻转了性能的符号。这将问题从最大化转换为最小化,类似于最小化统计分布的负对数可能性。这是不必要的;没有符号颠倒,我们需要的结果也会随之而来。这不仅很好地符合了传统的用法,而且这也给了我们对角线上的正数,当打印出来的时候,更容易阅读,对用户更友好。

从二次拟合计算 Hessian 矩阵很简单,只需对每个参数的每一项微分一次。当然,这意味着我们计算对角线项的二阶导数,其中相同的参数出现两次。线性项微分两次就消失了。矩阵是对称的,所以我们只需将一项复制到另一项。

   cptr = coefs ;
   for (j=0 ; j<nparams ; j++) {
      ++cptr ;   // Skip the linear term
      for (k=j ; k<nparams ; k++) {
         hessian[j*nparams+k] = *cptr ;
         if (k == j)                                        // If this is a diagonal element
            hessian[j*nparams+k] *= 2.0 ;     // Second partial is twice coef
         else                                                // If off-diagonal
            hessian[k*nparams+j] = *cptr ;    // Copy the symmetric element
         ++cptr ;
         }
      }

这是一个简短离题的好地方,讨论什么可能出错,以及为什么表面上的问题实际上可能没有看起来那么严重。有些问题本身就能提供信息。回想一下,因为我们翻转了性能度量的符号,所以我们现在最小化了我们的函数。这意味着,如果我们处于真正的局部(理想情况下是全局!)最小值,函数相对于每个参数(Hessian 矩阵的对角线)的二阶导数将严格为正。但是如果一条或多条对角线是零,或者,但愿不会,是负数,那该怎么办呢?

简而言之,后续的计算会受到严重影响。请记住,我们的基本假设是我们处于最小值(我们的性能处于最大值)。我们将冒险得出的关于参数关系的一切都取决于这一假设的有效性。以下是关于这个问题的一些想法:

  • 在随后的计算中,必须忽略对角线元素不是正的任何参数。至少就数据的最小二乘拟合而言,该参数不是最佳值。

  • “局部”是一个主观描述。一个参数可能确实在其位置的狭窄邻域内处于局部最优,但是这个局部最优可能不是全局的。

  • 该参数可能确实是全局最优的,但是最小二乘拟合被扩展到这样的距离,以致于它不再代表函数的局部行为。换句话说,最小二乘拟合是问题所在,因为它被要求近似高度非二次行为。

  • 也许最重要的是,非正对角线是一个红色信号,表明交易系统的参数化是不稳定的。通常,这表明性能曲线不是每个参数的平滑函数,而是上下剧烈跳动。参数的微小变化可能会剧烈移动性能,或者可能会向上移动,再移动一点点后向下移动,然后再次向上移动,多次。当交易系统,而不是可靠地利用可重复的模式,或多或少地随机捕捉大赢,然后大输时,就会发生这种情况,因为参数在其范围内来回变化。这是不良行为。

  • 前面陈述的一个推论是“本地”行为应该尽可能地扩展到本地之外。如果性能曲线以一种接近最佳的方式运行,但很快就改变为不同的方式,这是一个危险的系统。对于每个参数,我们都希望在最佳值附近看到一个宽的性能峰值,随着我们远离最佳值,性能会平稳下降。

前面几点的结论是,如果我们发现一条或多条对角线是非正的,我们不应该诅咒算法,而是自动考虑切换到数值微分作为最小二乘拟合方法的替代方法,这种方法具有很好的噪声消除特性。相反,我们应该仔细观察我们的交易系统,特别是绘制敏感度曲线,这将在第 108 页讨论。

好了,说得够多了,所以我们继续讨论如何处理负对角线。这很简单:只需将任何对角线元素及其行和列设置为零。这将从所有后续计算中删除它。

   for (j=0 ; j<nparams ; j++) {
      if (hessian[j*nparams+j] < 1.e-10) {
         for (k=j ; k<nparams ; k++)
            hessian[j*nparams+k] = hessian[k*nparams+j] = 0.0 ;
         }
      }

同样,当且仅当 Hessian 矩阵是半正定时,我们处于局部最小值(记住我们翻转了性能的符号)。但是野参数值有可能导致 Hessian 不具有该属性的二次拟合。如果有必要,我们通过限制非对角元素来鼓励这种做法,尽管奇怪的相关模式仍然可能产生负的特征值。

   for (j=0 ; j<nparams-1 ; j++) {
      d = hessian[j*nparams+j] ;           // One diagonal
      for (k=j+1 ; k<nparams ; k++) {
         d2 = hessian[k*nparams+k] ;    // Another diagonal
         limit = 0.99999 * sqrt ( d * d2 ) ;
         if (hessian[j*nparams+k] > limit) {
            hessian[j*nparams+k] = limit ;
            hessian[k*nparams+j] = limit ;
            }
         if (hessian[j*nparams+k] < -limit) {
            hessian[j*nparams+k] = -limit ;
            hessian[k*nparams+j] = -limit ;
            }
         }
      }

如果任何对角线被置零,Hessian 矩阵用通常的方法是不可逆的,我们很快就会需要它的特征值和向量,所以我们计算它们并用它们来计算 Hessian 矩阵的广义逆。我们将逆矩阵放回到 Hessian 矩阵中,以避免又一次内存分配。evec_rs()的源代码在 EVER_RS.CPP 中。

   evec_rs ( hessian , nparams , 1 , evect , evals , work1 ) ;

   for (j=0 ; j<nparams ; j++) {
      for (k=j ; k<nparams ; k++) {
         sum = 0.0 ;
         for (i=0 ; i<nparams ; i++) {
            if (evals[i] > 1.e-8)
               sum += evect[j*nparams+i] * evect[k*nparams+i] / evals[i] ;
            }
         hessian[j*nparams+k] = hessian[k*nparams+j] = sum ;     // Generalized inverse
         }
      }

我们终于准备好打印一些真正有用的信息。我们从每个参数的相对变化开始。如果我们正在处理负对数似然分布,这些值将是参数的最大似然估计的估计标准误差。但是因为我们离那个场景很远,所以我们重新调整,使最大变化参数的值为 1.0。这些是每个参数可以变化的相对量,同时对交易系统的性能具有最小的影响。较大的值意味着系统相对不受参数变化的影响。计算缩放比例,然后将它们打印在生产线上。有关此输出的示例,请快速查看第 118 页。

   for (i=0 ; i<nparams ; i++) {          // Scale so largest variation is 1.0
      if (hessian[i*nparams+i] > 0.0)
         d = sqrt ( hessian[i*nparams+i] ) ;
      else
         d = 0.0 ;
      if (i == 0  ||  d > rscale)
         rscale = d ;
      }

   strcpy_s ( msg , " " ) ;
   for (i=0 ; i<nparams ; i++) {
      sprintf_s ( msg2, "      Param %d", i+1 ) ;
      strcat_s ( msg , msg2 ) ;
      }
   fprintf ( fp , "\n%s", msg ) ;

   strcpy_s ( msg , "  Variation-->" ) ;
   for (i=0 ; i<nparams ; i++) {
      if (hessian[i*nparams+i] > 0.0)
         d = sqrt ( hessian[i*nparams+i] ) / rscale ;
      else
         d = 0.0 ;
      sprintf_s ( msg2 , " %12.3lf", d ) ;
      strcat_s ( msg , msg2 ) ;
      }
   fprintf ( fp , "\n%s", msg ) ;

现在,我们可以通过用标准差换算协方差来计算和打印参数相关性。

   for (i=0 ; i<nparams ; i++) {
      sprintf_s ( msg, "  %12d", i+1 ) ;
      if (hessian[i*nparams+i] > 0.0)
         d = sqrt ( hessian[i*nparams+i] ) ;          // ‘Standard deviation’ of one parameter
      else
         d = 0.0 ;
      for (k=0 ; k<nparams ; k++) {
         if (hessian[k*nparams+k] > 0.0)
            d2 = sqrt ( hessian[k*nparams+k] ) ;   // ‘Standard deviation’ of the other
         else
            d2 = 0.0 ;
         if (d * d2 > 0.0) {
            corr = hessian[i*nparams+k] / (d * d2) ;
            if (corr > 1.0)                                     // Keep them sensible
               corr = 1.0 ;
            if (corr < -1.0)
               corr = -1.0 ;
            sprintf_s ( msg2 , " %12.3lf", corr ) ;
            }
         else
            strcpy_s ( msg2 , "        -----" ) ;            // If either diagonal is zero, corr is undefined
         strcat_s ( msg , msg2 ) ;
         }
      fprintf ( fp , "\n%s", msg ) ;
      }

同样,如果你想看打印出来的样本,请看第 118 页。

我们现在来看看我认为最有趣、最有启发性的成果。Hessian 矩阵的特征向量定义了作为参数函数的交易系统性能的水平曲线椭圆的主轴。特别地,最大特征值对应的特征向量是参数变化引起性能变化最大的方向,即灵敏度最大的方向。最小特征值对应的特征向量是导致性能变化最小的方向,即灵敏度最小的方向。

我们找到这两个极端的特征值。除非我们至少有两个正的特征值,否则继续下去是没有意义的。当然,如果只有一个,一些用户可能想继续,但是如果情况很糟糕,只有一个正特征值,交易系统是如此不稳定,整个过程可能是毫无意义的。

   for (k=nparams-1 ; k>0 ; k--) { // Find the smallest positive eigenvalue
      if (evals[k] > 0.0)
         break ;
      }

   if (! k)
      goto FINISH ;

为了更容易理解,我选择缩放方向,使得每个方向向量中的最大元素为 1.0。计算比例因子,然后打印输出。

   fprintf ( fp, "\n             Max         Min\n" ) ;

   lscale = rscale = 0.0 ;  // Scale so largest element is 1.0\.  Purely heuristic.

   for (i=0 ; i<nparams ; i++) {
      if (fabs ( evect[i*nparams] ) > lscale)
         lscale = fabs ( evect[i*nparams] ) ;
      if (fabs ( evect[i*nparams+k] ) > rscale)
         rscale = fabs ( evect[i*nparams+k] ) ;
      }

   for (i=0 ; i<nparams ; i++) {
      sprintf_s ( msg, "       Param %d %10.3lf %10.3lf",
         i+1, evect[i*nparams] / lscale, evect[i*nparams+k] / rscale) ;
      fprintf ( fp , "\n%s", msg ) ;
      }

第 119 页给出了一个真实交易系统的输出示例。

参数灵敏度曲线

前面的章节介绍了快速简单的方法来估计训练偏差和发现参数之间的关系。这两种方法都很粗糙,容易出现重大错误,而且它们的信息对于负责任的交易系统的开发并不重要。尽管如此,我还是喜欢在我的开发系统中包含这些功能,因为它们几乎没有增加计算开销,而且它们的结果几乎总是很有趣。但请理解,这一部分的主题是至关重要的,必须被视为任何交易系统开发人员的最低限度的尽职调查。

数字是展示信息的绝佳方式,但没有什么能胜过一张图片。特别是,我们应该检查交易系统性能的图表,因为参数围绕它们的计算最优值变化。我们希望看到平滑的曲线,尤其是在最佳值附近。在更远的值上的反弹不是很重要,但是在最佳值附近,我们想要平滑的行为。如果最优值处于窄峰,我们的交易系统就会不稳定;当市场条件不可避免地演变成久而久之时,曾经的最佳价值将跌落悬崖,不再接近最佳。此外,如果我们在最佳值附近有明显的多个峰值,这是一个迹象,表明系统可能由于幸运地锁定了一些好的交易和/或避免了一些坏的交易而获得了很高的性能。一个参数的微小变化交替地在这些特殊交易中获利或亏损,这意味着运气在系统回溯测试中扮演了过度的角色。

另一方面,如果我们看到交易系统的性能随着参数偏离其训练值而缓慢而平稳地从最佳值下降,我们知道系统对扰动的反应很温和,可能对运气的变化有很好的免疫力,并且可能在未来的一段时间内保持稳定。

计算这些灵敏度曲线几乎非常简单,但是我们还是要检查代码。如果可能的话,在实践中最好在计算机屏幕上显示这些平滑的曲线。但是为了简单起见,这里我使用了笨拙但实用的方法,将直方图打印到文本文件中。这不是最优雅的方法,但是很简单,而且很有效。

我们将要看到的例程的代码在 SENSITIV.CPP 中。子例程的调用如下:

int sensitivity (
   double (*criter) ( double * , int ) , // Crit function maximized
   int nvars ,                                     // Number of variables
   int nints ,                                      // Number of first variables that are integers
   int npoints ,                                  // Number of points at which to evaluate performance
   int nres ,                                      // Number of resolved points across plot
   int mintrades ,                              // Minimum number of trades
   double *best ,                              // Optimal parameters
   double *low_bounds ,                  // Lower bounds for parameters
   double *high_bounds                  // And upper
   )

标准函数与我们在差分进化中看到的相同,采用试验参数的向量和所需的最小交易数。我们有nvars个参数,其中第一个nint是整数。每个参数将在其low_boundhigh_bound范围内以等间距的npoints值进行评估。水平直方图将具有从零到最大性能值的nres离散值。负性能被绘制成好像它们是零。我们还需要最优参数值的best向量。

我们分配内存并打开结果将被写入的文本文件。然后我们开始处理每个参数的主循环。该循环的第一步是将所有参数设置为它们的最佳值,以便每次只有一个参数偏离其最佳值。

   vals = (double *) malloc ( npoints * sizeof(double) ) ;
   params = (double *) malloc ( nvars * sizeof(double) ) ;

   fopen_s ( &fp , "SENS.LOG" , "wt" ) ;

   for (ivar=0 ; ivar<nvars ; ivar++) {

      for (i=0 ; i<nvars ; i++)
         params[i] = best[i] ;

整数和实数参数是分开处理的,整数稍微复杂一些。下面是这段代码。整数值应该在浮点参数向量中精确表示,但是我们采取了异常不会导致问题的廉价保险。

      if (ivar < nints) {

         fprintf ( fp , "\n\nSensitivity curve for integer parameter %d (optimum=%d)\n",
                     ivar+1, (int) (best[ivar] + 1.e-10) ) ;

         label_frac = (high_bounds[ivar] - low_bounds[ivar] + 0.99999999) / (npoints - 1) ;
         for (ipoint=0 ; ipoint<npoints ; ipoint++) {
            ival = (int) (low_bounds[ivar] + ipoint * label_frac) ;
            params[ivar] = ival ;
            vals[ipoint] = criter ( params , mintrades ) ;
            if (ipoint == 0  ||  vals[ipoint] > maxval)
               maxval = vals[ipoint] ;
            }

         hist_frac = (nres + 0.9999999) / maxval ;
         for (ipoint=0 ; ipoint<npoints ; ipoint++) {
            ival = (int) (low_bounds[ivar] + ipoint * label_frac) ;
            fprintf ( fp , "\n%6d|", ival ) ;
            k = (int) (vals[ipoint] * hist_frac) ;
            for (i=0 ; i<k ; i++)
               fprintf ( fp , "*" ) ;
            }
         }

在前面的代码中,确保测试和打印尽可能等距的整数值有点棘手。我们将label_frac计算为每一步到下一点的参数值增量。如果你不理解计算,在边界值处测试公式。找到测试点中的最大性能后,计算直方图比例为hist_frac。然后,我们传递保存的性能值,计算要打印的字符数,并这样做。

实数参数稍微容易一些,因为我们不用担心测试严格的整数值。这是代码。不需要任何解释,因为它是刚刚显示的整数代码的简化版本。

      else {

         fprintf ( fp , "\n\nSensitivity curve for real parameter %d (optimum=%.4lf)\n", ivar+1,
                      best[ivar] ) ;

         label_frac = (high_bounds[ivar] - low_bounds[ivar]) / (npoints - 1) ;
         for (ipoint=0 ; ipoint<npoints ; ipoint++) {
            rval = low_bounds[ivar] + ipoint * label_frac ;
            params[ivar] = rval ;
            vals[ipoint] = criter ( params , mintrades ) ;
            if (ipoint == 0  ||  vals[ipoint] > maxval)
               maxval = vals[ipoint] ;
            }

         hist_frac = (nres + 0.9999999) / maxval ;
         for (ipoint=0 ; ipoint<npoints ; ipoint++) {
            rval = low_bounds[ivar] + ipoint * label_frac ;
            fprintf ( fp , "\n%10.3lf|", rval ) ;
            k = (int) (vals[ipoint] * hist_frac) ;
            for (i=0 ; i<k ; i++)
               fprintf ( fp , "*" ) ;
            }
         }
      }

在下一节中,我们将看到一个在实际应用环境中绘制参数灵敏度图的例子。

把这一切放在一起交易 OEX

我们现在提出一个程序,它结合了差分进化、廉价的训练偏差估计、廉价的参数关系计算和绘制参数灵敏度曲线。这个程序的源代码在 DEV_MA.CPP 中,交易算法是四参数阈值移动平均线交叉系统。读者用自己的交易系统替换这个系统应该没有问题。

交易系统

通常我不太关注本书例子中使用的交易系统,而是关注讨论中的技术。但是在这种情况下,交易系统与技术紧密相连,用户理解它是很重要的。在这里尤其如此,因为在 PARAMCOR 中计算的参数关系。当参数成比例缩放时,CPP 是最有意义的,所以如果您实现一个系统,请确保这样做。

该系统的原理是计算原木价格的短期和长期移动平均值。如果短期均线超过长期均线至少一个指定的多头阈值,第二天就做多头。如果短期移动平均线比长期移动平均线低至少一个指定的空头阈值,就建立空头头寸。否则,我们保持中立。因此,有四个参数:两个回顾和两个阈值。评估例程调用如下:

double test_system (
   int ncases ,                         // Number of prices in history
   int max_lookback ,             // Max lookback that will ever be used
   double *x ,                          // Log prices
   int long_term ,                    // Long-term lookback
   double short_pct ,              // Short-term lookback is this / 100 times long_term, 0-100
   double short_thresh ,         // Short threshold times 10000
   double long_thresh ,           // Long threshold times 10000
   int *ntrades ,                       // Returns number of trades
   double *returns                   // If non-NULL returns ncases-max_lookback bar returns
   )

只有一个可优化的参数是整数,即长期回看。短期回顾被指定为长期回顾的百分比。短阈值和长阈值被指定为实际阈值的 10,000 倍。这是因为在实践中,最佳阈值将非常小,并且使用该乘数将阈值提高到与其他两个参数相当的范围。如果我们用实际的阈值,参数。由于缩放比例的巨大差异,CPP 算法将变得几乎毫无价值。不过,其他一切都会好的。

如果需要,最后一个参数returns可以输入空值。但是如果为非空,则在那里放置单个的回车。STOC _ 偏差. CPP 中的廉价偏差估计例程需要这些信息

第一步是将指定的相应比例的参数转换成在这里有意义的值。读者们,如果你们用自己的交易系统代替这个系统,一定要注意这个相称的比例要求!如果使用的话,还要初始化总回报累积器、交易计数器和returns的索引。

   short_term = (int) (0.01 * short_pct * long_term) ;
   if (short_term < 1)
      short_term = 1 ;
   if (short_term >= long_term)
      short_term = long_term - 1 ;
   short_thresh /= 10000.0 ;
   long_thresh /= 10000.0 ;

   sum = 0.0 ;                     // Cumulate performance for this trial
   *ntrades = 0 ;
   k = 0 ;                             // Will index returns

穿越市场历史的主循环在这里。请注意,不管long_term如何,我们总是在同一根棒线上开始交易,以求一致。这很重要。计算短期移动平均线。

   for (i=max_lookback-1 ; i<ncases-1 ; i++) {   // Sum performance across history
      short_mean = 0.0 ;                                     // Cumulates short-term lookback sum
      for (j=i ; j>i-short_term ; j--)
         short_mean += x[j] ;

然后我们计算长期移动平均线,注意我们要利用已经对短期移动平均线做的求和。

      long_mean = short_mean ;           // Cumulates long-term lookback sum
      while (j>i-long_term)
         long_mean += x[j--] ;

      short_mean /= short_term ;
      long_mean /= long_term ;

将短期/长期均线差与阈值进行比较,并相应地计算下一根棒线的回报率。请注意,我选择用比率而不是差来定义差异。我更喜欢这种标准化,尽管它是不对称的,但是请不要反对,特别是因为我们正在处理日志价格。实际上,这种差别是很小的。

      change = short_mean / long_mean - 1.0 ;             // Fractional diff in MA of log prices

      if (change > long_thresh) {                                     // Long position
         ret = x[i+1] - x[i] ;
         ++(*ntrades) ;
         }

      else if (change < -short_thresh) {                           // Short position
         ret = x[i] - x[i+1] ;
         ++(*ntrades) ;
         }

      else
         ret = 0.0 ;

      sum += ret ;

      if (returns != NULL)
         returns[k++] = ret ;

      } // For i, summing performance for this trial

   return sum ;
}

链接标准例程

将交易系统的参数化嵌入到差分进化例程或任何其他通用例程中是糟糕的编程风格。嵌入参数mintrades已经够糟糕了,但是因为这是一个交易系统应用程序,而且这是一个常见的参数,我觉得这样做是有道理的。但是剩下的参数,可能会随着不同的交易系统而显著变化,必须作为一个真实的向量来提供。因此,我们需要一种方法将通用标准例程映射到最终的性能评估器,并传递多余的参数。我一直使用的标准方法是让讨厌的参数是静态的,并使用一个标准包装器。特别是,我在程序的顶部做静态声明,并在需要之前设置它们。此处还显示了包装:

static int local_n ;
static int local_max_lookback ;
static double *local_prices ;

double criter ( double *params , int mintrades )
{
   int long_term, ntrades ;
   double short_pct, short_thresh, long_thresh, ret_val ;

   long_term = (int) (params[0] + 1.e-10) ;     // This addition likely not needed
   short_pct = params[1] ;
   short_thresh = params[2] ;
   long_thresh = params[3] ;

   ret_val = test_system ( local_n , local_max_lookback , local_prices , long_term ,
                                        short_pct , short_thresh , long_thresh , &ntrades ,
                                        (stoc_bias != NULL) ? stoc_bias->expose_returns() : NULL ) ;

   if (stoc_bias != NULL  &&  ret_val > 0.0)
      stoc_bias->process () ;

   if (ntrades >= mintrades)
      return ret_val ;
   else
      return -1.e20 ;
}

上一页的代码很好地演示了一种使用通用包装器的简洁方法,这种包装器将像diff_ev()这样的工具箱例程与交易系统之间的差异以及像价格历史和交易开始棒线这样的讨厌参数隔离开来。我们只需要确保在调用criter()例程之前local_静态被设置为正确的值。这个包装器还负责检查是否满足最低交易要求,并处理StocBias处理(第 92 页)。

我们将跳过平庸的市场阅读代码;见 DEV_MA。CPP 了解详情。在市场被读取之后,我们初始化传递讨厌的参数的静态数据,我们为四个可优化的参数设置界限,并且我们设置一个最小交易计数。创建StocBias对象,使用差分进化进行优化,并计算偏差,我们可以从最佳性能中减去偏差,以获得估计的真实性能。最后,我们做敏感性测试。

   local_n = nprices ;   local_max_lookback = max_lookback ;
   local_prices = prices ;

   low_bounds[0] = 2 ;
   low_bounds[1] = 0.01 ;
   low_bounds[2] = 0.0 ;
   low_bounds[3] = 0.0 ;

   high_bounds[0] = max_lookback ;
   high_bounds[1] = 99.0 ;
   high_bounds[2] = max_thresh ;  // These are 10000 times actual threshold
   high_bounds[3] = max_thresh ;

   mintrades = 20 ;

   stoc_bias = new StocBias ( nprices - max_lookback ) ;   // This many returns

   diff_ev ( criter , 4 , 1 , 100 , 10000 , mintrades , 10000000 , 300 , 0.2 , 0.2 , 0.3 ,
                  low_bounds , high_bounds , params , 1 , stoc_bias ) ;

   stoc_bias->compute ( &IS_mean , &OOS_mean , &bias ) ;
   delete stoc_bias ;
   stoc_bias = NULL ;  // Needed so criter() does not process returns in sensitivity()
   sensitivity ( criter , 4 , 1 , 30 , 80 , mintrades , params , low_bounds , high_bounds ) ;

适用于交易 OEX

从一开始到 2017 年年中,我使用 S&P 100 指数 OEX 运行了 DEV_MA 程序。图 4-1 显示了程序的主要输出。我们看到总对数回报是 2.671,最优参数(长回看,短回看占长回看的百分比,10,000 倍短阈值,10,000 倍长阈值)也显示出来。剩下的四行数字来自于StocBias操作,预期收益 2.3489 是优化收益 2.6710 减去偏差 0.3221。

img/474239_1_En_4_Fig1_HTML.jpg

图 4-1

OEX DEV _ MA 的主要输出

图 4-2 显示了 PARAMCOR 产生的输出。CPP 算法(第 96 页)。检查变异排。在一个极端情况下,我们看到短期回顾和短期阈值在其最佳值附近对性能的影响最小,长期回顾的影响也稍小。突出的参数是长阈值,其具有极端的敏感性。即使其值发生微小的变化,也会对性能产生极大的影响。

img/474239_1_En_4_Fig2_HTML.jpg

图 4-2

OEX DEV _ MA 的 PARAMCOR 输出

短回望和短阈值之间的相关性为–0.679,表明一个阈值的变化可以被另一个阈值的相反变化所抵消。我无法解释这个意外的现象。

这些观察结果得到了最大灵敏度方向的支持,该方向几乎完全受长阈值支配。主导权重是–1 而不是 1 的事实是不相关的;这是一个方向,它可能指向任何一个方向。

最小影响的方向更有趣,它证实了前面提到的相关性。我们看到,移动参数——使短期回顾占长期回顾的百分比在一个方向上,而短期阈值几乎在相反方向上——是所有可能的参数变化中对性能产生最小影响的参数变化方向。太迷人了。

图 4-3 至图 4-6 显示了四个参数的灵敏度曲线。请注意,特别是对于两个阈值参数,之前报告的变化与图明显一致,其中参数 3 具有最小灵敏度,参数 4 具有最大灵敏度。

img/474239_1_En_4_Fig6_HTML.png

图 4-6

长阈值灵敏度

img/474239_1_En_4_Fig5_HTML.png

图 4-5

短阈值灵敏度

img/474239_1_En_4_Fig4_HTML.png

图 4-4

短回送百分比的灵敏度

img/474239_1_En_4_Fig3_HTML.png

图 4-3

长回送的灵敏度

五、评估未来表现 I:无偏交易模拟

这一章的标题是乐观的,也许是可耻的。众所周知,金融市场变化无常。它们是不稳定的(它们的统计特性会随着时间而变化),易受不可预见的外部冲击的影响,偶尔会受到没有明显原因的剧烈波动的影响,并且通常是不合作的。认为我们可以在很大程度上评估交易系统的未来表现的想法是可笑的。但是我们经常做的是识别那些预期未来回报非常低的交易系统,所以我们可以保持警惕。自然地,我们真正喜欢的是识别系统的能力,这些系统具有很高的未来回报的可能性。而我们也可能偶尔运气好,享受这种难得的奖励。试试也无妨。但是读者必须明白,这一章的真正目的是使用严格的统计方法来剔除那些表面上有希望的系统,这些系统实际上应该被丢弃,或者至少在投入实际货币使用之前进行修改。

样本内和样本外性能

开发者很少会在确切地说构想出一个交易系统,也就是它的最终形式。绝大多数时候,开发者会假设一个家族的交易系统。该家族的任何特定成员将由一个或多个参数的值来定义。举一个相当平凡的例子,开发商可能假设,如果最近市场价格的短期移动平均线越过长期移动平均线,这个市场的趋势已经改变,是时候做多了,如果相反的情况发生,就做空。但是短期长期是什么意思呢?在我们有一个实际的交易系统之前,每个均线的回望周期必须被指定。

我们如何选择有效的回顾期?显而易见的方法是,在计算机时间和其他资源允许的情况下,尝试尽可能多的值,并选择任何一对长期和短期回顾给出最佳结果。我将附带说明,我们用来定义“最佳结果”的标准可能很重要,我们将在后面讨论。现在,假设我们有一种衡量交易系统性能的方法,允许我们选择最好的参数。

如果我们处理的是完美的、无噪声的数据,那么我们通过优化数据集的短期和长期回顾所获得的性能结果通常会反映出我们将来会看到的结果。不幸的是,金融市场的数据就像它得到的一样嘈杂。实际上,市场价格被噪音所主导,只有很少的真实模式隐藏在噪音之下。

这种嘈杂情况的含义是,我们的“最优”参数最终以这样一种方式被选择,即我们的交易系统符合训练集中的噪音模式,甚至比真实的市场模式更好。根据定义,噪音不会重复。结果,当我们把有前途的交易系统投入使用时,我们可能会发现它几乎或完全没有价值。这在任何应用中都是一个问题,但在市场交易中尤其具有破坏性,因为金融市场是由噪声主导的。

交易系统运行的这两种环境有标准的名称。我们用来优化系统参数(如移动平均交叉系统中的短期和长期回顾)的数据集称为样本内 ( )数据集。任何没有参与参数优化的数据集被称为样本外 ( OOS )。IS 表现超过 OOS 表现的程度称为训练偏差。本章主要致力于量化和处理这种影响。

值得一提的是,训练偏差可能是由至少两种完全不同的效应引起的。我们已经讨论了最“著名”的原因,学习不可重复的噪声模式,就好像它们是真实的市场价格模式。当模型过于强大时,这可能会特别严重,这种情况称为过度拟合。一个更微妙但同样有问题的原因是训练(样本)数据中模式的代表性不足。如果训练交易系统的市场历史不包含未来可能遇到的每个可能的价格模式的足够的表示,那么当它们最终被遇到时,我们不能期望系统正确地处理被忽略的模式。因此,利用尽可能多的市场历史发展我们的交易系统符合我们的利益。

证明训练偏差的 TrnBias 计划

我的网站包含一个小型控制台应用程序的源代码,该应用程序演示了刚刚描述的原始移动平均交叉系统的训练偏差。读者可以很容易地修改它,以试验各种优化标准。完整的源代码在 TRNBIAS.CPP 中。

我不会在这里详细探讨这个程序,因为它被很好地注释了,并且对于任何想为自己的目的修改它的人来说应该是可以理解的。但是,我将简单地讨论它的操作。

从命令行调用该程序,如下所示:

TrnBias Which Ncases Trend Nreps

Which指定优化标准:

  • 0 =平均每日回报

  • 1 =利润系数(赢的总和除以输的总和)

  • 2 =原始夏普比率(平均回报率除以回报率的标准差)

Ncases是交易天数。

Trend是变化趋势的强度。

Nreps是重复的次数,通常至少几千次。

该程序生成一组Ncases对数日价格。价格由随机噪音加上每 50 天反转一次的交替趋势组成。这种趋势的强度被指定为一个小正数,可能是 0.01(弱)到 0.2(强)左右。0.0 的Trend表示价格序列完全随机。然后,测试一整套长达 200 天的移动平均回拨测试,以找到短期和长期回拨的组合,从而获得最佳的样本内表现。用户指定判断该性能的标准。最后,使用相同的趋势强度,生成一组新的价格。它的分布与样本内集合相同,但它的随机分量不同。使用优化的短期和长期回顾,将移动平均交叉规则应用于该 OOS 数据集,并计算其平均日回报率。

这个过程重复Nreps次,样本内和样本外的平均日收益率在重复中取平均值。样本内值减去样本外值就是训练偏差。这三个量被报告给用户。

如果你用这个程序做实验,你会发现一些和我在实际交易系统开发中看到的效果相似的效果。

  • 如果您有大量的案例,选择优化标准的影响相对较小。事实上,所有这三种不同的方法都倾向于为大型数据集提供相同的最佳回顾,而不管趋势的强度如何。

  • 如果数据集很小,优化标准对结果有很大的影响。

  • 通过优化利润因子来获得最大的 OOS 平均日收益是一个轻微但不普遍的趋势。我在现实生活的发展中也看到了同样的效果。

  • 在我运行的几乎每个测试中,当优化平均每日回报时,平均每日回报的训练偏差最高(最差)。几乎可以肯定,这是因为除了间接的风险(损失)之外,平均日回报率并不考虑其他因素。利润因子和夏普比率都有利于一致、可靠的回报,使它们成为交易系统的最佳优化标准。此外,利润因素几乎总是有最小的训练偏差。这是我最喜欢的优化标准。

读者可能希望修改 TrnBias 程序,以纳入他们假设的价格模式类型、他们的交易系统规则和他们首选的性能标准,以研究他们情况下训练偏差的性质。

选择偏差

Agnes 领导着一家公司的交易系统开发部门。她手下有两个人,每个人都负责根据到目前为止的历史数据独立开发一个盈利的交易系统。很快,John 向她展示了出色的回溯测试结果,而 Phil 的结果虽然不错,但并不令人印象深刻。很自然地,她选择了约翰的交易系统,并用真钱来交易。

几个月后,他们的交易资本基本上消失了。全军覆没。约翰的完美系统失败了。阿格尼丝彻底地斥责了约翰,但她还是被解雇了,他们请来了玛丽来代替她。

Mary 检查了 John 的系统,并立即发现了问题:他使用了一个非常强大的预测模型,该模型在模拟市场数据中固有的噪音方面做得非常好。此外,因为 Agnes 给了这两个人到目前为止完整的市场历史,他们在开发他们的交易系统时都用到了。他们都没有花费任何精力去评估他们系统的样本外性能。因此,他们都不知道他们的交易系统有多好地捕捉了真实的市场模式,而不仅仅是模拟噪音。

在拍了拍他们的双手后,她告诉他们 Agnes 忽略的一个至关重要的原则,在这个原则中,他们是同谋:当从竞争系统中选择时, 总是将选择标准基于样本外结果 ,忽略样本内结果。

为什么呢?原因是,如果选择是基于结果,选择过程有利于过度拟合模型。如果模型 A 主要捕捉真实的市场模式,而模型 B 不仅捕捉这些模式,而且在捕捉噪音模式方面也做得很好,那么模型 B 将在结果上胜过模型 A 并被选中,只是在噪音不再重复用于真实交易时失败。

这个原则如此重要,以至于玛丽明智地选择保留最近一年的市场历史。她给了 John 和 Phil 截止到当前日期前一年的市场数据,并告诉他们再试一次。

一段时间后,他们都带着他们的系统来到她面前,自豪地炫耀他们惊人的成果。(这些家伙就是不学无术!)所以,她用约翰的交易系统,用她隐瞒的那一年的数据进行测试。还算体面。这让她很高兴,因为她刚刚观察到的结果是对约翰的系统未来所能做的真正公平、公正的估计。那一年的测试对他的系统开发没有任何作用,所以它不能影响他的选择或训练程序,因此它没有乐观的偏见。这正是她对交易系统的真实质量做出明智决定所需要的信息。

插曲:无偏到底是什么意思?

让我们暂时靠边站,对刚刚出现的术语无偏做一个简单直观的澄清。我们假设一个假想的宇宙,有无限多的约翰,在无限多不同的嘈杂的市场历史中运作,每个约翰都根据自己宇宙独特的嘈杂的市场历史发展自己的交易系统。本例中的无偏(通常也是如此),我们的意思是,平均而言*,这些不同的约翰制作的交易系统的 OOS 结果既不会高估也不会低估实际预期的未来表现。由于宇宙间的随机变化,几乎可以肯定的是,任何单个约翰产生的交易系统都会高估或低估其 OOS 结果的预期未来表现。“不偏不倚”并不意味着而不是我们可以预期未来会有同样的表现。在一个随机的宇宙中,这种希望太大了。约翰的交易系统的 OOS 性能会高估或低估系统的实际预期未来性能。但是无偏确实意味着无论如何都不会有固有的偏见。由于已经讨论过的训练偏倚,样本内结果具有强烈的乐观偏倚。样本外结果没有这样的偏差。粗略地说,我们可以说约翰的 OOS 结果高估和低估了未来的表现。这是我们能做的最好的。*

*#### 选择偏差,续

好了,足够的转移注意力;让我们回到正在进行的故事。我们有约翰的 OOS 演出。Mary 现在继续测试 Phil 开发的交易系统,这个系统基于她从这两个人那里得到的最近一年的数据。这是 OOS 的表现,就像约翰的表现一样,也是不偏不倚的,而且略胜约翰一筹。所以,她明智地选择了菲尔的系统进行交易。

我们现在来看这一节的关键点:公司现在交易的菲尔系统的 OOS 表现是乐观偏差!啊?这怎么可能呢?刚才,菲尔的 OOS 表现是他的系统的预期未来表现的公正措施。但是现在选择了交易,投入工作,那个业绩数字突然就偏了?那没有意义!

其实确实有道理。我们正在经历的被称为选择偏差。当玛丽检查了 OOS(约翰和菲尔)的表演并选择了更好的表演者时,它就开始发挥作用了。选择一个而不是另一个的行为引入了乐观偏见。玛丽刚刚测量的菲尔系统的 OOS 性能现在平均来说会高估他的系统的预期未来性能。

一眨眼的功夫,怎么会发生如此离奇的从不偏不倚到有偏不倚的转变?这是因为这两个竞争系统(约翰的和菲尔的)的 OOS 表现都受到两种不同效果的影响:真正的技巧和狗屎运。这两个系统无疑将基于轻微的(或很大的!)不同的正宗花样。与另一个系统相比,一个系统中的随机噪声更像该系统的真实模式。因此,在其他条件相同的情况下,选择更好的系统往往会有利于更幸运的系统。如果两个系统具有相等(尽管不可测量)的真实功率,那么更幸运的系统将具有更好的 OOS 性能,因此被玛丽选择。只有当他们真正的力量大相径庭,淹没了运气,真正更好的系统几乎肯定会被选中。

根据定义,噪音不会重复。任何有利于一个系统而不利于另一个系统的好运都将消失。只要我们在个体的基础上关注每一个系统,那些假想的宇宙中的好运和厄运就会达到平均,OOS 的表现就会是无偏的。但是,当我们比较两个或更多竞争系统的无偏性能并选择更好的系统时,运气不再平均;好运受到青睐,因此我们引入了选择偏差。这在现实生活中是巨大的。被警告。

现在很明显,如果 Mary 想要对她选择的交易系统的未来表现有一个公正的估计,她必须提供更多的数据。她需要拿出两年(??)的数据,而不是只保留一年(或者她想要的任何时间段)。她向 John 和 Phil 提供了截止到当前日期前两年的市场历史记录。当他们向她展示他们的系统时,她在他们培训年之后的数据年测试他们的系统,该数据年在当前日期之前一年结束。基于竞争系统在“第一个 OOS”年的表现,她选择了最好的系统。然后,她用最近一年的数据来检验这种选择,这一年可能被称为“第二个 OOS”年。这提供了对所选系统性能的无偏估计。这个估计不仅不受训练偏差的影响,而且也不受她从竞争者中选择最佳系统所导致的选择偏差的影响。

serbias 计划

在您跳过这一部分之前,请允许我鼓励每个人学习这一材料,即使是那些对使用或修改 SelBias 程序没有兴趣的人。原因在于,对选择偏差演示程序如何工作的描述将有助于强化前面章节中提出的有些违反直觉的观点。选择偏差的概念对许多开发人员来说是如此陌生,但又如此重要,以至于很难过分强调这个主题。即便如此...

我的网站包含一个小型控制台应用程序的源代码,它演示了刚才描述的原始移动平均交叉系统的选择偏差。读者可以很容易地修改它,以试验各种交易系统和优化标准。完整的源代码在 SelBias.cpp 中。

从命令行调用该程序,如下所示:

SelBias Which Ncases Trend Nreps

Which指定优化标准:

  • 0 =平均每日回报

  • 1 =利润系数(赢的总和除以输的总和)

  • 2 =原始夏普比率(平均回报率除以回报率的标准差)

Ncases是交易天数。

Trend是变化趋势的强度。

Nreps是重复的次数,通常至少几千次。

该程序生成一组Ncases对数日价格。价格由随机噪音加上每 50 天反转一次的交替趋势组成。这种趋势的强度被指定为一个小正数,可能是 0.01(弱)到 0.2(强)左右。0.0 的Trend表示价格序列完全随机。

前面讨论的 TrnBias 程序采用了双边(多头和空头)交易系统。但是这部分的 SelBias 程序把它分成两个独立的交易系统,一个严格做多,另一个严格做空。

对于这两个竞争系统中的每一个,我们都测试了一套完整的测试移动平均回看,范围长达 200 天,以找到一个短期和长期回看的组合,给出每一个的最佳样本内性能。对于每个系统(仅长系统和仅短系统),分别找到这些最佳回顾。用户指定判断该性能的标准。

一组新的价格,使用相同的趋势强度,被生成。它的分布与样本内集合相同,但它的随机分量不同。这个数据集对应于上一节中提到的“第一 OOS”数据集。在 Mary-John-Phil 的例子中,这将是给 John 和 Phil 的数据之后的一年。针对两个竞争系统的移动平均交叉规则应用于该 OOS 数据集,对每个系统使用优化的短期和长期回顾。对于这个新的数据集,计算每个系统的平均每日回报,为我们提供两个系统的未来性能的无偏估计。

然后,我们生成第三个独立的数据集,之前称为“第二 OOS”数据集。在选择了较优的模型之后,在第三个数据集上评估两个竞争模型中在先前数据集上表现最好的那个,以提供性能的无偏估计。选择偏差是获胜模型在第二个(第一个 OOS)数据集上的性能减去其在第三个(第二个 OOS)数据集上的性能。

这个过程重复Nreps次,两个竞争系统的样本内和样本外平均日收益、大 OOS 收益和选择偏差在重复中平均。每个竞争者的样本内值减去样本外值就是训练偏差。每个竞争者都有自己的训练偏差,但只有一个选择偏差。这些平均数量被报告给用户,同时还有选择偏差的 t 值。

向前行走分析

大多数交易系统开发人员都熟悉使用 walkforward 分析来估计未来的表现。尽管这种算法无处不在,我们还是在这里提出它,既是为了澄清任何误解,也是为了指出该算法最常用版本中的几个潜在缺陷。

walkforward 分析背后的思想是,给定一个历史数据集,我们模拟一个交易系统在该市场历史中实时执行(不知道未来)时的表现。换句话说,在任何一个特定的历史时刻,我们都有所有可利用的市场历史,直到并包括那个特定的时间,我们假装不知道那个时间以后的市场价格。我们使用特定时间的数据设计我们的交易系统(通常通过优化参数),然后测试这个交易系统在最近的未来时间段(即 OOS)的表现。这模拟了我们的系统在那个历史时期的真实生活中的表现。我们暂时把未来的表演藏在某个地方。然后,我们及时向前移动一切,重复这个过程,就像一个真正的交易者在不断更新交易系统以跟上不断变化的市场条件时所做的那样。当到达历史数据的末尾时,我们汇集所有的单个 OOS 结果,并计算我们想要的任何性能度量。该算法的最基本版本可以表述如下。稍后会出现更高级的版本。

  1. OOS_START设置为用户期望的测试开始日期栏。

  2. 基于期望的回望期的市场历史创建交易系统,该回望期刚好在到OOS_START之前的结束。

  3. 在从OOS_START开始的期望时间段NTEST内执行交易系统。保存系统的性能。注意NTEST不需要固定。例如,我们可能想在一个日历年内做日内交易,所以NTEST将取决于被测试的一年内的交易天数。

  4. 如果还有更多市场数据,将OOS_START前进NTEST并返回步骤 2。

当前面的算法完成时,我们检查在步骤 3 中保存的每次循环的性能结果。大多数人将单个这样的传球称为折叠,我们偶尔会使用这个术语。

请注意,由于该算法的构造方式,合并的 OOS 结果是连续的(没有缺失数据),并且它们按照如果该过程是真实生活而不是模拟时它们将会发生的顺序出现。这意味着,即使订单依赖的性能统计数据,如下降,可以合法计算。

即使在交易系统投入使用后,我们也可能想继续进行这种测试。通过这种方式,我们可以跟踪其正在进行的性能,以确定系统是否正在恶化(这是经常发生的事情!).在这种情况下,我们还有一个考虑。连续性假设第二步,交易系统的创建,可以足够快地进行下一个交易决策。如果我们在做日末交易,我们可能会在一夜之间重新训练系统。另一方面,如果我们在日间和夜间交易中进行价格点的日内交易,我们必须定义折价率,以便在市场空闲时(如周末)重新创建系统。实际上,这很少成为问题,因为我们几乎总能找到足够长的空闲时间来重新训练系统。但是关键的一点是如果我们希望进行持续的评估,我们必须使用在实时使用中强加给我们的相同粒度来执行开发走查分析

为了清楚起见,假设我们的系统相对于交易速度来说训练得很慢,以至于在实际使用时必须在周末更新参数。在这种情况下,如果我们想要评估正在进行的绩效(总是明智的!),那么在开发期间,我们也应该使用周一到周五的块作为折叠来进行我们的 walkforward 分析。这样,实时结果可以与历史结果相媲美。

不明显的 IS/OOS 重叠造成的未来泄漏

开发交易系统的一个流行而强大的方法是基于市场历史建立一个由预测者目标组成的数据集。预测指标通常是 RSI、趋势线斜率等指标。目标是对未来市场价格变化的一些度量,例如从当前价格到 10 天后价格的变化。数据集中的每个案例都包含市场中单个实例的所有预测值和目标值,如日棒线或盘中棒线。然后,开发人员将该数据集提供给建模算法,该算法可能像普通的线性回归一样简单,也可能像深度信任网一样复杂。当预测模型已经被训练时,通过计算当天的一组预测值的模型预测,每天执行交易系统。基于模型所做的预测,可能会也可能不会在市场中建立头寸。这让我们能够利用复杂的现代机器学习技术来驱动我们的交易系统。

当指标的回顾期超过一个柱时,这种方法会出现一个严重的潜在问题,事实上总是如此,目标的展望期也超过一个柱,这通常是真的。当指标回顾一根以上的柱线时,它们具有序列相关性,因为相邻的数据库案例共享一个或多个市场价格观察结果。例如,假设我们有一个指标来衡量一个 50 棒回看周期的线性趋势线的斜率。当我们进入下一个案例时,两个相邻的案例共用 49 个小节。因此,趋势指标从一种情况到下一种情况变化很小。

目标也会产生同样的效果。假设我们的目标是从现在到十天以后的价格变化。当我们从一个案例前进到下一个案例时,这两个案例有九个共同的市场变化。这两种情况下的净市场变化在大多数时候非常相似。

现在考虑在分隔折叠的训练集的结尾和该折叠的测试集的开始的边界处发生了什么。如果指标和目标在中都具有序列相关性,那么训练集中的后期案例将与测试集中的早期案例相似,因为指标和目标都不会有太大变化。结果是关于测试集的信息已经泄漏到训练集中,这意味着假定公平的 OOS 测试现在是乐观的,因为在训练时,我们将有一些关于测试集的信息进入优化过程。

这种情况的含义是,我们必须通过省略训练集块末尾的一些情况来将训练集块与测试集块分开,这些情况将被未来的泄漏所污染。我们省略了多少?我们找到指标回顾和目标展望的最小值并减去 1。当然,如果指标有不同的回顾,我们认为指标回顾是所有指标中最大的。

例如,假设我们有三个指标,分别是 30、40 和 50 棒线。我们的目标有一个 80 小节的前瞻。(30,40,50)的最大值是 50。50 和 80 的最小值是 50。因此,我们必须从每个训练集块的末尾省略 49 个小节。

这个公式从何而来?推导它对读者来说是一个简单但有教育意义的练习。假设我们要测试一个从小节 100 开始的 OOS 集合。选择一个小的回顾和一个小的展望。对于 IS 和 OOS 区块,柱 99 处的潜在训练案例与柱 100 处的测试案例具有相同的价格吗?98 酒吧怎么样?在 IS 价格集或 OOS 价格集与第一个测试用例不再有共同价格之前,必须省略多少个结束用例?请记住,只有当既有一个指标,又有 IS 集和 OOS 集之间的目标股价历史时,我们才有问题,因为这就是测试集信息泄漏到训练集中的方式。如果一个或另一个(指标集或目标)对于两种情况是独立的,那么这些情况在 is 和 OOS 集之间没有共享偏见信息。

这里有两件事值得注意。首先,在几乎所有的实际情况下,指标回顾会超过目标展望,通常是很多。因此,前瞻是极限量。第二,如果目标前瞻只是一个小节,这是常见的情况,我们不必省略任何训练数据。在下一节中,我们将探讨仅预测一个小节的另一个优势。

多杆前视误差方差膨胀

在前面的部分中,我们看到,如果目标前瞻大于一个 bar,我们必须从训练集中移除最接近折叠边界的那些情况,以避免在应该是无偏的结果中出现灾难性的乐观偏差。在这一节中,我们将探讨多条前视的另一个问题,它有一个非常不同的解决方案。

偶然污染我们的市场数据的噪声中的随机变化将导致我们的步行前进性能数字也被随机变化污染;在汇集了所有 OOS 褶皱数据后,我们得到的性能数据,尽管如果我们做得对,是无偏的,但几乎肯定会高估或低估真实值。我们在第 125 页讨论术语无偏的含义时触及了这一点。自然,我们希望这个误差方差尽可能小。此外,负责任的开发人员会尝试用其他有用的信息来补充性能结果,例如,如果系统真的毫无价值,那么这么好的结果可能是通过随机的好运气获得的(一个 p 值)。我们甚至可能试图计算置信区间,或者进行从 283 页开始讨论的任何复杂的测试。

问题是,几乎所有我们想要进行的统计检验都要求检验所依据的观察值是独立的。(有一些测试不需要独立性,但是它们很难执行,并且通常价值可疑。)现在考虑当我们的前瞻大于一个小节时会发生什么。由于共享重叠的价格历史,相邻柱的目标值将紧密相关。因此,我们用来计算业绩统计的观察值(交易回报)并不是独立的。

这比只是模糊地“违反”各种统计检验的假设更严重。事实证明,这种违反是最糟糕的:测试变得反保守。这意味着,如果我们计算 p 值,计算出来的概率会太小,导致我们得出结论,我们的交易系统比实际情况要好。如果我们为了界定输赢而计算置信区间,那么得到的区间会太窄,真实区间可能比计算出来的要宽得多。

即使我们不进行任何统计测试(不负责任!)且只考虑无偏的 OOS 性能,我们仍然为使用多条前瞻而没有我们将很快描述的补救措施付出代价。造成我们所有问题的根本原因是,误差方差,即我们的无偏性能估计值围绕其真实值随机变化的程度,比单个交易回报独立的情况下要大。

当回报是独立的,并汇集成一个单一的性能统计,随机误差的回报往往会抵消。有些错误是积极的,有些是消极的,它们会相互抵消。但是当收益具有序列相关性时,他们取消的机会就少了。存在大量的正误差和大量的负误差,使得平滑消除更加困难。

结果是,即使 OOS 性能是无偏的,其令人烦恼的误差方差被夸大了。它对真实性能的过高估计或过低估计要比其他情况下更大。由于有很大的前瞻性,这种通货膨胀可能会很严重。在严重的情况下,误差方差可能大到使 OOS 性能估计几乎没有价值,尽管是无偏的。

解决这个问题的通常方法是使用仅一个条长的测试折叠,而不是将折叠推进一个条,而是通过前瞻来推进它们。这保证了 OOS 测试用例不会共享任何市场信息。例如,假设前瞻为 5,我们将在小节 100 开始 OOS 折叠。训练块将以条 95 结束,省略 4 个最近的情况以防止偏差。在对棒线 100 做出交易决定后,我们将把 OOS 折叠提前到棒线 105。

这种方法的另一个好处是,它模仿了大多数交易者在现实生活中的做法。大多数交易者不希望在前瞻期间继续建立他们的头寸,即使模型建议这样做。灾难性损失的风险太大了。

通用的向前行走算法

我们首先定义一些必须由用户指定的量。

  • LOOKBACK是用于计算指标的价格历史(包括当前棒线)的棒线数量。

  • LOOKAHEAD是用于计算目标的未来价格棒线的数量,不包括当前棒线。

  • NTRAIN是交易决策所基于的预测模型的训练集中使用的案例数(在忽略任何最近的案例之前)。我们从价格历史中的当前棒线往回看的总距离是LOOKBACK+NTRAIN–2。实际培训案例数为NTRAINOMIT

  • NTEST是每个 OOS 测试块中测试用例的数量。

  • OMIT是在LOOKAHEAD大于 1 时,为防止乐观偏差而从训练集中忽略的最近训练案例的数量。

  • EXTRA是除了NTEST之外,为下一个折叠提前的箱子数。换句话说,每一个折叠都将被数据集中的NTEST + EXTRA个案例推进,每个案例对应一个价格条。

正如前面几节所讨论的,如果LOOKAHEAD大于 1(这是我们应该尽可能避免的),如果我们要智能地向前行走,我们应该采取一些预防措施。

  1. 我们必须设置OMIT=min (LOOKAHEAD, LOOKBACK)–1 以避免致命的乐观偏见。这一点至关重要。

  2. 如果我们要避免交易结果中危险的序列相关性,我们必须设置NTEST = 1 和EXTRA=LOOKAHEAD–1。单独的序列相关不会引入偏差,但它会增加影响我们的 OOS 性能数据的误差方差,并且它排除了大多数传统的统计测试。

一般的 walkforward 算法如下:

  1. OOS_START设置为用户期望的测试开始日期栏。如果要使用整个数据集,设置OOS_START = NTRAIN

  2. 基于从OOS_STARTNTRAINOOS_STARTOMIT–1 的案例的市场历史创建交易系统。

  3. OOS_STARTOOS_START + NTEST - 1 执行交易系统。保存系统的性能。注意NTEST不需要固定。例如,我们可能想在一个日历年内做日内交易,所以NTEST将取决于被测试的一年内的交易天数。

  4. 如果还有更多市场数据(数据集中的案例),将OOS_START向前推进NTEST + EXTRA,然后返回步骤 2。

算法的 C++ 代码

文件重叠。我们很快将探讨的 CPP 包含了一个完全通用版本的 walkforward 算法的例子。下面是说明该算法的一段代码。我们将把它分成几个部分,分别解释每一部分。

完整的数据集在data中。该矩阵包含ncols列,最后一列是目标变量(通常是近期未来市场价格变化的度量),所有之前的列是预测值。这个矩阵有ncases行,每行对应一根棒线或一个交易机会。我们将当前训练集的起点trn_ptr初始化为数据集的起点。OOS 测试集从索引istart开始,刚好经过组成训练集的用户指定的ntrain案例。我们将在n_OOS统计 OOS 病例。

      trn_ptr = data ;      // Point to training set, which starts at the beginning of the data
      istart = ntrain ;       // First OOS case is immediately past training set
      n_OOS = 0 ;          // Counts OOS cases as they are processed

主折叠环如下所示。我们不必预先计算折叠的次数,而是让它保持开放,当我们用完历史数据时就停止前进。

      for (ifold=0 ;; ifold++) {
         test_ptr = trn_ptr + ncols * ntrain ;          // Test set starts right after training set
         if (test_ptr >= data + ncols * ncases )    // No test cases left?
            break ;                                                  // Then we are finished

在刚刚显示的循环开始时,我们将测试集指针设置为当前训练集开始之后的ntrain个案例。我们也可以使用istart来设置这个指针,但是我相信这个公式更清晰。如果测试集的开始超过了历史数据的结尾,我们就完成了。

find_beta()的调用是培训阶段,即将讨论。我们有ntrainomit训练案例,从trn_ptr开始。另外两个变量是由训练算法返回的优化参数。然后我们将nt设为 OOS 块中测试用例的数量。这通常是用户指定的数量ntest。但是最后一个 OOS 区块可能会更短,所以我们根据需要将其修剪回来。

测试循环对每种情况进行预测。如果预测是正面的,我们就做多,记录目标。否则,我们采取空仓(减去目标)。最后,我们推进训练和测试模块。

         find_beta ( ntrain - omit , trn_ptr , &beta , &constant ) ;
         nt = ntest ;
         if (nt > ncases - istart)                            // Last fold may be incomplete
            nt = ncases - istart ;
         for (itest=0 ; itest<nt ; itest++) {              // For every case in the test set
            pred = beta * *test_ptr++ + constant ; // test_ptr points to target after this line
            if (pred > 0.0)
               oos[n_OOS++] = *test_ptr ;
            else
               oos[n_OOS++] = - *test_ptr ;
            ++test_ptr ;                                          // Advance to indicator for next test case
            }
         istart += nt + extra ;                                // First OOS case for next fold
         trn_ptr += ncols * (nt + extra) ;               // Advance to next fold
         }  // Fold loop

依赖于日期的向前行走

基于日期执行前推分析是很常见的。例如,我们可能希望一次测试一年:我们在一个日历年的年底进行培训,并在下一年进行测试。然后,我们将培训和测试窗口提前一年,做同样的事情。这具有最小化模型必须被训练的次数的优点,当训练时间有问题时,这可能是好的。这也有助于直观地展示结果。可以使用刚刚显示的通用 walkforward 算法,根据测试年份中的小节数为每个折叠设置NTEST。而且很容易设置OMIT以防止乐观偏差。然而,如果我们要避免方差膨胀,我们必须使用一个一的LOOKAHEAD,方差膨胀排除了大多数的统计检验。

如果我们必须让LOOKAHEAD大于 1,并且我们还必须呈现年度或其他日期相关的前推结果,那么我们需要将每个测试周期分解成单棒测试(NTEST =1),每个测试周期由LOOKAHEAD分隔,并且将结果合并到每年中。如果为每个子文件夹重新训练模型,将获得最佳结果,但这不是必需的。

探索步行前进的失误

在本节中,我们使用一个小的控制台程序来研究当没有采取适当的措施来消除有害影响时,大于一巴的前视头的影响。这个程序叫 OVERLAP.CPP,完整的源代码在 OVERLAP.CPP 中,我们从调用参数表开始,然后详细解释程序的操作。我们将以一系列的实验来证明各种相关的问题。首先,从命令行调用该程序,如下所示:

  • nprices是市场历史中市场价格(棒线)的数量。为了获得最准确的结果,这个值应该很大,至少为 10,000。

  • lookback是用于计算指标的历史条形数。

  • lookahead是用于计算target的未来棒线数量。

  • ntrain是交易决策所基于的预测模型的训练集中使用的案例数(在省略任何案例之前)。训练案例的实际数量将是ntrain减去omit

  • ntest是每个 OOS 测试块中测试用例的数量。

  • omit是指当lookahead大于 1 时,为防止偏差而从训练集中忽略的最近训练案例的数量。

  • extra是除了ntest之外,为下一个折叠提前的箱子数。如果lookahead大于 1,那么ntest应该是 1,而extra应该是lookahead减 1,如果我们要避免交易结果中危险的序列相关性的话。

  • nreps是用于计算中位数 t 分数和后面描述的尾部分数的重复次数。为了得到准确的结果,它应该相当大,至少是 1001。

OVERLAP nprices lookback lookahead ntrain ntest omit extra nreps

首先,该程序计算的价格历史是随机游走的,完全不可预测。这意味着,平均而言,没有一个交易系统会提供零以外的预期回报。实际回报超过零的程度表明乐观偏见已经蔓延的程度。

生成价格历史后,将创建一个由单个指标和目标组成的数据库。该指标是价格历史在回望期的线性斜率。目标是未来的市场价格lookahead棒线减去当前价格。数据库中的每个案例对应于价格历史中的一个条形。

现在开始向前遍历,从数据库中的第一个案例开始。我们使用数据库中的第一个ntrain减去omit的案例作为训练集来计算线性回归方程(斜率和截距),用于从单个指标预测目标。然后通过应用这个回归方程来处理 OOS 块中的测试用例,以预测目标。如果预测是正面的,我们就做多,如果预测是负面的,我们就做空。

这种原始模型背后的原理是,至少在某些时间段内,市场将处于趋势跟踪模式,这将导致回归方程选取最近的价格趋势和未来趋势的延续之间的关系。当然,因为这个模拟中的市场价格是随机游走的,这种情况不会发生,除非是随机的,所以这个交易系统的预期收益应该是零。

在该 OOS 块中的所有测试用例被处理之后,通过将训练和测试窗口向前移动ntest加上extra个用例来推进该折叠,并且为该下一个折叠重复训练/测试。这种情况一直持续到所有价格用尽。

刚刚描述的整个过程,从市场价格历史生成开始,重复nreps次。对于每个复制,计算 OOS 交易结果的 t 值,并打印所有复制的 t 值中值。因为市场价格是随机游走的,我们期望这个中间值大约为零,但是我们将会看到不正确的前向游走结构将会导致乐观偏差。此外,对于每个复制,计算右尾 p 值(至少产生这种好结果的概率可能是完全靠运气获得的)。(实际上,为了简单起见,使用正常 CDF 代替 t CDF,但是当使用大量市场价格时,这是一个极好的近似值。)每当这个 p 值小于或等于 0.1 时,计数器递增。因为市场价格是随机游走的,我们预计这一事件会发生大约 0.1 次nreps重复。打印观察到的时间分数。我们将看到,如果 walkforward 的结构不正确,这个相当重要的 p 值将比 0.1 更频繁地出现。

这里有几个实验,展示了使用数据库/模型方法时不正确的前推的后果。在所有这些实验中,我们使用以下参数:

nprices = 50,000 使用长期价格历史可提供准确的结果。

lookback = 100 这对相对结果几乎没有影响。

lookahead = 10 任何大于 1 的值都表明存在问题。

这是相当不重要的。

nreps = 10001 较大的值会降低随机误差对结果的影响。

提醒一下,该程序将打印两个结果,OOS 回报的中值(跨重复)t-score 和与 t-score 相关的 p 值小于或等于 0.1 的这些重复的分数。因为市场是真正的随机游走(不可预测),我们预计前者在 0.0 附近,后者在 0.1 附近。任何超出这些期望值的增长都是由于不恰当的向前走而导致的危险的乐观偏差。

实验 1:来自 IS/OOS 的乐观偏倚与大测试集 重叠

ntest= 50

省略 = 0

多余的 = 0

对于这个测试,我们使测试集的大小与训练集的大小相同,并且不采取措施来对抗由超过 1 的前瞻引起的问题。如此大的测试集(与训练集大小相同)通常不会在现实生活中进行,因为测试集中的后期观察结果与训练集相差太远,市场中的任何不稳定性都会降低可预测性。但是,当模型需要大量训练时间,而我们又没有计算资源来更频繁地重新训练时,这可能是必要的。

我们发现中位数 t 值是 5.35,这是一个严重的偏差,t 值在 0.1 水平上显著的重复比例是 0.920,这是一个荒谬的偏差。

实验 2:来自 IS/OOS 的乐观偏差与 1-Bar 测试集 重叠

ntest = 1

省略 = 0

多余的 = 0

这是理想的测试和实时情况,在每次使用后重新训练模型。当交易日棒线时,这通常是可行的;我们每天晚上重新训练模型,以便对第二天做出预测。

我们发现 t 值的中位数是 74.64(!),极端偏倚,t-得分在 0.1 水平显著的重复分数为 1.0,完全失败。为什么这种偏差比之前的实验严重得多?原因是,当我们在每个折叠中都有一个大的测试集时,随着案例从训练集进一步深入到未来,重叠价格的数量会减少,从而减少乐观偏差。但是,当我们只测试紧接在训练集之后的单个案例时,我们有最大可能数量的重叠价格。

实验三:乐观偏差来自 IS/OOS 重叠,完全处理

ntest = 1

省略 = 9

多余的 = 0

在这个实验中,我们探讨了从第 131 页开始描述的主题,当多条先行产生不明显的未来泄漏时的乐观 OOS 性能。回想一下,目标前瞻是 10 个小节,因此为了完全消除将来的泄漏,我们必须省略 10-1=9 个最近的训练案例。我们在这个测试中就是这样做的。

我们发现中位数 t-score 为-0.023,除了测试中的随机变化外,该值为零。因此,我们已经完全消除了 OOS 结果中的偏差。然而,t-得分在 0.1 水平显著的重复比例是 0.314。当 OOS 的结果没有偏见时,这怎么可能发生呢?这是因为第 133 页讨论的方差膨胀。我们将在实验 5 中探讨这个问题。

实验 4:乐观偏差来自 IS/OOS 重叠,部分处理

ntest = 1

省略 = 8

多余的 = 0

这个测试与之前的实验完全相同,除了我们几乎忽略了足够多的训练案例。我们需要省略九种情况,但我们只省略了八种。

我们发现中位数 t 值是 1.88,虽然不算大,但仍然是个问题。即使我们已经非常接近了,但是由于未能省略所需数量的病例而导致的欺骗仍然会引入危险的乐观偏见。此外,t-得分在 0.1 水平上显著的重复比例为 0.588,比先前的实验更差。

实验五:乐观偏差和方差膨胀,全权处理

ntest = 1

省略 = 9

多余的 = 9

在这个实验中,我们处理了多条目标前瞻中涉及的两个问题。回想一下,目标前瞻是 10 个小节,因此为了完全消除未来的泄漏偏差,我们必须省略 10-1=9 个最近的训练案例。此外,为了避免 OOS 交易结果的序列相关性引起的方差膨胀,我们必须增加 9 个案例。我们在这个测试中两者都做。

我们发现中位数 t 值是-0.012,除了测试中的随机变化之外,该值为零。因此,我们已经完全消除了 OOS 结果中的偏差。此外,t 分数在 0.1 水平上显著的重复比例是 0.101,这是我们在随机试验中所能预期的最完美的结果。

测试对非平稳性的鲁棒性

交易系统开发者的诅咒(好吧,反正是诅咒之一)是金融市场的不稳定性。几个月来具有高度可预测性的模式可能会突然消失。这可能是因为不断变化的经济环境,如异常高或异常低的利率。也可能是因为大型机构发现了这些可预测的模式,导致可预测性被套利而不复存在。不管原因是什么,重要的是我们要测试我们的交易系统对市场变化的承受能力。

应该注意的是,不同的交易系统对常见类型的市场变化确实有不同程度的稳健性。这通常是有意的。一些开发商故意设计对变化的条件有快速反应的交易系统,但也需要经常修改以跟上不断变化的市场模式。其他人设计的系统利用的模式,虽然往往不太突出,但在市场上存在多年甚至几十年。不管我们的选择如何,或者即使我们没有刻意选择,我们也需要知道,随着市场模式的演变,一个经过训练的模型能够保持多长时间的预测能力。

评估对非平稳性鲁棒性的一种有效方法是进行多次前向分析,每次分析都有不同的测试周期。例如,我们可能每晚重新训练一个日棒系统,只在第二天测试它。然后我们用两天的 OOS 时间测试同一个系统,每隔一天重新训练它。继续这个测试模式,延长测试周期,直到性能严重下降。

当我们绘制 OOS 性能与测试周期的关系图时,我们通常会在最短的测试周期(最频繁的重新训练)看到峰值性能。随着测试时间的延长,性能会下降。通常,开始时下降会很慢,然后直线下降,让开发人员大致了解系统必须多长时间重新培训一次。

一个更加敏感,但是稍微复杂一点的方法是,只根据每个测试折叠中最后一个条的来确定性能。这消除了早期较好结果的影响,尽管(较小的)代价是性能曲线中更多的变化。

交叉验证分析

walkforward 分析的一个主要缺点是它不能有效地利用所有可用的市场历史。对于每个前向折叠,所有超过 OOS 块末尾的信息都将被忽略。我们可以通过交叉验证来解决这个问题。我们的想法是,不仅仅使用在 OOS 测试块之前的训练数据,我们还将 OOS 测试块之后的数据包括在训练集中。这在不涉及时序数据的应用程序中非常有用。然而,当交叉验证应用于时间序列数据,如市场历史,几个微妙的问题可以咬我们。在本节中,我们将探讨这些问题。

IS/OOS 重叠不明显

如果你已经忘记了大于一根棒线的前瞻如何在向前遍历分析中导致未来泄漏的乐观偏差,请回顾从 131 页开始的资料。我将留给读者一个练习来说明,正如我们必须在 walkforward 分析中从训练集的末尾省略 min(回顾,前瞻)–1个案例一样,当我们进行交叉验证时,我们也必须从训练集的 OOS 测试块之后的部分的开头省略这么多案例。为了显示这一点,使用您在展示它时使用的相同技术来进行前向分析。

图 5-1 显示了五重交叉验证的工作原理。矩形的完整左右范围表示可用数据的历史范围。长矩形上方的四个散列标记描绘了五个折叠。在所示的文件夹中,我们正在测试中间的模块。

img/474239_1_En_5_Fig1_HTML.jpg

图 5-1

交叉验证中的保护缓冲区

如果我们的目标变量只有一个条形的预测,我们可以使用 OOS 测试集两边的所有数据作为训练数据。但是该图说明了具有更长前瞻的情况。因此,我们需要忽略测试集两侧的训练案例,作为保护缓冲区,以防止可能导致危险的乐观偏差的无意的 IS/OOS 重叠。

完全通用的交叉验证算法

在某些情况下,程序员可能会发现避免即将展示的算法中涉及的所有洗牌是最容易的。这可以通过将训练集、测试集和保护缓冲区的开始和停止边界直接合并到训练和测试代码中来实现。但这本身就很棘手。而且,它需要高度定制的训练和测试代码;固定的或通用的算法是不可能的。本节和下一节中显示的算法旨在将所有定型数据合并到一个连续案例数组中,并将测试数据合并到另一个连续块中。这大大简化了单独的训练和测试代码。

在本节中,我们以简单的算法形式陈述了一般的交叉验证过程,以提供一个概述。在下一节中,我们将看到阐明细节的 C++ 代码。如果不需要保护缓冲器(omit =0),则算法很简单。但是,如果我们需要一个保护缓冲区,将训练数据压缩到一个连续的数据块中需要复杂的原地洗牌,或者保留数据集的单独副本,根据需要从源阵列复制到目标阵列。我们选择后一种方法,因为它不仅编程更简单,而且执行更快。

因此,如果omit > 0,我们有两个数组。我们称之为 SRC 的数据库包含整个历史数据集。另一个称为 DEST,它是将被传递给训练和测试例程的数组。但是如果omit =0,我们只使用历史数据的数组,对每个折叠进行适当的移动。在这两种情况下,istart是当前第一个测试用例的索引(原点 0),而istop比当前最后一个测试用例的索引大 1。符号m::n是指从mn的连续案例块,但不包括n。算法如下:

istart = 0                                          First OOS test block is at start of dataset.

ncases_save = ncases ;                  We’ll temporarily reduce this, so must restore.

For each fold...

   Compute n_in_fold and istop         Number of test cases; one past end of test set.

   if omit                                             We need guard buffers.
      copy SRC[istart::istop] to end of DEST     This is the OOS test block.

      if first fold                                    The training set is strictly after the test set.
         copy SRC[istop+omit::ncases] to beginning of DEST  This is the training set.
         ncases -= n_in_fold + omit      This many cases in training set.

      else if last fold                            The training set is strictly before the test set.
         copy SRC[0::istart-omit] to beginning of DEST   This is the training set.
         ncases -= n_in_fold + omit      This many cases in training set.

      else                                             This is an interior fold.
         copy SRC[0::istart-omit] to beginning of DEST          First part of training set.
         copy SRC[istop+omit::ncases] to DEST[istart-omit]   Second part of training set.
         ncases -= n_in_fold + 2 * omit         This many cases in training set.

   else                                          omit=0 so we just swap in place.
      if prior to last fold                  We place OOS block at end; already there if last fold.
         swap istart::istop with end cases
      ncases -= n_in_fold              This many cases in training set.

   Train                                         Training set is first ncases cases in new data matrix.

   ncases = ncases_save            Restore to full dataset (it was reduced for training).

   Test                                          Test set is last istop–istart cases in new dataset.

   if (not omit AND not last fold)  If we shuffled in place, unshuffle.
      swap istart::istop with end cases        swap OOS back from end.

   istart = istop                             Advance OOS test set for next fold

.

通用算法的 C++ 代码

前面的算法旨在给出相对复杂的洗牌过程的粗略概述,该洗牌过程用于合并每个折叠的训练和测试数据,便于通用训练和测试算法的使用。但是该概述忽略了许多细节,现在将使用实际的 C++ 代码来介绍这些细节。

我们从一些初始化开始。在整个算法中,istart是当前第一个 OOS 测试用例的索引,istop比当前最后一个测试用例的索引大一。每次折叠后完成的 OOS 病例总数将在n_done中显示,出于索引目的,这些病例将在n_OOS_X中一次一个地计数。如果我们使用保护缓冲区(omit > 0),那么我们需要保存案例总数,因为ncases将减少到每个折叠所使用的训练案例的实际数量。

      istart = 0 ;                           // OOS start = dataset start
      n_done = 0 ;                       // Number of cases treated as OOS so far
      n_OOS_X = 0 ;                  // Counts OOS cases one at a time, for indexing
      ncases_save = ncases ;     // Save so we can restore after every fold is processed

这是折叠环。此折叠中的 OOS 测试案例数是尚未完成的案例数除以剩余待处理的折叠数。

      for (ifold=0 ; ifold<nfolds ; ifold++) {   // Processes user's specified number of folds

         n_in_fold = (ncases - n_done) / (nfolds - ifold) ;        // N of OOS cases in fold
         istop = istart + n_in_fold ;                                          // One past OOS stop

下面的if语句处理必须处理保护块的情况。首先,我们将当前的 OOS 测试集复制到目标数组的末尾,在那里进行测试。

         if (omit) {
            memcpy ( data+(ncases-n_in_fold)*ncols , data_save+istart*ncols ,
                             n_in_fold*ncols*sizeof(double) ) ;

如果这是第一个(最左边的)折叠,则该折叠的整个训练集位于 OOS 块的右边。将其复制到目标数组的开头。训练案例数是案例总数减去 OOS 集和保护块案例中的案例数。

            if (ifold == 0) {   // First (leftmost) fold
               memcpy ( data , data_save+(istop+omit)*ncols ,
                                (ncases-istop-omit)*ncols*sizeof(double) ) ;
               ncases -= n_in_fold + omit ;
               }

如果这是最后的(最右边的)折叠,则整个训练集在 OOS 块之前。复制那些案例。

            else if (ifold == nfolds-1) {  // Last (rightmost) fold
               memcpy ( data , data_save , (istart-omit)*ncols*sizeof(double) ) ;
               ncases -= n_in_fold + omit ;
               }

否则,这是一个内部文件夹。这里我们处理一个在前面显示的算法大纲中没有明确说明的问题。可能用户指定了如此多的折叠,以至于每个折叠都有一个微小的 OOS 测试集,甚至可能只有一个案例。然后,可能发生在测试集的一侧,在保护块被排除之后没有案例。我们必须解决这个问题。

            else {                      // Interior fold
               ncases = 0 ;

               if (istart > omit) { // We have at least one training case prior to OOS block
                  memcpy ( data , data_save , (istart-omit)*ncols*sizeof(double) ) ;
                  ncases = istart - omit ;    // We have this many cases from the left side
                  }

               if (ncases_save > istop+omit) {  // We have at least one case after OOS block
                  memcpy ( data+ncases*ncols , data_save+(istop+omit)*ncols ,
                         (ncases_save-istop-omit)*ncols*sizeof(double) ) ;
                  ncases += ncases_save - istop - omit ;    // Added on this many from right
                  }
               } // Else this is an interior fold
            } // If omit

下面的else块处理omit =0 的情况:没有保护块。这就简单多了。我们甚至没有单独的源数组。一切都被调换了位置。对于每个折叠,我们将 OOS 测试集交换到数组的末尾。在一个折叠的训练和测试完成后,数据被交换回原来的方式。注意,对于最后一个(最右边的)折叠,测试集已经在末尾,所以我们不交换。

         else {
            // Swap this OOS set to end of dataset if it's not already there
            if (ifold < nfolds-1) {                           // Not already at end?
               for (i=istart ; i<istop ; i++) {             // For entire OOS block
                  dptr = data + i * ncols ;                // Swap from here
                  optr = data + (ncases-n_in_fold+i-istart) * ncols ;  // To here
                  for (j=0 ; j<ncols ; j++) {
                     dtemp = dptr[j] ;
                     dptr[j] = optr[j] ;
                     optr[j] = dtemp ;
                     }
                  } // For all OOS cases, swapping
               } // If prior to last fold

            else
               assert ( ncases-n_in_fold-istart == 0 ) ;

            ncases -= n_in_fold ;
            } // Else not omit

/*
   Train and test this XVAL fold
   When we prepared to process this fold, we reduced ncases to remove
   the OOS set and any omitted buffer.   As soon as we finish training,
   we restore it back to its full value.
*/

         find_beta ( ncases , data , &beta , &constant ) ;  // Training phase
         ncases = ncases_save ; // Was reduced for training but now done training

         test_ptr = data+(ncases-n_in_fold)*ncols ;   // OOS test set starts after training set
         for (itest=0 ; itest<n_in_fold ; itest++) {         // For every case in the test set
            pred = beta * *test_ptr++ + constant ;        // test_ptr points to target after this
            if (pred > 0.0)                                             // If predicts market going up
               OOS[n_OOS_X++] = *test_ptr ;             // Take a long position
            else
               OOS[n_OOS_X++] = - *test_ptr ;           // Take a short position
            ++test_ptr ;   // Advance to indicator for next test case
            }

/*
   Swap this OOS set back from end of dataset if it was swapped there
*/

         if (omit == 0  &&  ifold < nfolds-1) {  // No guard buffers and prior to last fold
            for (i=istart ; i<istop ; i++) {            // This is the same code that swapped before
               dptr = data + i * ncols ;
               optr = data + (ncases-n_in_fold+i-istart) * ncols ;
               for (j=0 ; j<ncols ; j++) {
                  dtemp = dptr[j] ;
                  dptr[j] = optr[j] ;
                  optr[j] = dtemp ;
                  }
               }
            }

         istart = istop ;                    // Advance the OOS set to next fold
         n_done += n_in_fold ;      // Count the OOS cases we've done
         } // For ifold

在前面的代码中,请注意,我们使用的“模型”与第 138 页详细讨论的重叠程序中使用的“模型”相同。子程序find_beta()是训练阶段,使用data中的前ncases个案例计算一个线性函数,用于预测下一个数据值(下一个案例的价格变化)。在每个折叠的 OOS 测试阶段,我们通过测试集。对于测试集中的每一个案例,我们都预测了即将到来的市场变动。如果预测是正面的,我们就做多,如果是负面的,我们就做空。这些事实对于当前的讨论并不重要,因为这里的重点是交叉验证交换。只是要知道在所有的交换中,什么时候进行训练和测试。

交叉验证可能存在悲观偏见

人们普遍认为交叉验证产生了对总体性能的无偏估计。乍一看,这是有道理的:我们总是在测试一个模型,这个模型是根据独立于测试数据的数据进行训练的(假设在需要的时候使用了适当的保护缓冲)。但是交叉验证中微妙的问题是每个训练集的大小。每个折叠中的训练集小于整个数据集,而当模型投入使用时,我们通常会使用整个数据集进行训练。当我们有一个较小的训练集时,模型参数估计没有用整个数据集训练时准确。当然,模型参数估计不太准确意味着模型的准确性会降低,这意味着平均而言 OOS 性能较差。因此,在其他条件相同的情况下,我们可以预期交叉验证会略微低估当我们最终使用整个数据集进行训练,然后将模型投入使用时所获得的性能。

交叉验证可能存在乐观偏差

如果数据是非平稳的,这在市场交易应用中是很常见的,这种非平稳性可能是交叉验证中乐观偏差的来源。这个想法是,通过将未来市场数据包括在训练集中,即使个别情况被适当地排除,我们也为训练算法提供了关于数据的未来分布的有价值的信息,这些信息在现实生活中是不可用的。

举个简单的例子,假设你的历史数据自始至终都在稳步增加波动性。在 walkforward 分析中,以及在现实生活中,每个测试集(以及现实生活中的交易时段)的波动性都将超过训练集中的波动性,这可能是有问题的。但交叉验证中的众多内部测试折叠将在用来自未来和过去的数据训练的模型上进行测试,从而提供各种包含测试集中的波动性的波动性示例。这是未来泄露的一种微妙形式,即使没有实际案例共享。

交叉验证不能反映现实生活

从前面两节可以明显看出,当涉及到模拟现实生活时,交叉验证与 walkforward 分析相比是非常可疑的。当然,交叉验证确实允许使用比前向遍历分析更多的训练数据,特别是在早期折叠中,当前向遍历分析被迫使用贫乏的历史数据时。事实上,由于这个原因,walkforward 分析可能比交叉验证有更严重的悲观偏见。另一方面,大多数开发人员使用与用于训练最终产品模型的训练集大小相等的训练集来执行前向遍历分析。这是因为他们不愿意跨越太宽的历史时期,因为不稳定可能包含太多的市场机制。在这种常见的情况下,交叉验证的数据优势就消失了。一旦这种优势消失,就没有动力去容忍前一节中讨论的那种微妙的未来泄漏,其中向训练算法提供了未来非平稳性问题的暗示。因此,我不建议在交易系统开发中进行交叉验证分析,除非是在非常特殊的情况下。

算法交易的特殊注意事项

首先,我们先明确一下算法交易的含义。最近的大部分讨论都集中在日益流行和强大的基于模型的交易*。在基于模型的交易中,我们建立一个预测者和目标的数据集,然后训练一个强大的模型来预测目标,给定交易机会的预测者。这与更古老、更传统的算法交易形成鲜明对比,在传统的算法交易中,严格定义的算法会即时做出交易决策。算法交易的一个古老法则是均线交叉系统:当短期均线高于长期均线时,我们做多;反之,我们做空。为了训练这样一个系统,我们找到了短期和长期的回顾来优化一些性能指标。我们现在调查算法交易系统中不明显的未来泄漏的潜在致命问题。*

*回想一下从 131 页开始的讨论,对于前向走查分析和交叉验证,我们可能需要从训练集中删除一个保护缓冲区,在它接触测试集的地方。移除的事例数比计算模型训练数据库时使用的回望和前瞻距离的最小值小 1。

对于基于模型的交易,几乎总是回望距离超过前瞻距离,通常达到相当大的程度。我们可以回顾历史,寻找数百根棒线来计算趋势、波动和更复杂的指标。但是,当我们在市场上建仓时,我们通常最多持有几根棒线,这样模型就可以快速响应不断变化的市场条件。

但是对于算法系统,反过来往往是正确的,有时到了必须假设向前看的距离是无限的程度!例如,假设我们的交易系统按照以下规则运行:如果一条短期均线(在当前的这根棒线上)越过了一个高于长期均线 2%的阈值,我们就建立一个多头头寸。我们保持这个位置,直到短期均线穿越长期均线下方。在这个示例系统中,需要注意的关键点是我们不知道该头寸将开放多长时间

让我们检查一下这个系统的步行测试。假设我们为长期移动平均线回看任意设置了 150 根棒线的上限。训练后,我们很可能会发现实际的回望比这要小,但它可能是如此广泛,所以我们必须做好准备。

前瞻呢?不幸的是,平仓规则可能在进场后几根棒线就生效了,或者我们可能在 1000 根棒线后还在我们的位置上。我们只是事先不知道。

这意味着,与基于模型的交易不同,在基于模型的交易中,前瞻几乎总是决定保护缓冲区的大小,而对于开放式算法交易,往往是回顾决定保护缓冲区的大小,这通常会大得令人沮丧。

追查这个例子将澄清情况。假设我们在这个折叠中的训练块的最后一个小节,比如小节 1000。为了找到最佳的短期和长期回顾,我们尝试了大量的候选对,甚至可能是每一对可能的短期和长期回顾。对于每个候选对,我们从训练块中最早的可能棒线开始,我们可以为其计算长期移动平均值。我们评估进场规则,如果出场规则通过,我们就建仓,直到出场规则生效。我们穿过训练区,按照规则进行交易。当开市过程到达 1000 点时,我们停下来计算这个短期/长期回顾对的表现。然后,我们对不同的回看对重复该过程。最终,我们有了在训练中表现最好的回顾对。

然后我们去 1001 小节,这是 OOS 测试集中的第一个小节。我们使用先前确定的最佳回顾来评估进场规则,并相应地采取行动。如果测试集的大小超过一根棒线,我们对下一根棒线重复,累积整个测试集的净性能。

敏锐的读者已经注意到,我们忽略了这个算法的一个重要方面:在训练过程中,当我们到达折叠训练块的末尾时,我们该如何处理这个位置?我们至少有五种方法可以处理训练/测试边界附近的问题,其中四种是好的,一种是灾难性的。

  1. 如果一个位置在训练块的最后是开放的,我们让它开放并继续前进,只有当关闭规则触发时才关闭它。 这提供了一个诚实的结果,在现实生活中会获得的利润。但是,假设在训练区的最后一根棒线 1000 开仓,这是一个非常有利可图的交易。训练算法将有利于捕捉大交易的回顾。现在考虑在 OOS 测试集中的第一个小节 1001 发生了什么。这个试验将与先前的棒线分享许多过去的价格历史,比最佳长期回顾少一个棒线。因此,它几乎肯定会打开一个交易。此外,这种交易将分享在训练阶段产生巨大利润的相同的未来棒线,因此它将非常有利可图。训练期和测试期之间的这种过去和未来的价格共享是严重的未来泄漏,它将产生重大的乐观偏差。别这么做。

  2. 当到达训练块的末端 时,强制训练算法关闭并标记位置。这消除了未来的泄漏,使交易系统与现实生活中所能达到的一致,因为没有未来的信息参与训练。但它确实扭曲了训练期结束时的交易,因为它以不同于训练期早期平仓的方式平仓,而训练期早期平仓不太可能过早平仓。这可能会也可能不会对最佳回看对的计算产生不利影响。这当然值得深思。

  3. 修改平仓规则,以平仓已开仓的指定数量的棒线,并使用该大小的保护缓冲区。 在讨论的例子中,我们可以将平仓规则定为“我们持有头寸,直到短期移动平均线穿越长期移动平均线下方,或者头寸已经开仓 20 根棒线。”然后,在训练期结束前,当我们通过这么多栅栏时,我们就停止建立新的仓位(有一个保护缓冲)。这也防止了未来的泄漏,并且与现实生活中可能实现的一致。与方法 2 相比,它的优势在于所有交易都遵循相同的规则,这避免了优化过程中的失真。但除非杠杠限制非常大,否则这可能是对开发商交易假设的不受欢迎的侵犯。

  4. 按照方法 1,在训练期结束后自由推进未平仓交易。然而,当我们到达训练期结束时,停止开仓(保护缓冲区)比最大回看值 少 1。在我们当前的例子中,我们可能开仓的最后一根棒线是 1000-(150–1)= 851。这是安全的,因为当我们从条 1001 开始测试时,我们将检查条 852 到 1001。因此,在训练期间作出进入决定的价格和作出测试进入决定的价格是完全不相关的。尽管避免了将来的泄漏,因此提供了无偏见的结果,这种方法有哲学上的烦恼,即它没有模仿现实生活;我们在培训过程中获取培训期结束后的价格。然而,这与其说是一个实际问题,不如说是一个哲学问题。

  5. 使用下一节的“单条前瞻”方法。

哪种方法最好?看情况(当然!).出于几个原因,我倾向于方法 3。所有的交易都遵循同样的规则,不管他们是在训练期的早期还是晚期。(方法 2 违背了这个美好的性质。)它不像方法 4 那样窥视未来,即使方法 4 的展望未来不会引入有害的未来泄漏。但也许最重要的是,在我多年的工作中,我发现自动交易系统在远离开盘价时会很快失去准确性。如果交易在开仓后没有达到或至少没有接近目标,它很快就会变成一次失败,也许会赢,也许不会。因此,通过引入头寸开放时间限制,我们减少了随机性的影响。

在一种情况下,方法 4 可能优于方法 3。这两种方法都要求我们在训练期结束前停止开仓。在方法 3 中,我们失去了交易时间限制,而在方法 4 中,我们失去了回顾。可能是我们的交易计划需要很长时间交易才能开市。以我的经验来看,这不是一件好事,但其他开发者可能会有不同看法。如果所需的时间比回望时间长,方法 4 会比方法 3 失去更少的交易机会。尽管对方法 4 在培训期间展望未来感到有些不舒服,但在这种情况下,我们可能会认为方法 4 是更好的选择。

将未知前视系统转换为单杆

我们刚刚探索了四种不同的方法来处理训练/测试边界附近的边界区域,其中三种是实用且有效的。我们现在介绍第五种方法,这种方法有时可能更复杂,但它完全避免使用任何保护缓冲区,因此增加了每个训练折叠的有效大小。它确实像方法 2 一样扭曲了训练结束时的交易,但是以一种更无害的方式。此外,它产生了一系列长的连续的单棒线回报,而不是更少的多棒线回报。这是执行 CSCV 优势测试和后面描述的其他几个程序所必需的。

为了实现这种转换,我们将交易规则修改为一系列单棒线交易,第一笔交易根据开盘规则开盘,随后的交易只是前一棒线头寸的延续。换句话说,假设我们期望的交易规则是在当前没有开仓(防止同时开仓交易)且OpenPosition条件为真时开仓,然后在ClosePosition条件为真时平仓。修改后的规则要求我们在每个棒线收盘时执行以下操作:

  • 如果没有职位空缺

  • 如果 OpenPosition 为真,开仓延伸通过下一根棒线

  • 否则

  • 平仓并记录该棒线的交易回报

  • 如果 ClosePosition 为假,则重新打开刚刚平仓的同一仓位

只有在使用需要明确开仓和平仓以记录交易的商业软件时,才需要这种复杂性。当然,如果你正在编写自己的软件,那就简单多了:只需记录一笔公开交易的每根棒线的市值回报!

这通常是最好的方法,因为它提供了最佳的回报粒度。这对于稳定的利润系数计算很重要,它可以实现更准确的提取计算(本质上是对每个棒线进行盯市),并且对于本文其他地方描述的一些最强大的统计测试(CSCV)来说,它是强制性的。请认真考虑。一个实际的例子,用 C++ 代码,将出现在 198 页。

无界回送可能会微妙地发生

我们在前面的方法 4 中看到,交易机会的数量随着交易决策的回顾而减少。当我们有一个开放式交易系统时,我们倾向于使用这种方法,我们不想对交易保持开放的时间施加限制。但在这种情况下,我们必须小心,我们没有回望,至少在理论上,是无界的。如果我们不能在回望上建立一个牢固的界限,一个不是不切实际的大的界限,那么我们不能使用方法 4。

我们的回望怎么会是无限的?一个显而易见的方法是,如果我们的决策计算的某个组件有无限的回顾。例如,我们一直在谈论均线交叉系统,其中均线的回看是有界的。很好。但是,如果我们使用指数平滑或递归滤波器来进行长期和短期平滑,会怎么样呢?这种过滤器的价值是基于一直追溯到市场历史中第一个价格的数据来计算的。诚然,真正早期价格的贡献可能非常小。但是请记住,当涉及到市场交易时,看似无害的偏见来源会产生令人震惊的严重影响。

无界回顾的一个更微妙的来源是当交易决策基于先前的交易决策时。例如,我们的系统可能包括一个安全阀,如果连续四次交易失败,它会关闭一个月的所有交易。现在,对当前棒线的回顾回到之前的交易,以及之前的交易,以此类推。

或者考虑这些进场和出场规则:如果一些严格定义的频繁循环的条件为真,我们就开仓我们目前没有开仓交易。当其他严格定义的条件成立时,我们关闭交易。在这种情况下,我们当前的交易决策取决于我们是否在之前的机会进行了交易,这反过来又取决于之前的机会,无限期。回望是无限的。

怀疑论者可能会嘲笑这个概念。我不知道,因为在我职业生涯的早期,这个问题让我深受其害,我不再低估它的影响。

比较交叉验证和 Walkforward: XVW

在第 138 页,我们介绍了重叠计划,以探讨不明显的 IS/OOS 重叠所引入的偏差。这里我们在 XvW 程序中扩展这个程序,XvW 程序的操作类似,但它的主要目的是演示完全相同的交易系统的 walkforward 和交叉验证分析之间可能存在的巨大差异。请放心使用这个程序(完整的源代码在 XvW。CPP)为模板,用自己的交易系统思路来探讨这种现象。

下面是调用参数列表。程序的大部分操作在第 138 页开始的部分有详细描述,所以我们在这里省略多余的细节。从命令行调用该程序,如下所示:

  • nprices是市场历史中市场价格(棒线)的数量。为了获得最准确的结果,这个值应该很大,至少为 10,000。

  • 是每 50 根棒线反转的趋势强度。趋势为 0.0 意味着市场价格序列是随机游走的。

  • lookback是用于计算指标的历史条形数。

  • lookahead是用于计算目标值的未来棒线数量。

  • ntrain是交易决策所基于的预测模型的训练集中使用的案例数(在省略任何案例之前)。训练案例的实际数量将是ntrain减去omit

  • ntest是每个 OOS 测试块中测试用例的数量。

  • nfolds是交叉验证折叠的次数。

  • omit是指当lookahead大于 1 时,为防止偏差而从训练集中忽略的最近训练案例的数量。理想情况下,它应该比前瞻值小 1。

  • nreps是用于计算几个 t 分数和后面描述的尾部分数的重复数。为了得到准确的结果,它应该相当大,至少为 1000。

  • seed是随机种子,可以是任意正整数。这有助于用不同的种子重复测试以确认结果。

XvW nprices trend lookback lookahead ntrain ntest nfolds omit nreps seed

正如从第 137 页开始所描述的,该程序反复生成市场历史。这个 XvW 程序和 OVERLAP 程序之间的一个区别是 OVERLAP 总是产生随机游走,而 XvW 可以有选择地产生具有用户指定的每 50 根棒线反转的趋势程度的价格历史。这在市场价格中引入了一定程度的可预测性,产生了正的平均回报。创建一个数据集,该数据集由每个条形的预测值和目标值组成。一个简单的线性回归模型通过向前遍历和交叉验证测试进行测试。完成后,将打印类似如下的一行:

Grand XVAL = 0.02249 (t=253.371)  WALK = 0.00558 (t=81.355)  StdDev = 0.00011 t = 150.768  rtail = 0.00000

这些信息是:

  • 平均 OOS 回报和交叉验证的相关 t 值

  • 平均 OOS 回报率和相关的 t 值

  • 两种方法之间差异的标准差、该差异的 t 得分及其右尾 p 值

如果将趋势指定为 0.0,产生一个纯随机游走,则除了自然随机变化之外,所有 t 分数都将是无关紧要的。当你增加趋势时,t 值会迅速变得显著。前向走查和交叉验证之间的差异的 t 分数高度依赖于后向看、前向看,并且在某种程度上依赖于折叠的数量。这个演示的主要收获是,在几乎所有的实际情况下,走查和交叉验证分析都会产生明显不同的结果,而且往往相差甚远。

计算对称交叉验证

我已经指出(用多种理由)我不赞成交叉验证市场交易系统的性能分析。然而,有一个特殊形式的交叉验证的有趣应用,我发现它经常是有用的。该应用程序的灵感来自于 2015 年 David H. Bailey 等人的一篇引人入胜的论文“回测过度拟合的概率”。它可以在互联网上广泛免费下载。

计算对称交叉验证(CSCV)在很大程度上或完全消除了普通 k-fold 交叉验证的一个方面,这在某些情况下可能是有问题的:不相等的训练集和测试集大小。除非我们只使用两次折叠(由于不稳定性,通常不推荐),否则每次折叠的测试集将比训练集小得多。在极端情况下,当我们使用保持一出交叉验证时,每个测试集由一个单独的案例组成。通常,我们将所有 OOS 回报集中到一个与原始数据集大小相同的测试池中,因此没有问题。但是偶尔我们可能想要计算每个折叠的 OOS 数据的一个单独的性能标准,也许得到一个折叠到折叠变化的概念。一些标准,尤其是那些涉及比率的标准,会受到小集合的影响。例如,夏普比率要求我们除以样本中回报率的标准差。如果样本很小,这个分母可能很小,甚至为零。如果样品只有一个箱子,我们根本做不到。利润因子(盈利除以亏损)也需要大型数据集,涉及支出的指标也是如此。

CSCV 的工作原理是将单个交易回报(几乎总是提前一个棒线的回报)分成偶数个大小相等或几乎相等的子集。然后,将这些子集以各种可能的方式组合起来,使其中一半成为训练集,另一半成为测试集。例如,假设我们将收益分成四个子集。我们将子集 1 和 2 组合成一个训练集,并将 3 和 4 组合成相应的测试集。然后我们把 1 和 3 组合成一个训练集,我们把 2 和 4 组合成相应的测试集。我们重复这种重组,直到用尽了所有可能的排列。

应该清楚的是,除非分区的数量很小,并且返回的数量远不是分区数量的整数倍,否则所有训练集和测试集的大小将几乎相等,每个大约是返回总数的一半。

我们暂时离题强调一下,这种划分是在单个棒线回报上进行的,而不是在价格数据上。例如,考虑我们良好的旧均线交叉系统,假设我们已经指定了计算均线的短期和长期回顾。我们不分割价格历史,因为重组会产生致命的不连续性,这会对移动平均线计算造成严重破坏。相反,我们从头到尾处理整个市场历史,跟踪每根棒线的回报。这组单个棒线回报是分区的。

那么,每个训练集的模型优化是如何完成的呢?不幸的是,CSCV 不允许我们使用任何“智能”训练算法,即使用先前试验参数集的性能标准来指导未来试验参数集的选择。因此,举例来说,我们不能使用基因优化或爬山。每个试验参数集或其他模型变量必须独立于先前试验获得的结果。因此,要么我们将几乎总是使用大量随机生成的模型参数,要么我们将在有效参数空间中进行彻底的网格搜索。在评估每个试验参数集的一些性能度量之后,我们选择在训练集中具有最佳性能的参数集。

CSCV 算法:直觉和一般陈述

让我们回顾一下目前我们所掌握的情况。我们已经创建了(通常是大量的)模型参数集的候选集。例如,如果我们有一个移动平均交叉系统,一个试验参数集将由一个长期回顾和一个短期回顾组成。这些众多的参数集可能是随机生成的,也可能是在网格搜索中生成的。

对于每个试验参数集,我们在整个可用的市场价格历史上评估交易系统。我们必须指定一个固定的粒度来评估回报。这个粒度通常是每个棒线:对于每个棒线,我们计算该棒线提供的头寸(多头/空头/中性)对净值的贡献。但不必是每个酒吧;日内交易可以是每小时,日内交易可以是每周,等等。但是,粒度越细越好。重要的是,粒度的定义方式要使我们能够同时获得每个竞争系统的利润数字。这几乎从来都不是问题;我们只是评估每个竞争系统在相同时间点的利润变化(例如每个酒吧或每个周五的一周)。

为了简单起见,从现在开始,我假设我们正在评估每根棒线的回报,理解粗粒度是合法的,尽管不太理想。当我们评估了每个棒线的每个交易系统(每个参数集)后,我们可以用矩阵来表示这些回报。矩阵的每一行都对应一个交易系统(参数集),其逐根棒线的回报横跨该行。我们的矩阵中每个参数集有一行,交易系统活跃时有多少条线就有多少列。(这是[Bailey 等人]文章中矩阵的转置,但这样做计算效率更高。)

请注意,由于交易决策的回顾和交易决策的单棒回报评估的前瞻,我们的回报棒线实际上总是比价格历史棒线少。例如,假设我们需要最近的 10 根棒线来做交易决定。我们将失去 10 根棒线的历史价格。

如果我们现在想要找到整个可用历史的最佳参数集(与实现 CSCV 算法相反),我们为这个返回矩阵的每一行分别计算我们的优化标准,并且查看哪一行(参数集)产生最佳标准。例如,如果我们的业绩标准是交易系统的总回报,我们只需找出每行的总和,然后选择行总和最大的系统。如果我们的标准是夏普比率,我们将计算每行的退货数量,并找到具有最大值的行,依此类推。它告诉我们最佳参数集。

为了实现 CSCV 算法,我们将这个返回矩阵的列分成偶数个子集,如前所述。这些子集将被重新组合,其中一半定义一个训练集,剩下的一半是 OOS 测试集。所有可能的组合都会被处理。现在,考虑一个这样的组合。

我们现在为每一行计算两个标准,一个是混合训练集的性能标准,另一个是混合测试集的性能标准。例如,假设我们的标准是每根棒线的平均回报率。对于每一行(交易系统),我们将组成训练集的那一行的列相加,然后除以这些列的数量,得到该交易系统的每根棒线的平均回报率。同样,我们将组成测试集的列相加,然后除以这些列的数量,得到每根棒线的 OOS 平均回报率。当我们对每一行都这样做时,我们有两个向量,一个用于训练集,一个用于测试集,每个向量都有与我们的竞争交易系统(参数集)一样多的元素。

为了找到用于单个训练/测试划分的最佳 IS 交易系统,我们简单地在其性能向量中定位具有最佳性能的元素。然后我们检查 OOS 向量中相应的元素。这是在这种特殊的分割中,IS-optimal 交易系统所达到的 OOS 性能。

这是 CSCV 算法的关键部分:我们考虑所有交易系统的 OOS 表现。如果我们的模型和参数选择程序是真正有效的,我们可以预期,相对于次优系统的 OOS 性能,次优模型也将具有更好的 OOS 性能。毕竟,如果这个模型优于样本内的竞争对手,它确实捕捉到了真实的可预测市场模式,那么它通常应该很好地利用样本外的市场模式。我们设定了一个相当低但合理的标准来定义我们所说的相对良好的样本外表现:IS-best 系统的 OOS 表现应该超过其他系统的 OOS 表现的中值。

考虑一下,如果模型没有价值,我们会期待什么;它未能捕捉到任何真实的市场模式:没有理由指望表现最好的信息系统也将是更出色的 OOS。最佳信息系统的相对 OOS 性能将是随机的,有时优于其他系统,有时表现不佳。我们预计这个“最好”的系统有 50%的概率高于 OOS 性能的中值。但是,如果这个模型很好,在预测市场运动方面做得很好,我们可以预计它在 OOS 的表现也会很好,至少在大多数时候是这样。

我们如何估计最佳信息系统的 OOS 绩效高于 OOS 绩效中位数的概率?当然是组合对称交叉验证!回想一下前面的讨论,我们将形成子集的每一种可能的组合,将其中一半放在训练集中,另一半放在测试集中。对于每个这样的组合,我们执行刚才描述的操作:找到最佳的 IS 系统,并将其 OOS 性能与其他系统进行比较。计算 IS-best 的 OOS 性能超过其他公司的中值的次数。这些操作不是独立的,但每一个都是无偏的。因此,如果我们将优秀 OOS 性能的计数除以测试的组合总数,我们就有了一个合理的无偏估计,即一个经过训练的系统的 OOS 性能在模拟中超过其竞争对手的中值的概率。

我说合理地无偏,因为有两个偏差来源,前面讨论过,要考虑。首先,CSCV 的每个训练集是完整数据集的一半大小,这导致了与用整个数据集进行训练相比的悲观偏见。参见第 150 页。此外,如果市场价格(以及回报)是不稳定的,那么与现实生活中可能达到的表现相比,任何类型的交叉验证都可能有轻微的乐观偏差。另见第 150 页。

最后,应该注意的是,为了避免无意中的 IS/OOS 重叠(第 131 页),我们几乎总是采用一个小节的前瞻,这就是我在本书中提出的。重组算法可以被修改以收缩训练段,但是这种修改将是麻烦的,并且在这种情况下通常是不值得的。

我们现在准备对刚才直观描述的算法做一个简短的陈述。

Given:
  n_cases: Number of cases (columns in returns matrix), ideally a multiple of n_blocks
  n_systems: Number of competing systems (rows in returns matrix)
  n_blocks: Number of blocks into which the n_cases cases will be partitioned (even!)
  returns: n_systems by n_cases matrix of returns.
        Returns [i,j] is the return from a decision made on trading opportunity j for system i.

Algorithm:
   nless = 0
   for all 'n_combinations' training/testing combinations of subsets
      Find the row which has maximum criterion in the training set
      Compute the rank (1 through number of test cases) of the test-set criterion in this
         row (system) relative to the test criteria for all 'n_systems' rows
      Compute fractile = rank / (n_systems + 1)
      If fractile <= 0.5
         nless = nless + 1
   Return nless / n_combinations

注意,我们可以使用一次取n_blocks/2n_blocks个事物的组合数的标准公式来预计算组合数(等式 5-1 )。

Ncombinations=\frac{Nblocks!}{\left( Nblocks/2\right)!\left( Nblocks/2\right)!}

(5-1)

在算法的直观描述中,我们将最佳 IS 性能的 OOS 性能与其他 OOS 性能的中值进行了比较。在前面的算法中,我们计算相对等级和相应的分位数,如果分位数小于或等于 0.5,则计算失败。这两种运算是等效的,但是前面算法中显示的方法比计算中值更快。

显而易见,我们希望的是比率nless / n_combinations的一个小值,因为这是我们的最佳表现者在样本外表现不如其竞争对手的大概概率。以这种方式表达使它与普通的 p 值有些相似。

这个测试实际上测量什么?

刚刚描述的测试背后的直觉是有道理的,但重要的微妙之处可能并不明显。我们现在更深入地探讨这一点。

理解这个测试本质的关键点是要认识到它的结果完全是相对于被评估的一组竞争对手而言的。在最常见的(尽管不是强制性的)情况下,这些竞争对手都是相同的模型,但是一个或多个参数的值不同。如果测试真的有用,我们选择试验参数的领域是至关重要的。如果域过宽,包括许多不切实际的参数值,或者如果过于严格,不能覆盖可能的参数值的完整范围,测试就失去了很大的适用性。

当我们说测试的结果是相对于竞争对手的集合,我们的意思是这个测试可以被认为是测量一种优势。它回答了以下问题:当真实世界的性能通过测试中的 OOS 性能来衡量时,就真实世界的性能而言,IS-optimal 模型在多大程度上优于其竞争对手?这里的关键词是竞争对手

假设我们通过包含大量系统来稀释竞争对手的领域,这些系统是任何一个有理性的开发者都会提前知道是没有价值的。就参数化而言,这相当于测试许多大大超出合理标准的参数集。无论是在样品中还是样品外,这些系统的性能都很差。因此,即使一个稍微不错的系统的 OOS 性能也将高于所有系统的中值性能,从而在这个测试中获得很高的分数,这可能是不应该的。

相反,假设我们将我们的竞争领域限制在只有预先知道可能是好的系统,几乎没有变化。没有一个系统,即使是最好的,会支配其他 OOS,导致一个糟糕的分数。

底线是,我们必须明白,这个测试的分数告诉我们,最好的 is 模型比与之竞争的较差的 is 模型 OOS 表现得更好。因此,我们应该努力确保竞争者彻底但不是不切实际地代表参数域。

CSCV 优势测试的 C++ 代码

在这一节中,我们将介绍 C++ 代码(CSCV _ 核心。CPP)来实现刚刚描述的测试。这段代码将被分成几个部分,每个部分都有自己的解释。我们从函数和局部变量声明开始。该子例程假设来自竞争交易系统的回报已经被计算并存储在矩阵中,如前所述。

double cscvcore (
   int ncases ,               // Number of columns in returns matrix (change fastest)
   int n_systems ,         // Number of rows (competitors)
   int n_blocks ,            // Number of blocks (even!) into which cases will be partitioned
   double *returns ,       // N_systems by ncases matrix of returns, case changing fastest
   int *indices ,              // Work vector n_blocks long
   int *lengths ,              // Work vector n_blocks long
   int *flags ,                  // Work vector n_blocks long
   double *work ,           // Work vector ncases long
   double *is_crits ,       // Work vector n_systems long
   double *oos_crits      // Work vector n_systems long
   )
{
   int i, ic, isys, ibest, n, ncombo, iradix, istart, nless ;
   double best, rel_rank ;

第一步是将返回的ncases列划分为大小相等或近似相等的n_blocks个子集。在[Bailey 等人]的论文中,假设ncasesn_blocks的整数倍,因此所有子集的大小相同。然而,我认为这并不是严格必要的,而且肯定是限制性的。因此,我使用数组indices指向每个子集的起始案例,并使用lengths作为每个子集中的案例数。每个子集中的病例数是剩余病例数除以剩余子集数。

   n_blocks = n_blocks / 2 * 2 ;    // Make sure it's even
   istart = 0 ;
   for (i=0 ; i<n_blocks ; i++) {      // For all blocks (subsets of returns)
      indices[i] = istart ;                  // Block starts here
      lengths[i] = (ncases - istart) / (n_blocks-i) ; // It contains this many cases
      istart += lengths[i] ;               // Next block
      }

我们将 IS-best 系统的 OOS 性能低于其他系统的 OOS 性能的次数的计数器初始化为零。我们还初始化了一个标志数组,它标识哪些子集当前在训练集中,哪些在测试集中。

   nless = 0 ;   // Will count the number of times OOS of best <= median OOS

   for (i=0 ; i<n_blocks / 2 ; i++)   // Identify the training set blocks
      flags[i] = 1 ;

   for ( ; i<n_blocks ; i++)            // And the test set blocks
      flags[i] = 0 ;

主最外层循环通过所有可能的块组合(返回的子集)进入混合训练集和混合测试集。该循环的第一步是计算每个系统的样本内性能。为此,将被评估系统的所有n返回收集到单个work数组中,然后调用外部子程序criter()来计算性能标准。

   for (ncombo=0; ; ncombo++) {   // For all possible combinations

/*
   Compute training-set (IS) criterion for each candidate system
*/

      for (isys=0 ; isys<n_systems ; isys++) {     // Each row of returns matrix is a system
         n = 0 ;                                                      // Counts cases in training set
         for (ic=0 ; ic<n_blocks ; ic++) {                // For all blocks (subsets)
            if (flags[ic]) {                                         // If this block is in the training set
               for (i=indices[ic] ; i<indices[ic]+lengths[ic] ; i++) // For every case in this block
                  work[n++] = returns[isys*ncases+i] ;
               }
            }

         is_crits[isys] = criter ( n , work ) ;            // IS performance for this system
         }

然后我们对测试集做同样的事情。代码与前面显示的几乎相同,但我们还是会显示它。

      for (isys=0 ; isys<n_systems ; isys++) {  // Each row of returns matrix is a system
         n = 0 ;                                                   // Counts cases in OOS set
         for (ic=0 ; ic<n_blocks ; ic++) {             // For all blocks (subsets)
            if (! flags[ic]) {                                    // If this block is in the OOS set
               for (i=indices[ic] ; i<indices[ic]+lengths[ic] ; i++) // For every case in this block
                  work[n++] = returns[isys*ncases+i] ;
               }
            }

         oos_crits[isys] = criter ( n , work ) ;       // OOS performance of this system
         }

搜索所有系统,找到具有最高样本性能的系统。

      for (isys=0 ; isys<n_systems ; isys++) {  // Find the best system IS
         if (isys == 0  ||  is_crits[isys] > best) {
            best = is_crits[isys] ;
            ibest = isys ;
            }
         }

计算最佳系统的 OOS 性能在所有系统的 OOS 性能总体中的排名。从数学上来说,best >= oos_crits[ibest]是正确的,但是为了防止浮点歧义,我们对此进行了预先测试。然后,我们计算分位数(rel_rank),如果这个性能没有超过中值,就增加我们的失败计数器。

      best = oos_crits[ibest] ;         // This is the OOS value for the best system in-sample
      n = 0 ;                                    // Counts to compute rank
      for (isys=0 ; isys<n_systems ; isys++) {   // Universe in which rank is computed
         if (isys == ibest  ||  best >= oos_crits[isys]) // Insurance against fpt error
            ++n ;
         }

      rel_rank = (double) n / (n_systems + 1) ;
      if (rel_rank <= 0.5)   // Is the IS best at or below the OOS median?
         ++nless ;

我们现在来看这个算法中唯一真正复杂的部分:前进到定义训练集和测试集的下一个块组合。许多读者会愿意相信它的运作。我会在代码后提供一个简短的解释。建议想了解其操作的读者拿出纸笔,算出一连串的组合。测试完所有组合后,我们将失败次数除以总组合次数,得到表现不佳的大概概率。下面是代码:

      n = 0 ;
      for (iradix=0 ; iradix<n_blocks-1 ; iradix++) {
         if (flags[iradix] == 1) {
            ++n ;                     // This many flags up to and including this one at iradix
            if (flags[iradix+1] == 0) {
               flags[iradix] = 0 ;
               flags[iradix+1] = 1 ;
               for (i=0 ; i<iradix ; i++) {  // Must reset everything below this change point
                  if (--n > 0)
                     flags[i] = 1 ;
                  else
                     flags[i] = 0 ;
                  } // Filling in below
               break ;
               } // If next flag is 0
            } // If this flag is 1
         } // For iradix

      if (iradix == n_blocks-1) {
         ++ncombo ;   // Must count this last one
         break ;
         }
      } // Main loop processes all combinations

   return (double) nless / ncombo ;
}

这段代码遍历这些块,寻找第一次出现的(1,0)对,并在此过程中计数 1。第一次找到(1,0)对时,它将 1 向右传播,用(0,1)对替换这个(1,0)对。然后,就像算法开始时一样,它将所需数量的 1 移动到数组开头的这一对之前,并用 0 填充前面部分的剩余部分。因此,这些操作不会改变 1 和 0 的计数。这种交换为我们建立了一个全新的、独特的组合家族,因为新的(0,1)对不可能在没有至少一个标志改变的情况下变回(1,0)然后再变回(0,1)。该算法本质上是递归的,最右边的 1 缓慢前进,它下面的所有标志以同样的方式递归变化。

如果你是那种喜欢启发式验证的人,知道你可以通过等式 5-1 从块的数量中显式计算出组合的数量。对推进算法进行编程,并对各种块数进行测试,确认您获得了正确的组合数。你知道不可能有重复,因为如果任何组合再次出现,算法将进入无限循环。因此,如果你得到了正确的组合数,你就知道它们是独一无二的,因此涵盖了所有可能的组合。

SPX 的示例

我们现在来看一个移动平均线与标准普尔 500 指数交叉的例子。我选择这个“市场”是因为它历史悠久,而且异常广阔,从而避免了任何个人股权问题。作为一个兴趣点,我对各种股票和指数重新进行了测试,发现了两个普遍的影响。首先,移动平均交叉系统在过去的几十年里运行得很好,当它们的性能急剧下降时(至少在我的测试中是这样的;我并不是说这是普遍现象)。第二,单个股票有巨大的差异,一些问题对这个系统反应良好,而另一些则不那么好。所以,我在这个例子中的目标是展示 CSCV 优势算法,而不是促进或阻止任何特定交易系统的使用。

我们从一个子程序开始(在 CSCV_MKT。CPP ),它展示了我们如何计算 CSCV 核心公司所需的returns矩阵。这个例程是用价格历史的数组和用户期望的最大回看来调用的。它计算returns矩阵。请注意,我们需要向它提供实际价格的日志,这样当市场在 1000 时的移动与当市场在 10 时的移动是相称的。我们将用iret索引returns中的项目,?? 在行(条)之间前进最快。

void get_returns (
   int nprices ,                 // Number of log prices in 'prices'
   double *prices ,           // Log prices
   int max_lookback ,      // Maximum lookback to use
   double *returns           // Computed matrix of returns
   )
{
   int i, j, ishort, ilong, iret ;
   double ret, long_mean, long_sum, short_mean, short_sum ;

   iret = 0 ;   // Will index computed returns

我们有三个嵌套循环。最外层循环将长期回看从最小两个小节变化到用户指定的最大值。下一个循环将短期回顾从最小值 1 变化到比长期回顾小 1,确保短期回顾总是小于长期回顾。最内层的循环遍历价格历史,做出交易决策,计算每笔交易的回报。我们不能在ilong-1开始这次价格上涨,即使有效的回报数据从那里开始。这是因为returns矩阵必须是真正的矩阵,每一行都有相同数量的正确对齐的列。因此,对于每个系统,我们需要从同一个起点开始。

   for (ilong=2 ; ilong<=max_lookback ; ilong++) {   // Long-term lookback
      for (ishort=1 ; ishort<ilong ; ishort++) {              // Short-term lookback
         for (i=max_lookback-1 ; i<nprices-1 ; i++) {   // Compute returns across history

我们可以明确地计算每根棒线的移动平均线,但这会非常慢。一种更快的方法是,在第一根棒线上计算两个移动和一次,并从那时起更新它们,尽管由于浮点误差的积累,这种方法的精确度会稍差一些。对于每根棒线,除以移动总和得到移动平均线。

            if (i == max_lookback-1) {      // Find the moving averages for the first valid case.
               short_sum = 0.0 ;               // Cumulates short-term lookback sum
               for (j=i ; j>i-ishort ; j--)
                  short_sum += prices[j] ;
               long_sum = short_sum ;    // Cumulates long-term lookback sum
               while (j>i-ilong)
                  long_sum += prices[j--] ;
               }

            else {                                   // Update the moving averages
               short_sum += prices[i] - prices[i-ishort] ;
               long_sum += prices[i] - prices[i-ilong] ;
               }

            short_mean = short_sum / ishort ;  // Convert sums to averages
            long_mean = long_sum / ilong ;

交易规则是,如果短期均线在长期均线之上,我们就做多,反之亦然。如果两条均线相等,我们保持中性。我在我的assert()中向读者阐明了returns矩阵中现在有多少项。

            // We now have the short-term and long-term moving averages ending at bar i

            if (short_mean > long_mean)             // Long position
               ret = prices[i+1] - prices[i] ;
            else if (short_mean < long_mean)     // Short position
               ret = prices[i] - prices[i+1] ;
            else                                                     // Be neutral
               ret = 0.0 ;

            returns[iret++] = ret ;                           // Save this return
            } // For i (decision bar)

         } // For ishort, all short-term lookbacks
      } // For ilong, all long-term lookbacks

   assert ( iret == (max_lookback * (max_lookback-1) / 2 * (nprices - max_lookback)) ) ;
}

当我在 SPX 上运行这个程序时,我尝试了几种不同的块数和最大回看数。获得了以下结果,提供了重要的证据,移动平均线交叉系统在这个市场中提供了有用的预测信息。

Blocks    Max lookback    Probability
  10           50             0.008
  10          100             0.016
  10          150             0.036
  12           50             0.004
  12          100             0.009
  12          150             0.027

这没有告诉我们任何关于风险/回报比率的信息,所以这个系统可能不值得交易。但它确实表明,经过优化训练的模型在样本外大大优于次优的竞争对手。这是有价值的信息,因为它告诉我们这个模型有真正的潜力;如果模型有缺陷,训练将增加很少或没有价值(OOS 表现),概率将接近 0.5。

嵌套向前分析

有时,我们的开发过程要求我们将一层前向分析嵌套在另一层中。这种情况的经典例子是投资组合构建。我们有一个包含在投资组合中的候选集合,其中的每一个都需要某种程度的性能优化(可能是单独的,也可能是具有公共参数的组)。我们也有一些投资组合表现的标准,我们用来选择这些候选人的子集,以纳入交易组合。无论是哪种情况,优化的两个阶段正在发生(投资组合的组成部分和投资组合作为一个整体),因此,为了估计投资组合的真实表现,我们必须执行嵌套的 walkforward 分析。下面是几个例子(远不完整!)在必要时:

  • 我们有各种各样的交易系统,它们的表现依赖于缓慢变化的市场机制。例如,我们可能有一个趋势跟踪系统,一个均值回复系统和一个通道突破系统。我们跟踪这三个系统中哪一个最近表现最好,当我们做交易决定时,我们使用当前最好的系统。

  • 我们有一个适用于几乎所有股票的交易系统,但是我们从经验中知道,不同的股票家族(交通、金融、消费品等等)在不同的时间用这个系统有更好的表现。我们跟踪哪些股票最近对我们的交易系统反应最好,这些就是我们交易的股票。

  • 你的一位同事坚持认为,平均回报率是衡量市场或交易系统表现的最佳指标。另一个主张夏普比率,而另一个喜欢利润因素。凭你的智慧,你怀疑理想的衡量标准可能会随着时间而改变。所以,你不用运行三个单独的测试,比较从头到尾的表现,而是跟踪哪一个表现指标是当前最准确的,并用这个指标来选择你当前交易的系统或市场。

为什么在这种情况下我们需要使用嵌套的 walkforward?为什么我们不能优化整个过程,将单个组件的参数化和组性能合并到一个大的可优化参数中呢?答案是,这些操作的第二阶段,无论是选择单个系统或投资组合组件,还是第二轮集中优化,都必须基于第一阶段的 OOS 结果。

让我们考虑一个简单的例子,在这个例子中,我们避免了不断变化的市场条件的复杂性。选择偏差的主题在 124 页已经介绍过了,现在可能是回顾这一节的好时机。你的部门成员给了你,部门主管,他们开发的各种模型,并提议公司交易。你必须从这些模型中选择最好的。你会检查竞争者的样品性能并选择最好的吗?当然不是,而且有充分的理由:如果这个系统过于强大(通常是因为它有太多可优化的参数),它会过度拟合市场历史,除了真实的模式之外还会模拟噪音。当这个系统在现实世界的交易中投入使用时,噪音模式将会消失(这就是噪音的定义),你将会得到一堆垃圾。明智的做法是比较竞争系统的 OOS 性能,并以此作为选择的依据。

当你处理一个不断变化的情况时,情况不会改变。你仍然需要根据竞争对手的 OOS 表现,定期、反复地决定交易什么或投资组合中包括什么市场。这是因为样本内的表现很少告诉我们交易系统在现实世界中的表现

这就是为什么我们需要嵌套的 walkforward。我们需要一个 walkforward 的内部级别(我喜欢称之为 Level-1 )来提供 OOS 结果,而 Level-2 优化将基于这些结果。当然,二级交易决策本身也需要通过 OOS 检验和 walkforward 分析。因此,我们嵌套了两个层次的向前遍历分析。

为了准备和阐明即将出现的算法,我们给出一个小例子来说明这个过程是如何工作的。对于这个例子,我们假设 1 级训练(通常是优化单个交易系统)的回顾是 10 根棒线,2 级优化(通常是从竞争的交易系统中选择)的回顾是 3 根棒线。然后我们进行如下操作:

Use Price Bars 1-10 to train the individual competitors.
Test each competitor with Bar 11, giving our first Level-1 OOS case
Use Price Bars 2-11 to train the individual competitors.
Test each competitor with Bar 12, giving our second Level-1 OOS case
Use Price Bars 3-12 to train the individual competitors.
Test each competitor with Bar 13, giving our third Level-1 OOS case

We now have enough Level-1 OOS cases to commence Level-2 testing

Use Level-1 OOS Bars 11-13 to train the Level-2 procedure
Test the Level-2 procedure on Bar 14, giving our first totally OOS case
Use Price Bars 4-13 to train the individual competitors.
Test each competitor with Bar 14, giving a new Level-1 OOS case
Use Level-1 OOS Bars 12-14 to train the Level-2 procedure
Test the Level-2 procedure on Bar 15, giving our second totally OOS case

Repeat the prior four steps, advancing the price and Level-1 OOS windows, until the historical data is exhausted

嵌套的 Walkforward 算法

有经验的程序员应该能够在给出先前的解释和例子的情况下编写嵌套的 walkforward。但是为了清楚起见,我将以一种相当一般的方式来陈述这个算法。这是嵌套式向前交易的最常见用法:你有两个或更多的交易系统,在每根棒线上,查看最近的市场历史,并决定在下一根棒线上的头寸(多头/空头/中性)。你也有一个评分系统,检查每个系统最近的 OOS 表现,选择一个明显更好的交易系统子集(也许只有一个)用于下一次交易。你的目标是从这个最佳子集收集 OOS 交易。这可以让你评估你的整个交易系统的表现,包括基础系统以及评分和选择最好的方法。以下变量尤其重要:

  • n_cases:价格数组中市场价格历史棒线的数量。

  • prices:市场历史(价格记录)。我们称这里的单位为棒线,但是这个信息也可以包括其他的指标,比如成交量和未平仓合约。

  • n_competitors:相互竞争的交易系统数量。

  • IS_n:用户指定的交易系统回看;用于交易决策的近期市场历史棒线数量。

  • OOS1_n:用户指定的系统选择器回看;多个交易系统产生的最近 OOS 回报的数量,由系统选择器用来选择最佳系统。

  • OOS1:交易系统的 OOS 收益,一个由n_cases矩阵构成的n_competitors。注意,这个矩阵中的第一个IS_n列没有被使用,因为它们没有被定义。该矩阵的列j包含棒线j产生的回报,作为对棒线j–1做出的决策的结果。

  • OOS2 : OOS 返回所选的最佳系统;我们的最终目标。

  • IS_start:训练集的开始栏。它随着窗口前进。

  • OOS1_start:系统选择器使用的当前系统 OOS 集合的起始栏 OOS1 中的索引。一旦系统选择器有OOS1_n个病例要回顾,它就随着窗口前进。

  • OOS1_end:系统选择器使用的当前系统 OOS 集合的倒数第一条。它随着窗口前进。这也作为当前 OOS1 案例索引。当算法开始时,它等于OOS1_start,并且每当窗口前进时它就递增。

  • OOS2_start:完整 OOS 集 2 的起始索引;它保持固定在IS_n + OOS1_n

  • OOS2 中的最后一个案例。这也是当前 OOS2 案例索引。

下一节中显示的算法经过大量编辑,可广泛应用。在下一节中,我们将展示一个完整的 C++ 程序,它在一个略有不同但相当的应用程序中使用嵌套的 walkforward。这里,我们首先将系统价格历史中的起始指数初始化为历史上的第一个案例。系统 OOS 回报在系统回望期后立即开始。选择者的 OOS 回归,我们的最终目标,紧接在 OOS 时期之后开始。然后,我们开始在价格历史系列中移动窗口的主循环。

IS_start = 0 ;                                     // Start training with first case
OOS1_start = OOS1_end = IS_n ;  // First OOS1 case is right after first price set
OOS2_start = OOS2_end = IS_n + OOS1_n ;// First OOS2 case is after OOS1 complete

for (;;) {   // Main outermost loop advances windows

每个窗口位置的第一步是评估该棒线处的所有竞争对手(交易系统),并将结果存储在OOS1中,这是一个二维数组,系统位于行下方,棒线位于列上方,该指数变化最快。例程criterion_1()处理所有系统,所以我们必须告诉它我们要评估哪个系统。为了评估一个系统,它会查看以小节IS_start开始、以小节IS_start+IS_n-1结束的IS_n小节。请注意,它不会查看条形OOS1_end,条形将始终是该采样周期后的下一个条形。

在绝大多数应用中,criterion_1()将使用市场历史的IS_n棒线来寻找模型参数,使这些IS_n棒线内的交易系统的性能最大化。然后,它将决定下一个杆的位置,该位置在杆OOS1_end=IS_start+IS_n处。作为大多数应用程序的最后一步,criterion_1()将在这个条OOS1_end上返回该交易产生的利润/损失。如果优化后的模型说要做多,这个回报就是prices[OOS1_end] – prices[OOS1_end–1]。(回想一下,价格几乎总是实际价格的对数。)如果模型要求建立空头头寸,criterion_1()将返回差额的负值,当然,如果头寸是中性的,则返回将为零。我没有在这里显示的算法中明确包括这种典型的行为,而是让它通用,以允许更复杂的交易系统,这些系统可能会在某些交易中翻倍,等等。

   for (icompetitor=0 ; icompetitor<n_competitors ; icompetitor++)
      OOS1[icompetitor*n_cases+OOS1_end] =
                                                         c riterion_1 ( icompetitor , IS_n , IS_start , prices ) ;

在上一步中,我们计算了最后一个历史棒线处的 OOS1 值,此时我们已经完成了移动窗口价格历史的遍历。在这一点上,没有更多的事情要做,因为没有另一个棒线用于计算 OOS2,即所选最佳系统的性能。

   if (OOS1_end >= n_cases-1)  // Have we hit the end of the data?
      break ;                                   // Stop due to lack of another for OOS2

我们现在负责推进移动窗口的部分任务。在算法开始时有一个预热阶段,我们建立了足够多的 OOS1 案例,以允许选择器函数做出决定。不管我们是否有足够多的 OOS1 案例,我们都会增加训练成分交易系统的起始价格指数,我们也会增加放置下一个 OOS 回报的 OOS1 指数。但是如果到目前为止计算出的 OOS1 条的数量OOS1_end – OOS1_start还没有达到选择器所要求的数量OOS1_n,那么我们还没有做更多的事情,我们只是继续推进窗口。

   ++IS_start ;       // Advance training window start
   ++OOS1_end ; // Advance current OOS1 case

   if (OOS1_end - OOS1_start < OOS1_n)  // Are we still filling OOS1?
      continue ;  // Can't proceed until we have enough cases to compute an OOS2 return

当我们到达这里时,OOS1 中有足够的事例来调用系统选择器并计算 OOS2 事例。首先我们找到最好的交易系统,使用每个系统最近的OOS1中的OOS1_n值。记住OOS1_end现在比OOS1多了一位(几行前我们增加了一位)。因此,OOS1_end酒吧的价格是小样。

这里的选择器功能是criterion_2()。它的第一个参数是要检查的OOS1值的数量,第二个参数是值向量的起始地址。如有必要,回头看看这些值在OOS1中是如何排列成矩阵的。

在这个算法中,我们找到单一的最佳交易系统,并评估其回报。相反,希望找到系统组合的读者应该很容易修改这个演示文稿。只需为每个系统调用criterion_2(),将值保存在一个数组中,并对数组进行排序。你想要多少最好的就保留多少。

   best_crit = -1.e60 ;
   for (icompetitor=0 ; icompetitor<n_competitors ; icompetitor++) {  // Find the best
      crit= criterion_2(OOS1_end-OOS1_start, OOS1+icompetitor*n_cases+OOS1_start);
      if (crit > best_crit) {
         best_crit = crit ;
         ibest = icompetitor ;
         }
      }

我们现在知道了最好的竞争对手,所以找到它的 OOS 回报。这里的函数trade_decision()使用优化的交易系统ibest来决定持仓。当我讨论criterion_1()的时候,我指出我允许不同规模的头寸。我没有让这个版本通用,只是因为我想完全清楚如何计算交易决策的回报。如果您的系统可能会因不同的信任度而开立多个头寸,您必须适当修改此代码。这个例程在禁止OOS2_end做出决定之前检查最近的IS_n价格。请注意,Bar OOS2_end不包含在决策过程中,因此它是样本外的。

   position = trade_decision ( ibest , IS_n , OOS2_end - IS_n , prices ) ;
   if (position > 0)           // Long
      OOS2[OOS2_end] = prices[OOS2_end] - prices[OOS2_end-1] ;
   else if (position < 0)   // Short
      OOS2[OOS2_end] = prices[OOS2_end-1] - prices[OOS2_end] ;
   else                            // Neutral
      OOS2[OOS2_end] = 0.0 ;

我们可以完成推进移动窗口的过程。在OOS1包含足够的值之前(选择器criterion_2()需要OOS1_n),我们没有推进OOS1_start。但是我们现在提前了,因为OOS1窗口已经满了。当然我们会提前OOS2_end

   ++OOS1_start ;   // Finish advancing the windows
   ++OOS2_end ;
   } // Main loop

我们已经走过了整个市场历史。此时,OOS1_endOOS2_end都等于n_cases,因为它们总是指向一个超过最后一个条目的值,我们处理了每一个可能的条。

现在,整个市场历史已经处理完毕,我们可以计算一些可能感兴趣的东西。首先,我们计算并保存每个系统的平均 OOS 性能。每个条形的信息在OOS1中。我们可以包含OOS1中的每个条目,一些开发人员可能会对这个数字感兴趣。然而,出于我们的目的,我们希望有一个公平的竞争环境,所以我们只包括那些在OOS2也可用的酒吧,它比OOS1晚开始。在这个演示中,我们计算的性能指标只是每根棒线的平均回报,但我们也可以计算利润因子、夏普比率或任何其他指标。毕竟,OOS1每一行的累计总和只是一条棒线对棒线的权益曲线,我们可以用任何方式对其进行评估。

for (i=0 ; i<n_competitors ; i++) {
   sum = 0.0 ;
   for (j=OOS2_start ; j<OOS2_end ; j++)
      sum += OOS1[i*n_cases+j] ;
   crit_perf[i] = sum / (OOS2_end - OOS2_start) ;
   }

最后一步是计算我们的最终目标,即所选最佳系统的 OOS 性能。这些回报在OOS2中。与 OOS1 一样,我们在这里计算均值回报,但也可以随意计算其他指标。

sum = 0.0 ;
for (i=OOS2_start ; i<OOS2_end ; i++)
   sum += OOS2[i] ;
final_perf = sum / (OOS2_end - OOS2_start) ;

嵌套向前行走的一个实际应用

在上一节中,我们看到了嵌套 walkforward 的最常见用法的概要,以一系列 C++ 代码片段的形式呈现。现在我们介绍这种技术的一种不同的用法,这一次是以一个完整的程序的形式,用户可以根据需要修改、编译并在实际应用中使用。这个程序可以作为选择器下载。CPP 和是完整的,准备编译和运行。

这一应用背后的动机是,在一个股票宇宙中,市场轮流成为表现最好的市场。在某些时期,银行可能表现出色,而在其他时期,技术可能占据主导地位。总的想法是,每天(或者其他时间段,如果我们想的话)我们检查宇宙中的每个股票,并选择最近表现最好的一个。我们在第二天买入并持有这支目前处于优势的股票,然后重新评估形势。

这个嵌套的向前遍历演示将回看窗口逐栏移动。打印结果的缩放假设这些是日棒线,但是当然在高速情况下它们可以是分钟棒线,在更放松的环境下是周棒线,或者是开发人员想要的任何东西。

在每根柱线上,它分析了多个市场最近的长期表现。它收集了单个市场的表现,这些表现是通过在历史窗口期简单地买入并持有该市场而获得的。然后,它买入并持有最近表现最好的下一根棒线。但是我们如何衡量每个竞争市场的表现来选择最佳市场呢?我们使用每根棒线的平均回报率吗?夏普比率?利润因素?这是这个应用程序的选择方面。在每一根棒线上,我们尝试几种不同的业绩指标,看看哪种指标在一个单独的历史窗口内提供了最好的 OOS 回报。当我们买下一根棒线的最佳市场时,我们的决定是基于最近 OOS 记录最好的业绩指标。因此,我们需要一个第二级选择的 OOS 绩效数字,其中我们使用一个“最佳衡量”来选择一个“最佳市场”需要嵌套的 walkforward。

要使用命令行选择器程序,用户提供一个市场历史文件列表,每个文件的文件名指定市场的名称。例如,IBM.TXT包含 IBM 的市场历史价格。市场历史文件的每一行都有日期(YYYYMMDD)、开盘价、最高价、最低价和收盘价。线路上的任何附加数字(如音量)都将被忽略。例如,市场历史文件中的一行可能如下所示:

20170622 1075.48 1077.02 1073.44 1073.88

除了提供列出市场文件的文本文件的名称之外,用户还指定IS_n,市场价格历史的回顾,用于找到当前表现最好的市场;OOS1_n,市场级 OOS 的回望结果为选择当前表现最好的标准;以及蒙特卡洛复制的次数(稍后讨论)。例如,用户可以从命令行调用选择器程序,如下所示:

CHOOSER Markets.txt 1000 100 100

Markets.txt文件可能如下所示:

\Markets\IBM.TXT
\Markets\OEX.TXT
\Markets\T.TXT
etc.

前面的命令行还说,将检查最近市场历史的 1,000 根棒线以找到最佳市场,该市场选择过程的 100 根棒线的 OOS 业绩将用于选择最佳业绩标准。它还说将进行 100 次蒙特卡洛复制来测试结果的统计显著性。这个题目将在 283 页介绍。

在这里,我们将介绍选择器的嵌套前进部分。CPP 代码比我们以前使用的通用算法更详细。但是请注意,完整的程序包括一个蒙特卡罗置换检验,我们在第 316 页之前不会讨论它,所以为了避免混淆,现在将省略这些代码部分。

为了明确起见,这里有三个不同的性能标准,它们将用于决定众多市场中哪一个是目前最有前景的。它们有两个参数:要检查的(对数)价格的数量和一个指向价格数组的指针。价格数组实际上必须是真实价格的对数,以使它们与规模无关,并享有简介中讨论的其他属性。

一个细分市场的总回报就是它的最后价格减去它的第一个价格。为了计算原始(非标准化)夏普比率,我们首先计算每根棒线的平均回报率,然后计算棒线与棒线变化的方差。原始夏普比率是平均值除以标准差。利润因子是所有向上移动的总和除以所有向下移动的总和。最后,criterion()调用指定的例程。

double total_return ( int n , double *pric es )
{
   return prices[n-1] - prices[0] ;
}

double sharpe_ratio ( int n , double *prices )
{
   int i ;
   double diff, mean, var ;

   mean = (prices[n-1] - prices[0]) / (n - 1.0) ;

   var = 1.e-60 ;  // Ensure no division by 0 later
   for (i=1 ; i<n ; i++) {
      diff = (prices[i] - prices[i-1]) - mean ;
      var += diff * diff ;
      }

   return mean / sqrt ( var / (n-1) ) ;
}

double profit_factor ( int n , double *prices )
{
   int i ;
   double ret, win_sum, lose_sum ;

   win_sum = lose_sum = 1.e-60 ;

   for (i=1 ; i<n ; i++) {
      ret = prices[i] - prices[i-1] ;
      if (ret > 0.0)
         win_sum += ret ;
      else
         lose_sum -= ret ;
      }

   return win_sum / lose_sum ;
}

double criterion ( int which , int n , double *prices )
{
   if (which == 0)
      return total_return ( n , prices ) ;

   if (which == 1)
      return sharpe_ratio ( n , prices ) ;

   if (which == 2)
      return profit_factor ( n , prices ) ;

   return -1.e60 ;   // Never get here if called correctly
}

读取市场历史的代码很简单,但是很乏味,所以在讨论中不再赘述。此外,所有市场的棒线必须在时间上对齐,因此,如果任何市场缺少某个棒线的数据,该棒线必须从所有其他市场中删除,以保持时间对齐。这在主要市场中是罕见的事件。这段代码也很乏味,因此在讨论中省略了;请参见选择器。CPP 的这个代码,高度评价。这里我们关注嵌套的 walkforward 代码,它使用了以下变量:

  • n_cases:市场价格历史棒线的数量。

  • market_close[][]:市场历史(价格记录)。第一个指数是市场,第二个是酒吧。

  • n_markets:市场数量(在market_close中的行数)。

  • IS_n:用户指定的每个选择标准要检查的最近市场历史棒线的数量。

  • OOS1_n:用户指定的市场选择器回看;最近从市场获得的 OOS 收益数,用于选择最佳的市场选择方法。

  • n_criteria:竞争市场选择标准的数量。

  • OOS1:由每个竞争标准确定的“最佳”市场的 OOS 回报,一个由n_cases矩阵确定的n_criteria。该矩阵的列j包含棒线j作为棒线j–1的“最佳市场”决策的结果而产生的回报。

  • OOS2:用最佳标准选择的市场的 OOS 回报率。

  • IS_start:当前市场表现窗口的开始栏。

  • OOS1_start:当前窗口开始栏 OOS1 中的索引。一旦系统选择器有OOS1_n个病例要回顾,它就随着窗口前进。

  • OOS1_end:当前 OOS1 窗口的最后一个条。它随着窗口前进。这也作为当前 OOS1 案例索引。

  • OOS2_start:完整 OOS 集 2 的起始索引;它保持固定在IS_n + OOS1_n

  • OOS2_end:OOS 2 中最后一个案例过去一个。这也是当前 OOS2 案例索引。

用户会发现将通过本节的市场选择程序获得的业绩与通过购买和持有单个市场或一篮子竞争市场获得的业绩进行比较是很有趣的。所以,我们打印这些信息。为了便于公平比较,我们应该考虑将参与OOS2计算的完全相同的棒线。OOS2中的第一根棒线将位于IS_n + OOS1_n,其回报与前一根棒线的价格相关。OOS2中的最后一个杆将位于n_cases–1,因为杆索引为零原点。我们将每根棒线的平均回报率乘以 25200。当价格是日线时,这是合理的,因为一年通常有 252 个交易日。这些价格实际上是对数价格,接近相对于先前价格的部分回报。因此,打印出来的值接近年化百分比回报率。下面是这段代码:

fprintf ( fpReport, "\n\n25200 * mean return of each market in OOS2 period..." ) ;
sum = 0.0 ;
for (i=0 ; i<n_markets ; i++) {
   ret = 25200 * (market_close[i][n_cases-1] - market_close[i][IS_n+OOS1_n-1]) /
                         (n_cases - IS_n - OOS1_n) ;
   sum += ret ;
   fprintf ( fpReport, "\n%15s %9.4lf", &market_names[i*MAX_NAME_LENGTH], ret ) ;
   }
fprintf ( fpReport, "\nMean = %9.4lf", sum / n_markets ) ;

做一些初始化。用户可能有兴趣知道每个市场选择标准根据其 OOS 性能被选为最佳的次数,因此我们将一组计数器置零。我们还初始化各种指数,让我们遍历市场历史。

for (i=0 ; i<n_criteria ; i++)
   crit_count[i] = 0 ;     // Counts how many times each criterion is chosen

IS_start = 0 ;                 // Start market window with first case
OOS1_start = OOS1_end = IS_n ; // First OOS1 case is right after first price set
OOS2_start = OOS2_end = IS_n + OOS1_n ; // First OOS2 case after complete OOS1

贯穿市场历史的主要循环是下一个。每次循环(窗口放置)的第一步是评估每个市场的最近历史表现,用每个竞争标准来衡量。对于每个标准,找到最近表现最好的市场,希望这个市场的出色表现会持续下去,至少到下一个酒吧。我们通过从当前棒线到下一个棒线(棒线OOS1_end)的变化来衡量下一个棒线的性能。我们在 OOS1 中保存这个 OOS 性能。

for (;;) {            // Main loop marches across market history

   for (icrit=0 ; icrit<n_criteria ; icrit++) {   // For each competing performance criterion
      best_crit = -1.e60 ;
      for (imarket=0 ; imarket<n_markets ; imarket++) {
         crit = criterion ( icrit , IS_n , market_close[imarket]+IS_start ) ;
         if (crit > best_crit) {
            best_crit = crit ;
            ibest = imarket ;   // Keep track of which market is best according to this criterion
            }
         }
      OOS1[icrit*n_cases+OOS1_end] =
                           market_close[ibest][OOS1_end] - market_close[ibest][OOS1_end-1] ;
      }

在前面所示的icrit循环结束时,我们在 OOS1 中获得了每个标准认为最有前景的市场的下一棒(OOS)表现。如果我们已经到达了市场数据的末尾,我们现在就脱离历史遍历循环。否则,使那些总是前进的窗口指针前进。然后查看我们在OOS1中是否有足够的条(OOS1_n)来选择最佳标准。

   if (OOS1_end >= n_cases-1)  // Have we hit the end of the data?
      break ;            // Stop due to lack of another for OOS2

   ++IS_start ;       // Advance training window
   ++OOS1_end ;  // Advance current OOS1 case

   if (OOS1_end - OOS1_start < OOS1_n)  // Are we still filling OOS1?
      continue ;       // Cannot proceed until enough cases to compute an OOS2 return

当我们达到这一点时,我们在 OOS1 中有足够的棒线来比较竞争标准,看看哪一个在选择市场方面做得最好,其出色的表现将延续到下一个棒线。在这里,我们对标准能力的衡量只是回顾窗口中每个竞争标准的总 OOS 回报。纯粹为了用户的熏陶,统计一下每个标准有多少次被选为最靠谱的。

   for (icrit=0 ; icrit<n_criteria ; icrit++) {              // Find the best criterion using OOS1
      crit = 0.0 ;                                                     // Measures competence of icrit
      for (i=OOS1_start ; i<OOS1_end ; i++)       // Lookback window for competence
         crit += OOS1[icrit*n_cases+i] ;                  // Total return is a decent measure
      if (crit > best_crit) {
         best_crit = crit ;
         ibestcrit = icrit ;                                          // Keep track of most reliable criterion
         }
      }

   ++crit_count[ibestcrit] ;   // This is purely for user's edification

在刚刚展示的循环结束时,我们知道ibestcrit是标准,至少在最近,被证明是选择最佳市场购买的最可靠的方法。所以我们用这个标准来评估每个市场的近期表现,选择最佳市场买入。我们在棒线OOS2_end之前检查IS_n价格,棒线【】将是这个二级 OOS 棒线。

   best_crit = -1.e60 ;

   for (imarket=0 ; imarket<n_markets ; imarket++) { // Use best crit to select market
      crit = criterion ( ibestcrit , IS_n , market_close[imarket]+OOS2_end-IS_n ) ;
      if (crit > best_crit) {
         best_crit = crit ;
         ibest = imarket ;  // Keep track of best market as selected by best criterion
         }
      }

我们现在知道哪个市场被选为最近表现最好的市场,我们是根据最近表现最可靠的标准进行选择的。所以希望这是一个伟大的选择;这是用最可靠的标准选出的最佳市场。我们通过计算从被检查的OOS1中的最后一根棒线到下一根棒线OOS2_end的价格变化来测试这一点。将此回报保存在OOS2。最后,推进我们之前没有推进的窗口索引。

   OOS2[OOS2_end] =
                          market_close[ibest][OOS2_end] - market_close[ibest][OOS2_end-1] ;

   ++OOS1_start ;   // Finish advancing window across market history
   ++OOS2_end ;

   } // Main loop that traverses market history

艰难的工作完成了。我们在OOS2中看到了 OOS 从我们的双重选择过程中获得的回报,使用了目前最好的标准来选择目前最有前景的市场。现在是计算和打印汇总结果的时候了。可以参考 CHOOSER。CPP 来看看我如何打印这些结果,如果你想;他们的计算显示在这里。回想一下,正如我们在本演示开始时对原始市场所做的那样,性能只考虑那些可用于OOS2的棒线。这使得所有性能数据都具有可比性。此外,正如我们对原始市场回报所做的那样,我们乘以 25,200,使这些数字成为日棒线的近似年化百分比回报。

for (i=0 ; i<n_criteria ; i++) {       // Provide separate results for each criterion
   sum = 0.0 ;
   for (j=OOS2_start ; j<OOS2_end ; j++)
      sum += OOS1[i*n_cases+j] ;
   crit_perf[i] = 25200 * sum / (OOS2_end - OOS2_start) ;
   }

sum = 0.0 ;
for (i=OOS2_start ; i<OOS2_end ; i++)
   sum += OOS2[i] ;
final_perf = 25200 * sum / (OOS2_end - OOS2_start) ;

使用 S&P 100 组件的示例

我在 S&P 100 的大部分组件上运行了刚才描述的选择器程序,这些组件的历史至少可以追溯到 1986 年末。这提供了 65 个市场超过 20 年(7725 天)的数据。市场回看(每个业绩标准检查的价格数量)是 1000 根棒线(天),OOS1 回看(用来比较业绩标准的最好市场 OOS 棒线的数量)是 100。进行了 1000 次重复的蒙特卡罗置换检验。有关这些 p 值的讨论,请参见第 316 页。获得的结果如下:

Mean =   8.7473

25200 * mean return of each criterion, p-value, and percent of times chosen...

 Total return    17.8898    p=0.076    Chosen 67.8 pct
 Sharpe ratio    12.9834    p=0.138    Chosen 21.1 pct
Profit factor    12.2799    p=0.180    Chosen 11.1 pct

25200 * mean return of final system = 19.1151 p=0.027

这告诉我们关于该测试的以下事情:

  • 如果我们在 OOS2 期间简单地购买并持有所有这些股票,我们将获得大约 8.7473%的年回报率。

  • 如果我们只使用总回报来选择目前表现最好的市场,我们将获得大约 17.8898 的年回报。

  • 仅使用夏普比率或仅使用利润因子将分别提供 12.9834%和 12.2799%的较低回报。

  • 当我们将所有三个标准投入竞争时,它们分别被选为最可靠的 67.8%、21.1%和 11.1%。

  • 如果我们还跟踪哪个标准是目前最可靠的,我们的 OOS 年回报率大约增加到 19.1151%。

Walkforward 中嵌套的交叉验证

通常情况下,我们希望将交叉验证嵌套在 walkforward 分析中。为了理解什么时候这是合适的,回想一下测试自动交易系统中交叉验证和 walkforward 分析之间的基本权衡:交叉验证比 walkforward 测试更有效地利用可用数据,但它不能反映现实生活。它可能遭受悲观或乐观的偏见,并且它的结果通常与从通常更“合理”的向前行走分析中获得的结果大不相同。

这种权衡使我们倾向于交叉验证,而不是当它的弱点不是非常重要的问题时进行测试。在前两节介绍的嵌套前推示例中,偏差和“实际应用”不仅是最终结果中的重要考虑因素,也是OOS1内部结果中的重要考虑因素,因为内部结果使我们能够从竞争的性能评估函数中进行选择。但是有些情况下,缺乏现实生活中的一致性,包括小的偏见问题,并不那么严重。

两个典型的情况是模型复杂性的优化和预测变量的选择。显然,这两者都适用于模型驱动的交易系统,而不是基于规则的算法系统。然而,在一些(罕见的)情况下,在算法系统的前向测试中嵌入交叉验证可能是有用的。

不可否认,将交叉验证还是前向遍历嵌入到外部前向遍历分析中的决定通常是不明确的,也是有争议的。然而,作为一个例子,考虑在预测市场运动的多层前馈网络中优化隐藏神经元的数量。如果我们的神经元太少,模型就会太弱,无法找到预测模式。如果我们有太多,模型会过度拟合数据,除了真实的模式之外还会学习随机噪声。我们需要甜蜜点。

这个最佳点从根本上取决于数据中噪声的性质和程度,因此我们希望在做出这个复杂性决策时使用尽可能多的数据,从而有利于交叉验证。此外,我们并不太关心优化过程是否反映了现实生活中的进度;我们只是在寻找由数据性质决定的模型的理想结构。此外,预期由于使用少于完整数据集(第 150 页)而产生的任何悲观偏差将在所有复杂性试验中大致相等地反映出来,并且由于非平稳性泄漏(第 150 页)而产生的任何乐观偏差也将相当平衡,这并不是不合理的。我们在这个测试中的唯一目标是评估由于过度拟合导致的乐观偏差,这在比较不同复杂性的模型时会很突出。所以在这种情况下,我们倾向于交叉验证。

为了清楚地了解在 walkforward 分析中嵌入交叉验证的过程,考虑下面这个小例子。我们想决定是否应该在我们的神经网络中使用三个或五个隐藏神经元。我们将历史数据集分成 10 个部分(1-10 ),并选择使用三重交叉验证。因此,我们采取以下措施:

  1. 将模型配置为具有三个隐藏的神经元。

  2. 用章节 2 和 3 训练模型,预测章节 1 中的案例。

  3. 用章节 1 和 3 训练模型,预测章节 2 中的案例。

  4. 用章节 1 和 2 训练模型,预测章节 3 中的案例。

  5. 汇总第 1–3 节的预测,并计算该三神经元模型的 OOS 性能。

  6. 将模型配置为具有五个隐藏神经元。

  7. 重复步骤 2-5 以获得五个神经元的性能。

  8. 选择哪个模型(三个或五个隐藏神经元)具有更好的 OOS 性能。用 1—3 段训练模型。

  9. 使用这个模型来预测第四部分第一部分,我们的第一个终极 OOS 集。

  10. 如果我们还没有到达第十部分(最后一部分),重复步骤 1-9,除了每个部分的编号增加到下一个,将整个操作窗口向前移动一个部分。

  11. 当我们到达终点时,我们有了第 4–10 段的步行前进 OOS 数据。汇集它得到一个宏伟的业绩数字。如果不满意,就从头开始。

  12. 如果我们对大的性能感到满意,对整个数据集(任何合理的折叠数)使用交叉验证两次,计算三个和五个神经元模型的 OOS 性能。

  13. 选择表现更好的模型,用最近的三个部分(为了与我们的测试保持一致)或整个数据集(为了最大限度地使用数据)来训练它,以便在交易中使用。

最后一步值得讨论一下。当训练最终模型用于生产时,我们应该使用多少数据?在本例的步行测试中,我们用三个数据块训练每个模型进行 OOS 测试。为了保持一致,我们的生产模型也应该用最近的三个块进行训练。如果我们担心市场存在显著的不稳定性,这是一件好事。但是通过使用所有可用的数据,我们创建了一个更稳定的模型。这两种选择都是合理的。

在前面几节中,我们介绍了一个通用算法和一个如何在 walkforward 中嵌套 walkforward 的具体示例。这个过程涉及到一些相当复杂的操作,包括最低级别市场数据、中级 OOS 结果和高级 OOS 结果的起始和终止指数。在大多数应用程序中,这是解决问题的最简单和最清晰的方法,尽管有一定的复杂性。

但是当嵌入交叉验证时,事情变得更加复杂。出于这个原因,也因为在大多数应用中,交叉验证是模型训练过程的一部分,我们几乎总是采取不同的、更简单的方法。前页所示示例的步骤 1-8 通常在单个子例程调用中执行,而不是像嵌入式 walkforward 那样混合在整个过程中执行。

换句话说,我们有一个单独的子例程(可能调用其他例程)来处理各个折叠的训练,监督模型架构之间的交叉验证竞争,并训练最终的模型。然后在简单的 walkforward 实现中调用这个子例程;它是用最早的市场历史数据块调用的,然后使用训练好的模型对一个或多个市场数据条进行交易,无论用户希望测试窗口有多长,测试集都是如此。这些 OOS 结果被保留,并且整个训练/测试窗口被向前移动,使得下一个测试窗口中的第一个条形紧跟在当前测试窗口中的最后一个条形之后。该窗口向前移动,直到到达数据的末尾。结果是,就前向行走分析而言,它只是预测模型的原始单层前向行走,前向行走算法完全不知道训练例程中正在进行交叉验证。**