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

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

协议:CC BY-NC-SA 4.0

六、评估未来表现 II:交易分析

处理动态交易系统

在前一章,我们主要关注如何从系统中收集公正的、真实的交易,这些系统对每根棒线做了头寸决策,并对每根棒线产生了可衡量的回报。许多交易系统,尤其是那些基于算法而非模型的系统,会决定开仓并持有该头寸,直到在某个不确定的未来时间触发平仓规则。在此期间,甚至可以对系统进行调整,比如移动跟踪止损点。这使事情变得复杂。

本章的重点是如何分析我们使用前一章的技术收集的无偏交易,并使用这种分析来估计我们交易系统未来表现的各个方面。但是在深入这个话题之前,我们需要学习如何处理动态交易系统产生的交易,并探索几种非常不同的分析这些交易的方法。出于这个原因,我们的第一个例子将显示一个有效的方法来做到这一点,我们将比较不同的方法来评分交易。

未知的单杠前瞻,重温

在第 155 页,我们看到了一个很好的技术,可以把不确定前瞻的算法交易系统转换成前瞻一根棒线的系统;请现在查看该部分。这太棒了,因为当我们对这样的系统进行前向分析时,我们不需要处理浪费数据的保护缓冲区,不管回看时间有多长。此外,这种技术提供了尽可能精细的粒度,支持使用一些我们最强大的统计分析算法。

这种技术还有另一个巨大的吸引力,在那一节中没有提到,因为我想等到我可以给出一个详细的例子时再说。现在是时候了。当然,如果我们的交易系统本质上是一根棒线的系统,比如那些在我们完成下一根棒线时逐根棒线决定头寸的系统,我们已经有了我们需要的,所以我们不需要担心转换。但是,如果我们有一个开仓规则,另一个在不确定的时间后平仓的规则,甚至可能有随着交易的进展改变出场规则的规则,我们应该强烈倾向于使用第 155 页给出的转换算法。

我们现在提到的这种算法的吸引力在于,尽管动态交易系统很复杂,但从训练阶段到测试阶段的过渡很简单。此外,如果训练过程足够快,可以在两根棒线之间进行(比如在日内交易系统中过夜),我们可以无缝地从最后一次交易融合到最后的训练和交易系统的立即使用。

作为演示其工作原理的一个小例子,考虑一个 walkforward 测试的最后一次折叠。假设我们有编号为 1 到 120 的 120 个数据条,我们想使用前 100 个条作为训练期,剩余的 20 个条作为测试期,在测试完成后立即重新训练,并准备好下单,以便在下一个条 121 中有一个未结头寸。

在本例中,我们在训练期间的最后一个交易决策将在棒线 99 上做出,因为我们需要棒线 100 的价格来计算最终棒线对我们在训练期间的绩效指标的贡献,该指标正在通过参数调整进行优化。当找到最佳参数并且我们准备进入测试阶段时,我们还需要知道训练阶段中的最后一个位置,该位置对于在最佳模型中从柱 99 移动到柱 100 是有效的。最简单的方法是将其与训练期间的最佳参数更新一起保存。然后,当我们在测试期开始时进入棒线 101,我们使用优化模型对棒线 100 进行交易决策,并使用棒线 101 的价格计算测试期的第一个回报。如果在训练期间保留最后一个位置的原因不清楚,请参考 155 页的算法,看看我们为什么需要先前的位置。我们需要这个来做律师协会的决定。

还有更好的。假设我们通过 Bar 120 获得了数据,并且已经完成了具有良好结果的前向遍历。我们重新训练系统,通过条 119 做出决策,保留最后的位置,并使用优化的模型在该条 120 上做出决策。这是我们在现实交易中的第一个头寸,为明天的 121 小节做准备。平稳!

每条的利润?每笔交易?每次?

当我们完成了一个前推测试,并且有了一个逐棒的 OOS 回报集合,我们有几个选择来处理这些数据,为统计分析做准备。

  • 删除所有未开仓的棒线。他们的回报反正是零,所以他们稀释了数据集。只保留所有开仓棒线的单个棒线回报。这可能是最常见的方法,因为它提供了细粒度的数据,但只是我们实际进入市场时的数据。本书中的大多数技术都将使用这种方法。

  • 保留所有的棒线,甚至那些因为没有开仓而返回零的棒线。这提供了最大可能的细节,因为它包括了先前技术中的数据,以及关于我们在市场中的频率的信息。我们将在后面看到的一些分析关注于区分几乎总是在市场上的系统和那些不经常交易的系统。我们应该考虑那些很少交易但成功率很高的系统与那些经常交易但成功率较低但通过大量交易来弥补的系统之间的权衡。

  • 将几组相邻的条形汇集成多个“汇总”回报。例如,我们可以将整个数据集中的前十个棒线(包括没有开仓的棒线)的回报相加为一个单一回报,接下来的十个棒线为第二个回报,依此类推。或者汇集可以是基于日期的,也许汇总成每周或每月的回报。这样做的缺点是丢弃了许多潜在有用的信息,即这些数据包中发生的事情的细节。它还减少了可用于分析的数据量,这总是一件坏事。但它有几大优势。野生棒线(价格波动异常大的棒线)的影响被稀释了,这在统计分析中总是一件好事。此外,随机性也减少了。我们不能通过检查六个单个的棒线回报来了解系统的性能。但是如果我们有半打的回报,每一个都是 10 个棒线回报的总和,我们可以告诉更多一点。我们将在后面看到这种方法,当我们检查一个交易系统是否仍然像预期的那样运行,或者它的性能是否明显恶化。

  • 将每一笔完成的交易(通常称为回合)视为一次返回。我们记录交易开始时的价格和交易结束时的价格。回报是收盘价减去开盘价。

到目前为止,最后一种方法是业内最常见的,因为它很直观。而且这种方法倾向于夸大回报,不管是赢还是输,也没有坏处;如果开发者有一个赢的系统,夸张是受欢迎的,而如果开发者有一个输的系统(有夸张的亏损),我们永远不会看到。但是这种完全交易的方法对于统计分析来说是很糟糕的,既因为夸张又因为信息的损失。我们现在将探讨这些问题。

分析完整的交易回报是有问题的

当我们把所有单个的棒线收益合并成一个横跨整个交易的单一数量时,数据点数量的减少可能是巨大的。如果平均交易持续 50 根棒线,我们分析的数据点数量就会减少 50 倍。对于统计分析,拥有 10 个数据点和 500 个数据点之间的差别是巨大的。

同样严重的是,随着交易的进行,有关市场情况的信息会丢失。也许我们持有多头头寸,市场缓慢而稳定地上涨,直接走向有利可图的出场。或者可能在我们做多后,市场剧烈波动,暴涨,然后暴跌到我们进场点以下,然后在交易结束时反弹,出现盈利。就交易分析而言,这两种情况有着非常不同的含义,但是当我们将条形回报合并成一个单一的净数字时,我们丢失了这些信息,所以我们不知道发生了哪种情况。

在计算利润因子(我最喜欢的性能度量之一)时,细粒度信息的丢失尤其成问题。回想一下,利润因子的定义是赢的总和除以输的总和。考虑一些虚构的数字来说明这个问题。假设我们的系统有两笔交易,每笔交易跨越多根棒线。这两笔交易是一样的,它们的总盈利是 101 点,总亏损是 100 点。因此,每笔交易净赢 1 点。没有亏损的交易,所以基于交易的盈利因子是(1+1)/0;它是无限的。但如果我们从单根棒线计算利润因子,利润因子就是(101+101) / (100+100) = 1.01,本质上一文不值。

夏普比率的问题同样严重,因为问题的本质是内部波动信息的丢失。我们可以有两个相互竞争的系统,它们基于完整的交易回报具有相同的夏普比率,但是如果一个系统具有高的内部波动性,而另一个系统具有低的内部波动性,它们基于棒线的夏普比率将会非常不同(并且更准确!).

我们通常看到的(之前的利润因子演示是一个很好的例子)是,对于任何交易系统,基于已完成的交易回报计算业绩指标,会比基于交易中的单个棒线回报计算得到的值更极端。这部分是因为进入计算的回报数量随着交易回报而减少,导致更大的不稳定性,部分是因为交易中的自然市场变化被抵消了。这可能会导致错误的结论。

总之,我再怎么强调也不为过,你应该对基于交易净回报的业绩指标给予最少的关注。只要有可能,你应该尽可能合理地细分交易,并根据这些数量计算你的指标。当然,如果你正在做一个令人自豪的陈述,你可能会想把你的交易结果用粗体字印在讲义上;每个人都这样,所以你需要平等。但是对于你自己的内部研究,忽略那些数字。看看构成完整交易的细粒度回报。这才是最重要的。

什么程序

在本节开始时(第 195 页),我们探讨了几种用于统计分析的表示回报(典型的是 OOS 回报)的方法。我们还强调了在长期交易中获取逐根棒线回报的重要性,如果有必要的话,可以使用 155 页显示的算法。本节展示了一个演示程序,它将所有这些放在一起:使用第 155 页的算法将一个不确定的先行系统转换为一个先行系统,然后根据第 195 页的选项重新构造先行系统。文件 PER_WHAT。CPP 包含这个程序的完整的、可以编译的源代码。

这个例子中的交易系统是一个简单的多头均线突破系统。当市场价格超过阈值时,该阈值是在具有可优化回看的移动平均线之上的可优化距离,建立多头头寸。即使价格低于进场门槛,这个头寸也要保持到市场价格低于移动平均线。向前推进这种不确定的前视系统,用第 195 页所示的任何方法累计 OOS 结果。最后,计算几个用户指定的性能标准之一。读者应该能够修改训练、测试和 walkforward 例程,以满足他们自己的需要,或者使用这个程序的片段作为他们自己代码的模板。

我们现在研究源代码中最重要的部分,从用户指定的调用参数开始。

PER_WHAT which_crit all_bars ret_type max_lookback n_train n_test filename

让我们来分解这个命令:

  • which_crit:指定哪个标准将用于计算最佳参数,然后评估 OOS 性能。0 =平均回报;1 =利润系数;2 =夏普比率。

  • all_bars:仅适用于训练,且仅适用于平均回报和夏普比率标准。如果非零,所有的棒线,甚至那些没有开仓的棒线,都将被用来计算优化标准。

  • ret_type:仅适用于测试。如第 195 页所述,这将选择我们用于将棒线回报转换为可分析回报的方法。0 =所有条形;1 =位置打开的条形;2 =已完成的交易。如果我们想使用第 195 页上显示的第三种方法,将返回池化到固定块中,我们将在这里使用选项 0 并手动池化。请注意,完成的交易永远不会在培训中使用,因为这是一个可怕的方法,因为大量的信息丢失。

  • max_lookback:训练时尝试的最大移动平均回看(参数优化)。

  • n_train:每个步行折叠的训练集中的小节数。它应该比max_lookback大得多才能得到好的参数估计。

  • n_test:每个前向折叠的测试集中的条形数。较小的值(甚至只有 1)使测试对市场的非平稳性更加稳健,但执行时间要长得多。

  • filename:要读取的市场文件的名称。它没有标题。文件中的每一行都代表一个条形,日期为 YYYYMMDD,至少有一个价格。日期后第一个数字之后的任何数字都将被忽略。例如,市场历史文件中的一行可能如下所示,并且只读取第一个价格(1075.48)。喜欢使用关闭打开/高/低/关闭文件的读者可以很容易地修改这段代码。

20170622 1075.48 1077.02 1073.44 1073.88

我们不会费心解释读取市场信息和分配内存的代码;代码中的注释使这一点不言自明。唯一需要注意的是在源文件开头定义的常量 MKTBUF。我们事先不知道市场历史文件中会有多少条记录,所以价格会以这样的大小重新分配。它的价值并不重要。

我们将直接跳到 walkforward 代码。我们已经读取并存储了nprices市场历史价格,并将它们全部转换成日志。我们将第一个训练集中第一个价格的索引初始化为价格数组的开始。我们还将在向前行走期间累积的 OOS 返回次数的计数初始化为零。

   train_start = 0 ; // Starting index of training set
   nret = 0 ;           // Number of computed returns

这是 walkforward 循环。解释如下。

   for (;;) {

      crit = opt_params ( which_crit ,  all_bars , n_train , prices + train_start ,
                                     max_lookback , &lookback , &thresh , &last_pos ) ;

      n = n_test ;     // Test this many cases
      if (n > nprices - train_start - n_train) // Don't go past the end of history
         n = nprices - train_start - n_train ;

      comp_return ( ret_type , nprices , prices , train_start + n_train , n , lookback ,
                              thresh , last_pos , &n_returns , returns + nret ) ;
      nret += n_returns ;

      train_start += n ;
      if (train_start + n_train >= nprices)
         break ;
      }

我们很快就会看到opt_params()参数优化代码。此呼叫中的许多关键参数已在本节开始时定义。注意,我们将它prices+train_start作为指针传递给当前文件夹的训练集的开始。它返回最佳 MA 回看和最佳进场阈值。它还返回训练集结束时的位置(多头对中性),因为我们希望以此开始 OOS 测试。当然,我们也可以总是从这个位置零开始测试折叠,迫使 OOS 测试总是从零开始。但是在现实生活中,我们实际上总是知道这个位置,或者能够快速地计算出它,所以用这些有用的过去的信息来开始测试阶段是更现实的。

我们让n成为这个折叠的 OOS 测试用例的数量。通常是用户指定的值n_test。但是,如果我们正在进行最后一次折叠,市场历史中剩下的价格可能会更少,因此我们必须相应地限制测试案例的数量。

第一个测试用例的历史数组中的索引是train_start+n_train,当前训练期后的第一个价格。我们通过这个测试例程,之前计算的最佳回顾和阈值,以及在训练期结束时的市场位置。我们还给它一个 OOS 返回数组中的下一个可用槽,returns+nret。它返回给我们刚刚计算出的这个折叠的 OOS 收益数。

到目前为止的返回次数nret,按此折叠更新。我们还推进了训练集开始的索引,使得下一个测试折叠中的第一个条形将紧接在当前测试折叠中的最后一个条形之后。如果我们已经达到了在随后的折叠中将没有测试用例的程度,我们就完成了。当循环退出时,我们在returnsnret个连续的 OOS 返回。

训练(优化)例程的调用参数列表如下所示。所有这些参数都已经讨论过了,有些在本节开头的列表中,有些与刚才显示的 walkforward 代码结合在一起。

double opt_params (
   int which_crit ,              // 0=mean return per bar; 1=profit factor; 2=Sharpe ratio
   int all_bars ,                  // Include return of all bars, even those with no position
   int nprices ,                  // Number of log prices in 'prices'
   double *prices ,            // Log prices
   int max_lookback ,       // Maximum lookback to use
   int *lookback ,               // Returns optimal MA lookback
   double *thresh ,            // Returns optimal breakout threshold factor
   int *last_pos                 // Returns position at end of training set
   )

该例程中最外层的循环尝试回看和进入阈值的每种组合,测试每种组合的性能。用户指定哪个性能标准将被优化。为了保持简单,在速度损失可以忽略不计的情况下,我们将不断更新所有三个标准使用的一些东西,即使它们不会被使用。初始化这些量。我们还假设在培训期开始时没有职位空缺,这当然是一个合理的假设。

   best_perf = -1.e60 ;                                               // Best performance across all trials
   for (ilook=2 ; ilook<=max_lookback ; ilook++) {     // Trial MA lookback
      for (ithresh=1 ; ithresh<=10 ; ithresh++) {           // Trial threshold is 0.01 * ithresh

         total_return = 0.0 ;                           // Cumulate total return for this trial
         win_sum = lose_sum = 1.e-60 ;      // Cumulates for profit factor
         sum_squares = 1.e-60 ;                   // Cumulates for Sharpe ratio
         n_trades = 0 ;                                   // Will count trades
         position = 0 ;                                    // Current position

我们有一对参数(MA 回看和进入阈值)来尝试累积所有有效案例的性能。prices中第一根合法棒线的指标是max_lookback–1,因为我们需要移动平均线中的max_lookback案例(包括判决棒线)。所有回顾都从同一根棒线开始,以使它们具有可比性。我们必须在价格数组结束前停一根棒线,因为我们需要下一个价格来计算决策的回报。在下面的循环中,在棒线i做出决定,该决定的回报是从棒线i到棒线i+1的价格变化。

         for (i=max_lookback-1 ; i<nprices-1 ; i++) { // Compute performance across history

我们不是采用非常慢的方法来重新计算每根棒线的移动平均值,而是在第一根棒线上计算一次,然后为后续棒线更新它。

            if (i == max_lookback-1) {      // Find the moving average for the first valid case.
               MA_sum = 0.0 ;                   // Cumulates MA sum
               for (j=i ; j>i-ilook ; j--)
                  MA_sum += prices[j] ;
               }
            else                                 // Update the moving average
               MA_sum += prices[i] - prices[i-ilook] ;

移动平均值是我们不断更新的总和除以回顾值。我们还从ithresh开始计算试验进入门槛。

            MA_mean = MA_sum / ilook ;                 // Divide price sum by lookback to get MA
            trial_thresh = 1.0 + 0.01 * ithresh ;

现在我们有了均线和试探阈值,我们做一个交易决定。这里实现的算法看起来与第 155 页上的略有不同,但实际上是完全相同的算法。不同之处在于,第 155 页显示的版本是最通用的,如果我们被限制在一个商业平台,我们必须明确地打开和关闭交易。但是如果我们写自己的代码,我们可以简化它。如果进场规则触发,标记我们有一个开放的位置。如果退出规则启动,标记我们退出市场。如果两个规则都没有触发,就保持当前位置。然后根据当前位置计算下一根棒线的返回。因为这里显示的示例系统只有很长,所以这只是一个积极的区别。如果读取器实现了短系统或双系统,请相应地修改此代码。

            if (prices[i] > trial_thresh * MA_mean)         // Do we satisfy the entry test?
               position = 1 ;
            else if (prices[i] < MA_mean)                       // Do we satisfy the exit test?
               position = 0 ;

            if (position)
               ret = prices[i+1] - prices[i] ;                        // Return to next bar after decision
            else
               ret = 0.0 ;

为了简单起见,我们计算所有三个标准,即使我们只使用其中一个。如果你想的话,可以改变它,但是节省的时间是有限的。

            if (all_bars  ||  position) {
               ++n_trades ;
               total_return += ret ;
               sum_squares += ret * ret ;
               if (ret > 0.0)
                  win_sum += ret ;
               else
                  lose_sum -= ret ;
               }

请注意,在前面的if()块中,如果用户指定了all_bars=0,则只有在某个棒线的某个位置未平仓时,该棒线的回报才会进入绩效计算。但是如果用户指定了all_bars非零,那么没有开仓的棒线,因此返回 0,也将参与。这对利润因素没有影响,但它确实影响了其他两个标准,使它们对交易系统在市场中的频率敏感。

现在,我们跟踪性能最佳的参数集。我们更新了迄今为止的最佳表现,以及产生最佳表现的 MA 回望和进入阈值。我们还保存了试用系统在最后一个决定栏中的位置,因为当我们开始对折叠进行 OOS 测试时,我们会需要这个位置。

         if (which_crit == 0) {                                  // Mean return criterion
            total_return /= n_trades + 1.e-30 ;         // Don’t divide by zero
            if (total_return > best_perf) {
               best_perf = total_return ;
               ibestlook = ilook ;
               ibestthresh = ithresh ;
               last_position_of_best = position ;
               }
            }

         else if (which_crit == 1  &&  win_sum / lose_sum > best_perf) { // Profit factor crit
            best_perf = win_sum / lose_sum ;
            ibestlook = ilook ;
            ibestthresh = ithresh ;
            last_position_of_best = position ;
            }

以下夏普比率标准需要特别提及。我们通过从均方减去平均回报的平方来计算回报的方差。通常不鼓励使用这种方法,因为两个大小相似的数字相减会导致浮点不准确。然而,在这种应用中,均方值几乎总是比均方值大得多,因此这个问题在实践中不会成为问题,并且计算速度快,易于理解。

         else if (which_crit == 2) {                                       // Sharpe ratio criterion
            total_return /= n_trades + 1.e-30 ;                      // Now mean return
            sum_squares /= n_trades + 1.e-30 ;
            sum_squares -= total_return * total_return ;      // Variance (may be zero!)
            if (sum_squares < 1.e-20)  // Must not divide by zero or take sqrt of negative
                sum_squares = 1.e-20 ;
            sr = total_return / sqrt ( sum_squares ) ;
            if (sr > best_perf) {                                              // Sharpe ratio
               best_perf = sr ;
               ibestlook = ilook ;
               ibestthresh = ithresh ;
               last_position_of_best = position ;
               }
            }

         } // For ithresh, all short-term lookbacks
      } // For ilook, all long-term lookbacks

在尝试了所有的回顾和进入门槛之后,我们就完成了。返回截至最后一个决策棒(倒数第二个训练集棒)的最佳系统的最优参数和市场位置。

   *lookback = ibestlook ;
   *thresh = 0.01 * ibestthresh ;
   *last_pos = last_position_of_best ;

   return best_perf ;
}

获取这些最佳参数并将其应用于测试文件夹的例程与我们刚刚看到的类似,但我们还是会检查它,重点关注重要的差异。

在学习代码之前,我们必须了解测试期交易的算法。第一个 OOS 交易决策是在训练集的最后一根棒线上做出的。(回想一下,当我们使用刚才显示的代码进行训练时,我们没有对最后一根棒线做出交易决定,因为我们没有下一根棒线来计算回报。下一个小节在测试集中!)第一次 OOS 交易的回报是从训练集中的最后一根棒线到测试集中的第一根棒线的价格变化。

还记得,对最后一根棒线所做的交易决定可能取决于前一根棒线的市场位置。当进入规则和退出规则都没有触发时,就会发生这种情况,所以我们只是继续这个位置。这种依赖性是我们在训练算法中返回last_pos作为最后一根棒线的市场头寸的原因。我们想把它传给 OOS 测试程序,以便在第一次交易中使用。

理解了这一点,下面是测试例程的调用约定。除了第 198 页讨论的ret_type,所有这些项目都已经在训练程序中讨论过了。回顾一下,ret_type选择我们用于将棒线回报转换为可分析回报的方法,如第 195 页所述。调用方指定 0、1 或 2:0 =所有小节。1 =位置打开的条形;2 =已完成的交易。如果我们想使用第 195 页上显示的第三种方法,将返回池化到固定块中,我们将在这里使用选项 0 并手动池化。

此调用列表中的第二个参数nprices未被算法使用,如果需要,可以由阅读器删除。然而,一个assert()语句出现在代码中的一个地方,它向前看以计算一个回报,并且这个安全检查确保我们没有越过市场价格数组的末端。为自己的交易系统修改代码的读者可能想把它留在原处,作为防止粗心错误的廉价保险。

void comp_return (
   int ret_type ,                // Return type: 0, 1, or 2
   int nprices ,                  // N of log prices in 'prices' used only for safety, not algorithm
   double *prices ,           // Log prices
   int istart ,                     // Starting index in OOS test set
   int ntest ,                     // Number of OOS test cases
   int lookback ,               // Optimal MA lookback
   double thresh ,            // Optimal breakout threshold factor
   int last_pos ,                // Position in bar prior to test set (last training set position)
   int *n_returns ,            // Number of returns in 'returns' array
   double *returns            // Bar returns returned here
   )

我们首先初始化一些关键变量。计数器nret是为调用者计算的返回次数。如果返回类型指定我们保留所有的条(ret_type=0),这将等于ntest。否则可以少,往往少很多。优化例程在最后一个柱上给出了最优系统的市场位置,我们得到的是last_pos。我们只需要完成交易选项(ret_type=2)的prior_position。当仓位从零到非零的时候,我们只是开了一个新的仓位,当它从非零到零的时候,我们平仓。如果您的交易系统有未定义的前瞻,可以直接从多头变为空头或从空头变为多头,您将需要根据您想要如何记录已完成的交易来稍微修改这个代码。通常,这样会结束旧的交易,并在同一根棒线上开始新的交易。但其他会计实践也是可能的,包括额外交易开放或一组开放交易部分关闭的情况。请注意,对于“已完成交易”选项,我们必须将开盘价保存在测试块中,以避免将来泄露,因此prior_position=0

   nret = 0 ;                                    // Counts returns that we output
   position = last_pos ;                  // Current position
   prior_position = 0 ;                    // For completed trades, always start out of market
   trial_thresh = 1.0 + thresh ;       // Make it multiplicative for simplicity

在主循环中,我们在棒线i上做出交易决定。第一个决策是在训练集的最后一个小节(istart–1)上做出的,我们做出ntest决策。与训练程序中的情况一样,我们在测试的第一个柱线上计算一次,然后更新,而不是在每个柱线上从头开始重新计算移动平均值。

   for (i=istart-1 ; i<istart-1+ntest ; i++) { // Compute returns across test set

      if (i == istart-1) {              // Find the moving average for the first valid case.
         MA_sum = 0.0 ;            // Cumulates MA sum
         for (j=i ; j>i-lookback ; j--)
            MA_sum += prices[j] ;
         }

      else                                 // Update the moving average
         MA_sum += prices[i] - prices[i-lookback] ;

      MA_mean = MA_sum / lookback ;         // Divide price sum by lookback to get MA

正如我们在优化算法中所做的那样,我们执行第 155 页的算法与这里显示的略有不同,但结果相同。如果开放规则触发,我们确保一个位置是开放的(它可能已经开放)。如果退出规则生效,我们就平仓。如果两个规则都没有触发,我们保持先前的位置。这里的assert()是针对算法或调用者错误的廉价保险,当然,如果程序员对正确性有信心,它可以被省略(并且nprices参数被删除)。然后我们根据位置计算这根棒的回报。

      assert ( i+1 < nprices ) ;                                // Optional cheap insurance

      if (prices[i] > trial_thresh * MA_mean)          // Do we satisfy the entry test?
         position = 1 ;

      else if (prices[i] < MA_mean)                        // Do we satisfy the exit test?
         position = 0 ;

      if (position)
         ret = prices[i+1] - prices[i] ;
      else
         ret = 0.0 ;

此时,我们知道我们的位置,并返回该酒吧。保存(或不保存)适当的输出结果。

      if (ret_type == 0)                     // All bars, even those with no position
         returns[nret++] = ret ;

      else if (ret_type == 1) {           // Only bars with a position
         if (position)
            returns[nret++] = ret ;
         }

      else if (ret_type == 2) {                                // Completed trades
         if (position  &&  ! prior_position)               // We just opened a trade
            open_price = prices[i] ;
         else if (prior_position  &&  ! position)        // We just closed a trade
            returns[nret++] = prices[i] - open_price ;
         else if (position  &&  i==istart-2+ntest)     // Force close at end of data
            returns[nret++] = prices[i+1] - open_price ;
         }

“已完成交易”代码值得额外关注。如果我们的头寸已经从零变为非零,我们刚刚开了一笔交易,所以我们记录开盘价,这是决定棒。如果我们的头寸从非零变为零,我们就平仓了,所以我们记录了它的利润。这个演示系统只有多头,任何时候都只有一个仓位,所以这个交易的回报是决定平仓的价格减去开仓的价格。如果你的系统也可以做空,你需要增加一个额外的检查,并翻转空头仓位的回报符号。如果您的系统可以直接从长到短或从短到长,或者有多个开放的位置,则需要对这个短代码块进行更广泛的修改。

最后一个else if()代码处理当到达 OOS 测试块的末端时仍有位置开放的情况。(在主程序中,我们确保ntest不会超出完整的价格历史数组,所以我们现在不需要检查它。)

我们现在基本上完成了。将prior_position设置到当前位置,继续循环。当循环退出时,在处理完 OOS 测试集中的所有ntest条之后,我们传回返回的计数。

      prior_position = position ;
      } // For i, computing returns across test set

   *n_returns = nret ;
}

虽然这个 PER_WHAT 程序有助于一些有趣的实验,但是许多读者会希望推迟构建和使用这个程序,而是关注将出现在 232 页上的 BOUND_MEAN 程序。该程序实现了与 PER_WHAT 程序相同的交易系统,并且它通过使用几种方法来进一步计算该交易系统在用户提供的任何市场中的可能下限。

平均未来回报的下限

在前面的章节中,我们已经探讨了逐根棒线决策的交易系统,从而提供逐根棒线的回报。我们还举了一个例子,说明如何用一个交易系统,使用进场和出场规则,因此可能有未知的前瞻,并计算逐棒或完全交易的回报。我们会发现一个有用的性能指标是未来这些回报的长期均值的下限。(我们可能也很少对上限感兴趣。)如果我们取得了很好的向前测试结果,但随后我们发现,我们未来可以预期的回报真实均值的合理下限很小,那么我们最好回到制图板。简而言之,优秀的回测性能很棒但还不够。我们非常有信心这种出色的表现会持续下去。这是本节的主题。

首先,冒着过于迂腐的风险,我将简要回顾一下我们可能正在处理的更重要的回报类型,并加入一些评论。

  • 每个人都想知道已完成交易的回报界限。不幸的是,在大多数实际情况下,这是最难获得高度可靠的数据。造成这一困难的主要原因是缺乏数据。在统计分析中,数量等于可靠性。我们只有和交易一样多的数据点,除非系统频繁交易,否则我们的回报往往太少,无法计算出有用的边界。尽管如此,这是一个如此有用和有意义的数字,我们不能把它一笔勾销。

  • 我最喜欢的收益率是开仓棒线的平均收益率。这将提供比已完成交易的回报更多的数据点。这也是一个合理的绩效指标,因为它告诉我们,我们承担持仓风险(和可能的保证金支出)的预期回报。

  • 另一个常用的均值回归是所有棒线的子集(如周线总和)的回归。如果我们正在监控持续的性能以检测退化,这是很重要的。

简短的题外话:假设检验

我们的最终目标是对未来的平均回报有一个下限,我们很快就会达到这个目标。但是有一个有用的选择,也可以作为信心界限的垫脚石,所以我们从假设检验的主题开始。顺便说一下,为了简单起见,这里我们将关注单边测试,那些希望断言我们实现的平均回报远远大于零的测试,以提供我们有一个有用的交易系统的信心。稍后,我们将把这推广到“负面”措施的单边测试,如提款,并最终查看一个区间内的边界参数,这是一项在金融分析中不常做的任务,但在某些情况下仍然有用。

一个经典的假设检验使用间接推理来陈述我们的交易系统的质量,就像观察到的平均回报所暗示的那样。我们需要定义两个假设。

  • 无效假设通常是令人厌烦的“默认”假设,我们希望这种情况不会发生。当评估一个交易系统的观测 OOS 回报时,我们的零假设通常是这个系统是没有价值的:它的真实预期回报是零或更少。

  • 另一种假设通常是我们希望有效的情况。在目前的情况下,另一个假设是,我们的交易系统是好的,这是由一个非常大的积极的观察样本平均回报所证明的。

间接推理是这样工作的:

  1. 假设零假设为真,并计算在此假设下平均回报的理论分布(或我们的测试统计量是什么)。这是最难的部分。

  2. 使用这个分布,计算我们随机观察到的样本均值与我们获得的均值一样大(或更大)的概率。

  3. 如果这个概率很小,则断定零假设是错误的。

这是可行的,因为我们必须始终将无效假设和替代假设定义为互斥穷尽。这意味着不可能两个假设都为真,这两个假设涵盖了所有的可能性。真正的情况总是非此即彼,决不是两者兼而有之,也决不是两者皆非。

基本逻辑是这样的:假设我们看到,如果零假设是真的,我们观察到的回报很可能不会这么好。在这种情况下,我们得出结论,另一个假设可能是正确的。

重要的是要明白,得到一个与零假设完全一致的结果并不能让我们断言零假设是真的,甚至可能是真的。无论我们观察到什么样的结果,我们都不能断言零假设是正确的。我们只能断言零假设可能是错误的,从而断言另一种可能是正确的。

这里有两个例子可以说明这种情况。假设有人在两个相同的大罐子里装满了糖豆,两个罐子的高度都一样。你仔细看着他们,试着做一个陈述。你能说它们含有相同数量的软糖吗?他们看起来非常非常接近。但很可能一个包含 1000,另一个包含 1001。你永远看不出区别。你甚至不能说他们可能一样,因为你不知道填充者是否有一个议程来愚弄人们。另一方面,假设一个罐子显然装得比另一个高得多。然后你就可以理直气壮地说,它们含有不相等数量的糖豆。

第二个例子更接近手头的任务。假设我们正在测试我们交易系统的质量。它有两笔交易,一笔获利 10%,一笔亏损 8%。如果系统真的一文不值,那么仅仅两次交易就产生这种结果(或更好结果)的概率会非常高,因此我们不能用间接逻辑来拒绝零假设,从而断言替代方案。那么,这是不是意味着我们可以理直气壮地断言零假设是真的,系统就一文不值了呢?甚至可能毫无价值?当然不会,因为两笔交易太少了,不足以做出这样的决定。如果我们使用更长的市场历史,我们可能会获得 100%的回报和 100%的损失。在这种情况下,我们可能会发现,一个真正没有价值的系统表现如此出色的概率非常低。因此,我们可以拒绝这个系统毫无价值的无效假设,并断定它可能有价值。当然,我们可能仍然认为它没有赚到足够的钱来证明这个风险,但这是另一个问题。

底线是,未能拒绝零假设可能只是因为我们没有做足够的测试,而不是因为零假设是真的。如果我们延长测试期,我们可能会得出零假设是错误的结论。或者,也许我们选择了一个不恰当的测试程序,无法拒绝零假设。因此,我们必须永远不要断言零假设的真实性。

那么,我们如何使用这个概率呢?

让我们简要回顾一下上一节开始时提出的假设检验步骤。首先,我们假设零假设(无聊的情况)为真,并计算我们的测试统计量的统计分布(当前上下文中的平均回报)。第二,我们在这个零假设分布的上下文中考虑我们的检验统计的观察值。第三,如果我们观察到的值(或更好的值)在这种假设下非常不可能,我们得出结论,另一种假设(有趣的情况)可能是正确的。我们可以做三件具体的事情来执行这个过程,其中一件是完全合法的,一件是基本合法但处于灰色地带,还有一件是可怕的错误。

  • 执行这个测试的官方正确方法是提前决定错误拒绝零假设的概率是多少,我们愿意接受这个概率。回想一下,假设是零假设为真,我们正在计算我们的观察值(或更好的值)在这个假设下可能被观察到的概率。因此,如果这个观察到的概率很小,我们因此拒绝了一个真正的零假设,我们这样做是错误的。通常预先设置 0.05 的概率阈值,决定如果我们的观察值的概率为 0.05 或更小,我们将拒绝零假设。这意味着当我们进行测试并且零假设为真时,我们将有 5%的机会错误地拒绝这个假设。在目前的情况下,这意味着如果我们的交易系统真的一文不值,我们有 5%的机会错误地认为它合法赚钱。我们可能会更保守,要求只有 1%的机会错误地拒绝为真的零假设,这将给我们一个更严格的测试,一个更难通过的测试。或者我们可能放松要求,愿意接受 10%的错误结论,即一个真正没有价值的系统赚钱。在这种情况下,我们将我们的概率阈值设置为 0.1,如果我们观察到的平均回报的概率为 0.1 或更小,则认为是合法的。

    等效地,我们可以预先计算对应于概率为 0.1 的零假设分布下的值。然后,如果我们观察到的平均值等于或超过这个阈值,我们得出合法性的结论。如果你没有立即看到它,请思考这个等价性。(请记住,观察平均值越大,概率越小。)你用哪种方式做测试都没什么区别;他们是相同的。

  • 包括我自己在内的许多人都在使用另一种假设检验方法,因为如果一个人不小心解释结果,它会提供更多的信息,但代价是为一些滥用敞开大门。在这种方法中,不需要预先指定错误概率阈值,如 0.05 或其他值。取而代之的是,一个人继续前进,在零假设下计算获得与我们所获得的一样好或更好的结果的概率。在这种情况下,这个概率被称为一个 p 值。这给我们的不仅仅是第一种方法给我们的拒绝/不拒绝决定。它给了我们一个量化的数字。如果我们得到 p 值为 0.049,我们的结论是,这个测试在 0.05 的误差水平上拒绝了零假设,但只是勉强拒绝,所以我们应该小心谨慎。另一方面,如果我们得到 0.001 的 p 值,我们正确地得出结论:如果零假设是真的,我们的交易系统就不太可能像以前一样好。这仍然不足以交易系统;可能是它的风险/回报比差。但在其他条件相同的情况下,我们可以合理地得出结论,p 值为 0.001 比 0.049 更令人鼓舞。

    我提到过使用这种方法有风险。这里有一个很大很普通的,很微妙的。我们可能而不是使用 p 值作为系统相对值的可靠度量。如果我们的 p 值为 0.001,我们可能会有一种温暖、模糊的感觉,并且比 p 值为 0.049 时对我们的系统更有信心。但仅此而已。温暖而模糊;仅此而已。我们可能而不是得出结论,我们对哪一个更好已经有了明确的决定。如果我们采用 0.049 的系统,并对一段较长的历史数据进行测试,我们可能也会得到 p 值 0.001。这是假设检验的一大弱点:它们依赖于检验了多少数据。因此,在用数字解释 p 值时要小心。你可以(也应该)做这件事,但要有充分的把握。

  • 第三种偶尔使用的假设检验方法是 不正确的 !我们将在这里讨论它,不断提醒读者,这个要点中呈现的每一点“逻辑”都是错误的。假设您获得了 p 值 0.01,这是一个非常令人鼓舞的结果(一个合理的结论)。许多人使用的完全不正确的逻辑是,由于一个没有价值的系统只有 1%的机会侥幸获得如此好的结果(真的),如果我们断定这个系统是熟练的,我们只有 1%的机会出错(假的!).一些富有冒险精神的开发人员可能会更大胆地得出结论:因为我们得出系统熟练的结论是错误的,这种可能性只有 1 %(错!),系统有 99%的几率是熟练的(不可能!).

这最后一点很多人难以下咽,我们就来阐述一下。关键是假设检验的 p 值是有条件的。它说如果零假设为真,p 值就是得到至少和观察到的一样好的结果的概率。这个陈述中没有关于零假设是否为真的内容。

这里有一个粗略的例子。我们被告知,经过多年的研究,我们知道 99%的狗都有四条腿。由于不幸的事故,1%的狗少于四条腿。时不时地,有人打电话给你,说他们有一只有一定数量的腿的动物,他们问你关于它是否是一只狗的意见。今天他们打电话说他们的动物有两条腿。你知道狗只有百分之一的时间少于四条腿。考虑到这一点,你合理地得出结论,它可能不是一只狗,而且你对这个结论感到满意,因为两条腿的狗很少。在所有的时间里,动物真的是一只狗,你会被愚弄,只有 1%的时间称它为非狗。

但是你不能说这个动物是或者不是狗的概率。如果你不知道,那个定期给你打电话的人是从狗收容所来的,他只是在和你开玩笑。他向你提到的每一种动物,不管它有几条腿,都是狗。然后,每次他告诉你这个动物少于四条腿,而你因此断定它不是狗,你就错了。一直都是。上一页第三个要点的错误逻辑说你有 99%的机会是正确的,而事实上你有 0%的机会是正确的!真糟糕。另一方面,如果电话来自一个严格意义上的猫收容所,每次你拒绝零假设,你将是正确的。一直都是。因此,根据电话来自哪里,你要么永远不打,要么永远打。

总之,在使用基于平均回报(或后面讨论的其他数量)的假设检验我们交易系统的质量时,必须记住以下几点:

  • 如果我们的表现如此之好,以至于一个没有价值的系统只有很小的概率(p 值)至少能得到这么好的分数,我们可能有信心我们的交易系统有真正的技巧,而不仅仅是运气好。如果我们预先设置一个 p 值阈值(本节的第一个要点),并决定当且仅当我们实现的 p 值如此之小或更小时,系统是熟练的,那么在我们的 p 值所基于的无价值系统的宇宙中,我们将被愚弄,以预先指定的 p 值概率错误地声称熟练。这当然激励我们设定一个低 p 值阈值。我们想要一个低概率的被忽悠去声明一个没有价值的系统是熟练的。

  • 如果我们得不到一个小的 p 值,我们可能而不是得出这个系统毫无价值的结论。也许我们只是没有测试正确或测试足够的市场历史。

  • 不管我们的 p 值有多大,不管它是小得令人愉快还是大得令人讨厌,我们都不能说我们的系统是无价值的或熟练的。没什么?

参数 P 值

在前面的部分中,我们大肆讨论了 p 值的用途,即如果零假设为真,我们获得的性能至少与我们获得的性能一样好的概率。在当前的背景下,这是我们的 OOS 平均回报可能至少与我们获得的一样大的概率,仅仅是因为一个真正没有价值的交易系统是幸运的。但是我们如何计算这个 p 值呢?有几种常见的方法,本节讨论最简单的方法。

可以说所有统计中最重要的分布是正态分布。它之所以达到这个崇高的地位,是因为(非常粗略地说)当你把独立的、同分布的随机变量加在一起时,它们的和(和均值)趋向于正态分布。即使变量不完全独立或同分布,它们的总和(和平均值)的分布也有很强的趋势接近常见的正态分布的钟形曲线。带着一些谨慎,我们可能经常假设我们的交易系统的回报遵循一个足够接近正态的分布,我们可以根据这个假设进行统计测试。特别是,我们将使用学生的 t 检验,这是一种假设数据正态性的标准检验,但对中度非正态性相当稳健。

在继续之前,我们必须清楚在交易系统回报上使用基于正态性的 t 检验所涉及的最重要的问题。这个测试对常见形式的非正态性的中度水平具有惊人的稳健性,例如偏斜度(分布形状缺乏对称性)和重尾(极端值不严重极端)。它对异常轻的尾部(很少或没有极值)非常稳健。但是 t 检验的最大杀手是真正的极端情况,甚至是一个极端情况。如果我们的大部分盈亏都集中在-5 到 5 之间,而我们的回报率是 50,那么 t 检验就没有价值了。因此,在使用 t 检验计算收益的 p 值之前,一个必须绘制一个要检验的收益直方图。在钟形曲线的合理范围内的极端情况是好的(不需要挑剔),但是如果一个或多个回报远离大部分回报,使用后面描述的测试之一。

这不是挖掘 t-test 细节的地方;参考资料随处可得,有些读者可能想比这里的表面处理更深入一点。现在,我们只处理数学公式和代码片段,演示如何计算一组回报的 p 值,在这种情况下,决定回报是否足够好,以证明交易系统有技巧而不仅仅是运气。大多数情况下,这些回报是未平仓的单个棒线的回报,尽管可以测试前一章讨论的任何其他类型的回报。

x 1x 2 ,… x n 是要计算其 p 值的返回结果。它们的平均值由公式 6-1 给出。我们将总体标准差估计为无偏方差估计量的平方根,如等式 6-2 所示。这组回报的 t 分数由等式 6-3 给出。如果我们将具有 df 自由度(通常为n–1)的 t 统计量的累积分布函数指定为 CDF( df,t ),那么等式 6-4 就是相关的 p 值。这是 t 分数等于或超过指定值的概率,在我们的上下文中,这是一个没有价值的交易系统的平均回报等于或超过我们仅通过运气获得的平均回报的概率。

)

(6-1)

)

(6-2)

)

(6-3)

)

(6-4)

熟悉 t 分数的敏锐读者会注意到,等式 6-3 是在真均值为零的零假设下的 t 分数。但是在第 211 页指出,无效假设和替代假设必须是互斥的和穷尽的。为了满足详尽的部分,无价值的零假设必须是交易系统有一个真实的均值,即零或负。那么,为什么我们可以假设真均值为零而忽略负真均值的可能性呢?当我们给出方程 6-5 时,答案会变得更清楚,但是现在要明白,如果真实均值是负的,实际的 t 值会比方程 6-3 给出的值更大,p 值会更小。因此,零的真实平均值是最保守的情况;如果我们在零假设下拒绝,我们会在负均值零假设下更强烈地拒绝。因此,让零假设成为真实均值为零是合理的。我们可以忽略负真均值的可能性。

下面是演示这些计算的代码片段。这段代码摘自程序 BOUND_MEAN。CPP,为清晰起见做了一些小的修改。t_CDF()函数的源代码可以在 STATS.CPP 文件中找到。完整的程序及其应用示例将在第 233 页给出。

   mean = 0.0 ;                                            // Equation 6-1
   for (i=0 ; i<n ; i++)
      mean += returns[i] ;
   mean /= n ;

   stddev = 0.0 ;                                          // Equation 6-2
   for (i=0 ; i<n ; i++) {
      diff = returns[i] - mean ;
      stddev += diff * diff ;
      }
   stddev = sqrt ( stddev / (n - 1) ) ;

   t = sqrt((double) n) * mean / stddev ;      // Equation 6-3

   pval = 1.0 - t_CDF ( n-1 , t ) ;                  // Equation 6-4

参数置信区间

有一个 p 值,我们可以用它来检验零假设,即我们的交易系统是没有价值的,这很好,但更好的是有真实均值可能存在的范围。在任何领域的任何假设测试中,如果我们测试了足够多的案例,我们将获得哪怕是最微弱的合理效果。这在自动交易系统的分析中尤其成问题,在这种系统中,我们可能要回溯测试几十年。通常情况下,我们的交易系统确实有少量的技能,如果我们使用数千根棒线的交易回报进行假设检验,我们可能会得到一个小的 p 值,从而正确地得出结论,我们的系统可能有合法的技能。但是,如果我们的系统所拥有的实际技能提供了 0.5%的预期年化回报率,那该怎么办呢?这是真正的技能,如果有足够大的样本集,假设检验就能检测出来。但是没有人会想交易这个系统,不管有没有技能。它的回报虽然真实,但太小而无利可图。本节的主题是一个简单的方法来计算我们系统的真实平均收益的上界(很少需要)和下界。在第 222 页,我们将介绍一种非常不同的计算方法,即自举法。

回头看一下方程式 6-3 。这说明了如何在系统的真实平均回报率为零的零假设下,根据观察到的平均回报率计算 t 值。我们现在需要这个方程的更一般的形式,它不假设真实的平均值是零。这显示在方程式 6-5 中。在这个等式中, ObsMean 是观察到的平均值,它对应于等式 6-3 中的 Mean ,即你的 OOS 测试的平均回报。真均值是未知的真均值。注意当它为零时,等式 6-5 与等式 6-3 相同。

)

(6-5)

根据定义,方程 6-4 中出现的累积分布函数 CDF( df,t )是随机抽取的 t-score 将小于或等于指定的 t 的概率。定义这个函数的逆为 InvCDF( df,p )。根据定义,该函数为我们提供了 t-score 阈值,该阈值具有这样的性质:随机抽取的 t-score 将以指定的概率 p 小于或等于该阈值。为了便于标注,我们将 InvCDF( df,p )指定为 t p ,其中df = n–1。这个定义在等式 6-6 中陈述,其中 t 是随机观察到的 t 分数。

)

(6-6)

我们收集我们的 OOS 回报,并计算它们的平均值。我们不知道未来收益总体的真实均值,但我们想对其做一个概率陈述。为此,取方程 6-5 定义的 t 分数,并用它代替方程 6-6 中的 t 。这给了我们方程 6-7 ,一些简单的代数重排将它转化为方程 6-8 。

)

(6-7)

)

(6-8)

我们在方程 6-9 中定义了一个叫做下界的图形。就是上一个不等式左边的量。注意,它很容易计算;我们所需要的是我们的 OOS 回报的平均值,它们的标准偏差由等式 6-2 定义,回报的数量 n ,以及我们期望的概率的 t 分数阈值,由等式 6-6 定义。我们现在讨论为什么我们称之为 LowerBound 以及它的含义。

)

(6-9)

我们不知道将从中得出未来回报的总体的真实均值。在我们的 OOS 测试集中,我们确实有回报的平均值,并且有理由假设真实的总体平均值将在这个附近的某个地方。但是我们的 OOS 测试数据只是从人群中随机抽取的样本。这可能是不吉利的,因此低估了真正的意义。或者可能是运气好,对未来有乐观的看法。我们想量化这种可变性。

假设真实的平均值,我们不知道也不可能知道,恰好等于等式 6-9 中定义的下限,并且我们刚刚从样本中计算出来。请记住,这个真实平均值是一个实际的、固定的数字,例如 5.21766 或其他数字。我们不知道它是什么,但这并没有减少它的真实性。现在回头看看方程式 6-8 。不等式右边的数字是一个未知但固定的(假设平稳性!)值。不等式左侧的量是一个随机变量,受我们选择的 OOS 测试周期的抽样误差的影响。为刚刚运行的实验选择我们的 OOS 测试周期的行为是随机样本,因此等式 6-8 适用:不等式左侧的可计算量有概率 p 小于或等于真实平均值,我们暂时假设真实平均值是等式 6-9 中的值。我们很可能将 p 设置得很大,比如这个例子中的 0.95,所以这个不等式很可能成立。换句话说,如果我们不知道的真均值恰好等于等式 6-9 给出的值,我们称之为下界*,那么有 0.95 的概率满足等式 6-8 中的不等式。事实上,既然 LowerBound 是不等式左边的量,我们就有了完美的等式;条件得到满足,但只是勉强满足。*

现在考虑真实平均值实际上大于下限的可能性。很明显,方程 6-8 中的不等式很容易满足,有真正的不等式。但是如果真均值 as de 小于 LowerBound 呢?现在不等式不成立,概率很小(本例中 1–0.95 = 0.05)。换句话说, LowerBound 是满足等式6-8中不等式的真实均值的阈值,如果我们将 p 设置为高,这种情况的概率很高。

一些硬数字可能会让这一点更加清晰。假设我们对 100 个退货进行抽样。我们观察到平均回报率为 8,回报率的标准差为 5。我们设置 p =0.95,这样我们就可以 95%确定我们的真实回报率的下限。相关的 t 分数约为 1.66。将这些数字代入方程 6-9 得到的下界为 8–5 * 1.66/sqrt(100)= 7.17。

这个结果可以有两种解释。通常的解释是合理的,尽管不是严格正确的,就是说有 95%的可能性回报的真实均值,未来回报的中心值,至少是 7.17。这种解释的问题是,它听起来好像真实均值是一个随机变量,根据我们的 OOS 结果,我们刚刚计算出真实均值至少有一些最小值的概率。其实真正的均值是一个固定的数,而不是随机的。我们收集的 OOS 样本是随机数量。因此,严格正确的解释是,7.17 是真实平均值的最小值,至少有 95%的概率观察到我们获得的质量或更好的 OOS 样本。请不要过分强调这个概念。使用第一种也是最常见的解释,你并没有犯下严重的罪。

下面是演示这些计算的代码片段,摘自 BOUND_MEAN。CPP,为清晰起见做了一些小的修改。inverse_t_CDF()的源代码在 STATS.CPP 中。完整的程序及其应用示例将在 233 页展示。

   mean = 0.0 ;                                            // Equation 6-1
   for (i=0 ; i<n ; i++)
      mean += returns[i] ;
   mean /= n ;

   stddev = 0.0 ;                                          // Equation 6-2
   for (i=0 ; i<n ; i++) {
      diff = returns[i] - mean ;
      stddev += diff * diff ;
      }
   stddev = sqrt ( stddev / (n - 1) ) ;

   lower_bound = mean - stddev / sqrt((double) n) * inverse_t_CDF ( n-1 , 0.95 ) ;

人们几乎永远不会对真实均值的上限感兴趣。然而,为了完整起见,我们注意到上界是由等式 6-9 给出的,只是减号改为了加号。感兴趣的读者会发现这一事实提供了丰富的信息,可能还很有趣。这个推理与下界的推理基本相同,只是不等式的方向颠倒了。

请注意,如果您想要一个内部置信区间作为 de,一对界限,以便您可以用指定的概率说真实均值位于该区间内,您必须分割“失败”概率。例如,假设您希望真实平均值有 90%的概率位于下限和上限之间。您必须将 10%的故障拆分为每侧 5%的故障,使用 p =0.95 作为下限和上限。这就给出了 5%的可能性,真实平均值低于下限,5%的可能性高于上限,剩下 90%的可能性介于两者之间。

置信下限和假设检验

我们用一个有用的观察来结束这次讨论,这个观察很容易在刚才讨论的学生的 t 情景中得到证明,而且事实上在更普遍的情况下也是如此。回头看看第 217 页的方程式 6-3 。在该部分中,我们计算了 t-score,以检验真均值为零的零假设与真均值大于零的替代假设。现在看看等式 6-9 来计算真实均值的下限。对这两个方程的简单代数操作揭示了一个有趣的(也许并不令人惊讶)事实:当且仅当下界为正时,零假设将被拒绝。 所以我们实际上不需要做单独的测试。我们所要做的就是使用等式 6-9 来计算对应于某个 p 的下界,比如我们在例子中使用的 0.95 保证。当且仅当等式 6-9 给出一个正数时,我们可以在 1–p水平(1-0.95=0.05)拒绝零均值的零假设。(注意,由于我们通常有一个连续的分布,下限正好为零的概率是零,但为了保守起见,我们通常要求它为正,以拒绝零假设。)

自助置信区间

前一节的界定真实回报均值的方法易于理解和编程,计算速度快,除了存在一个或多个极端异常值外,通常对任何问题都相当稳健。但有时我们确实有一些可疑的异常值,或者我们可能想要格外小心。在这种情况下,至少对于平均回报,我们有一个更复杂但通常更安全的方法,叫做 bootstrap

主要有三种不同的 bootstrap 方法来寻找真实均值的下限(如果我们需要,也可以是上限)。对于所有三种方法的相当严格和相当容易理解的讨论,请参见我的书评估和改进预测和分类。关于极其严谨的陈述,请参阅埃夫龙和蒂布希拉尼的优秀著作自举简介。这里,我们将简要介绍其中的两种方法,但只详细介绍在这种应用中几乎总是三种方法中最好的一种。此外,因为这个最佳算法的理论背景是残酷的,想要研究这个理论的读者可以参考 Efron 和 Tibshirani 的资料。这里我们只关注相关的等式和源代码。

支点法和百分位数法

自举背后最容易理解的想法通常被称为 pivot 方法。首先考虑我们的处境。我们的交易系统给我们提供了一系列的回报(只要市场特征保持不变,它还会继续给我们提供回报)。当我们使用前面部分的学生 t 方法时,我们假设这些程序的分布不是非常不正常的。在更一般的情况下,我们对这种分布一无所知。我们希望对它的真实均值有一个很好的猜测,并且我们还希望对 OOS 样本的平均收益的样本间变化有一个估计。如果我们知道这种变化的大小和性质,我们就可以建立可能的真实均值的概率界限。

不幸的是,我们只有一个样本的回报,即从 OOS 测试集。这对于估计样本间的差异或样本均值对真实均值的任何可能的高估或低估来说并不太有用。但是我们可以做一些非常聪明的事情(谢谢你,布拉德利·埃夫隆)。我们可以假装我们的样本的回报实际上是整个总体的回报,并且这个假装总体至少在某些重要方面与母总体有些相似。当然,我们不能假设完全相似。我们样本中的回报可能平均大于或小于母体中的回报。它们可能有更大或更小的变化。因此,由于这种不可避免的随机变化,bootstrap 远非完美。但我们通常可以通过假设我们的样本是一个有代表性的父母代群体,然后从中抽样来收集一些有用的信息。

bootstrapping 中枢方法背后的基本思想是,无论我们在假装是人口的 OOS 样本中看到什么影响,都会在来自真实人口的原始样本中得到反映。例如,假设我们收集了一个 OOS 回报的样本,并根据这个样本计算了一些检验统计量。目前,我们的测试统计是平均值,但稍后我们将探索其他性能测量,所以我们使用一般术语测试统计而不是具体的。现在,我们从我们的 OOS 样本中随机抽取一个同样大小的样本,并进行替换。我们原始样本中的一些回报不会出现在这个 bootstrap 样本中,而另一些会出现多次。这是随机抽取的。我们计算这个引导样本的检验统计量。然后我们一次又一次地重复,成百上千次。因此,我们有成百上千的检验统计值,每个值都是从 bootstrap 样本中计算出来的。

我们知道原始样本中检验统计量的值,它现在扮演着总体的角色。假设我们发现,平均而言,我们的 bootstrap 样本中的检验统计量低估了原始样本中检验统计量的值几个百分点。bootstrap 假设是,我们原始样本中的检验统计量同样会低估总体中未知的真实值。因此,为了更好地估计检验统计量的真实总体值,我们将检验统计量的计算值增加几个百分点,无论需要多少来增加引导样本的平均值,以使该平均值达到原始样本的值。

我们在变异方面做了类似的事情。我们假设,无论我们在众多 bootstrap 样本的检验统计中看到什么样的变化,当我们收集 OOS 检验回报时,我们都受到了同样程度的变化。这给了我们一个很好的想法,我们的样本的平均回报可能离真实的总体平均回报有多远,因此我们可以计算真实平均值的概率下限和上限。

计算 bootstrap 置信区间的第二种主要方法称为百分位数法。这个概念在表面上更容易理解,但是一旦深入到表面之下就复杂得多了(在这里我们不会这样做)。算法很简单:收集大量 bootstrap 样本(理想情况下是数千个)并计算感兴趣的参数,这将是我们上下文中的平均值。那么在原始样本的 bootstrap 抽样下的那些计算值的分布被假定为在未知母体分布下的原始样本值的分布。因此,例如,该分布的第 5 个百分位数成为真实平均值的 95%置信下限,该分布的第 95 个百分位数成为真实平均值的 95%置信上限。这非常简单,而且令人惊讶的是,它在很多情况下都有效。

具有一定数学能力的雄心勃勃的读者可能想得出这样的结果,即由 pivot 和 percentile 方法产生的置信区间是彼此相反的:如果一种方法产生的下界比上界离计算的样本检验统计量远得多,那么另一种方法产生的上界比下界离样本检验统计量远得多。考虑到这种奇怪的情况,这两种方法能奏效已经是个奇迹了,但它们通常都做得很好。另一方面,在下一节中描述并在本文中使用的第三种方法是所有方法中最可靠的。

BC a 自举算法

本节中介绍的算法比上一节中的枢轴法和百分位数法适用范围更广。它有效的精确数学条件是广泛的,尽管不是普遍的。我的书评估和改进预测和分类做得相当好(如果我自己这么说的话!)列出了它有效的确切条件,并以受过中等程度数学训练的人应该能够理解的方式来这样做。然而,这种讨论超出了本文的范围,因为本文更倾向于实用性和数学背景有限的目标读者。如果你感兴趣,请参阅我的评估和改进预测和分类一书,或者如果你想进行激烈而彻底的讨论,请参阅埃夫龙和蒂布希拉尼的书引导法介绍。现在,请注意 BC a bootstrap(“偏差校正和加速”的缩写)很容易处理平均回报率以及除比率指标之外的大多数其他绩效指标,这将在第 238 页讨论。

为了使用 BC引导来计算置信界限,我们需要执行四个步骤。

*1. 计算偏差校正,它补偿必要的隐式转换的偏差程度。

  1. 计算加速度,它补偿隐式转换参数的方差依赖于其值的程度。

  2. 使用前面描述的百分位数方法计算下限和上限,然后根据这些修正修改分位点。

  3. 从排序的 bootstrap 参数估计中获得分位数。

我们现在一次一个地描述这些步骤。在整个讨论中,φ(z)代表正态累积分布函数(CDF),φ–1(p)是它的逆。

**第一步:**偏差修正只是简单的计数。我们看到有多少自举参数估计值小于原始样本的估计值。偏差校正是小于 grand 值的复制分数的逆法线 CDF。这在方程 6-10 中表示。在这个等式中,)是第 b 个 bootstrap 样本的参数估计值(平均回报率或我们正在研究的任何其他性能指标),总共有 B 个 bootstrap 样本。原始样本的参数估计值是),而#[]操作只是为了统计在Bbootstrap 中有多少次不相等。

)

(6-10)

步骤 2: 为了计算加速度,我们需要在参数估计器上执行刀切。我们的 OOS 返回集由 n 个案例组成。我们暂时从集合中移除案例 i ,并使用剩余的n–1 案例来计算参数。让)指定该参数值。设)为这些 n 折叠值的平均值,如公式 6-11 所示。然后加速度由公式 6-12 给出。

)

(6-11)

)

(6-12)

**第三步:**我们根据偏差和加速度修改百分位数法的分位点。例如,假设我们想要 90%的置信区间。假设我们想将失效概率上下平均分配,分位点将为α=0.05 和α=0.95。(这种分裂在 222 页讨论过。)通过等式 6-13 从原始α计算出修正的分位点αʹ。该等式分别应用于上端点和下端点。注意,如果偏差校正和加速度都为零,αʹ=α.

)

(6-13)

**第四步:**最后一步只是普通的百分位 bootstrap 算法,但是使用的是方程 6-13 提供的修正分位点,而不是用户指定的点。将)的 B 值按升序排序,并从此数组中选择下限和上限。对下限的无偏选择,其中αʹ < 0.5,是选择元素 k (索引 1 到 B ),其中 k = αʹ( * B * +1),如果有分数余数,则向下截断为整数。对于上界,αʹ > 0.5,让k=(1–αʹ)(b+1),如果有分数余数,则向下截断为整数。元素B+1-k是置信上限。

正如在基于 Student’s-t 的置信度一节中所提到的,当使用 bootstrap 算法计算平均收益率的置信区间时,我们很少会对上界感兴趣。我们最感兴趣的是真实的平均回报可能有多小。当然,如果我们也了解到真正的回报可能相当大,我们可能会很高兴。显而易见,我们已经完成了计算下界的 99.9%的工作;此外,寻找上限是一个微不足道的额外工作。因此,给出的所有例程都计算这两个边界。想用就用吧。

CONF 的靴子。CPP 子程序

文件 BOOT_CONF。我网站上的 CPP 包含了使用百分位数和 BC方法计算各种置信区间的子程序。在本节中,我们将学习这段代码。

为了打下基础,我们提出了一个简单却惊人有效的百分位数算法,它位于高级 BC算法的核心。回想一下前面的简短讨论,该算法只是使用大量 bootstrap 样本来评估正在研究的参数(如均值回报),它假设参数估计的结果分布直接为总体中参数的真实值提供了置信区间。使用以下调用约定(和变量声明)调用该算法:*

void boot_conf_pctile ( // Percentile bootstrap algorithm
   int n ,                         // Number of cases in sample
   double *x ,                 // Variable in sample
   double (*user_t) (int , double *) , // Compute parameter
   int nboot ,                  // Number of bootstrap replications
   double *low2p5 ,       // Output of lower 2.5% bound
   double *high2p5 ,      // Output of upper 2.5% bound
   double *low5 ,            // Output of lower 5% bound
   double *high5 ,          // Output of upper 5% bound
   double *low10 ,         // Output of lower 10% bound
   double *high10 ,        // Output of upper 10% bound
   double *xwork ,          // Work area n long
   double *work2           // Work area nboot long
   )
{
   int i, rep, k ;

我们最有可能用来自我们在x中的 OOS 测试的n返回来调用这个例程。user_t()函数将计算给定向量的平均值,假设我们感兴趣的性能指标是平均回报。我们应该把nboot设置得很大;一万也不是没有道理。

第一步是抽取大量 bootstrap 样本,并计算每个样本的目标参数。我们保存它们以便以后分类。以下代码中的外部循环绘制了每个nboot(前面讨论中的 B )样本。对于每个复制,我们从原始样本中随机抽取n个案例。每个 bootstrap 样本包含与原始样本相同数量的事例是很重要的,因为许多参数对样本中事例的数量很敏感。

   for (rep=0 ; rep<nboot ; rep++) {       // Do all bootstrap reps (b from 1 to B)

      for (i=0 ; i<n ; i++) {                        // Generate the bootstrap sample
         k = (int) (unifrand() * n) ;             // Randomly select a case from the sample
         if (k >= n)                                       // Should never happen, but be prepared
            k = n - 1 ;
         xwork[i] = x[k] ;                               // Put bootstrap sample in xwork
         }

      work2[rep] = user_t ( n , xwork ) ;  // Save parameter from this bootstrap sample
      }

对参数估计进行排序。qsortd()例程将待排序数组中第一个和最后一个案例的索引作为其参数。如第 227 页的步骤 4 所述,使用无偏分位数估值器从排序后的数组中提取下限和上限。这里只显示了一对边界,因为除了乘数之外,其他边界都是相同的。随意设置你想要的任何分位数乘数,或者让它成为一个调用参数。

   qsortd ( 0 , nboot-1 , work2 ) ;      // Sort ascending

   k = (int) (0.025 * (nboot + 1)) - 1 ; // Unbiased quantile estimator
   if (k < 0)
      k = 0 ;
   *low2p5 = work2[k] ;
   *high2p5 = work2[nboot-1-k] ;

顺便说一句,如果你想用一般的劣势枢纽法,那些界限很容易从百分位界限得到。设 Param 为原始样本的参数值。然后pivot lower= 2 *Parampctile upperpivot upper= 2 *Parampctile lower。好奇的读者可能想再读一遍 224 页对 pivot 方法的描述,然后研究这些公式如何反映总体-样本估计中样本-样本关系的逻辑。

我们现在转到 BC a bootstrap,它几乎总是优于中枢法和百分位数法。它类似于刚才显示的百分位数方法,因为参数是从大量 bootstrap 样本中估计的,这些估计值被排序,并且从排序后的数组中提取界限。不同的是,所选择的元素是从稍微调整的位置选择的。调用参数列表与百分位数方法的参数列表相同,但为了清楚起见,这里的参数列表为:

void boot_conf_BCa (   // BCa bootstrap algorithm
   int n ,                          // Number of cases in sample
   double *x ,                  // Variable in sample
   double (*user_t) (int , double *) , // Compute parameter
   int nboot ,                   // Number of bootstrap replications
   double *low2p5 ,        // Output of lower 2.5% bound
   double *high2p5 ,       // Output of upper 2.5% bound
   double *low5 ,            // Output of lower 5% bound
   double *high5 ,           // Output of upper 5% bound
   double *low10 ,          // Output of lower 10% bound
   double *high10 ,         // Output of upper 10% bound
   double *xwork ,          // Work area n long
   double *work2            // Work area nboot long
   )
{
   int i, rep, k, z0_count ;
   double param, theta_hat, theta_dot, z0, zlo, zhi, alo, ahi ;
   double xtemp, xlast, diff, numer, denom, accel ;

它从评估原始样本的参数开始。然后,它计算并保存nboot引导样本的参数值。在这样做的同时,它为方程 6-10 计算z0

   theta_hat = user_t ( n , x ) ;              // Parameter for full set

   z0_count = 0 ;                                   // Will count for computing z0 later
   for (rep=0 ; rep<nboot ; rep++) {       // Do all bootstrap reps (b from 1 to B)
      for (i=0 ; i<n ; i++) {                        // Generate the bootstrap sample
         k = (int) (unifrand() * n) ;              // Select a case from the sample
         if (k >= n)                                    // Should never happen, but be prepared
            k = n - 1 ;
         xwork[i] = x[k] ;                            // Put bootstrap sample in xwork
         }

      param = user_t ( n , xwork ) ;         // Param for this bootstrap rep
      work2[rep] = param ;                      // Save it for CDF later
      if (param < theta_hat)                    // Count how many < full set param
         ++z0_count ;                               // For computing z0 (Equation 6-10)
      }

   z0 = inverse_normal_cdf ( (double) z0_count / (double) nboot ) ; // In STATS.CPP

现在我们做步骤 2 中描述的折叠,换句话说,方程式 6-11 。原样品再加工n次,每次省略一例。然后我们评估方程 6-12 。

   xlast = x[n-1] ;
   theta_dot = 0.0 ;
   for (i=0 ; i<n ; i++) {                   // Jackknife Equation 6-11
      xtemp = x[i] ;                          // Preserve case being temporarily removed
      x[i] = xlast ;                            // Swap in last case
      param = user_t ( n-1 , x ) ;     // Param for this jackknife
      theta_dot += param ;             // Cumulate mean across jackknife
      xwork[i] = param ;                  // Save for computing accel later
      x[i] = xtemp ;                          // Restore original case
      }

   theta_dot /= n ;                         // This block of code evaluates Equation 6-12
   numer = denom = 0.0 ;
   for (i=0 ; i<n ; i++) {
      diff = theta_dot - xwork[i] ;
      xtemp = diff * diff ;
      denom += xtemp ;
      numer += xtemp * diff ;
      }

   denom = sqrt ( denom ) ;
   denom = denom * denom * denom ;
   accel = numer / (6.0 * denom + 1.e-60) ;

艰难的工作完成了。我们对 bootstrap 样本参数进行排序,正如我们对百分位数法所做的那样。

   qsortd ( 0 , nboot-1 , work2 ) ;      // Sort ascending

我们按照第 226 页第 3 步的等式 6-13 所述修改用户的分位点。

   zlo = inverse_normal_cdf ( 0.025 ) ;
   zhi = inverse_normal_cdf ( 0.975 ) ;
   alo = normal_cdf ( z0 + (z0 + zlo) / (1.0 - accel * (z0 + zlo)) ) ;
   ahi = normal_cdf ( z0 + (z0 + zhi) / (1.0 - accel * (z0 + zhi)) ) ;

最后一步与我们对百分位数方法所做的相同,除了不使用给定的分位点,我们使用修改的点,并且我们必须分别做下限和上限。我们不能对两者使用同一个k

   k = (int) (alo * (nboot + 1)) - 1 ; // Unbiased quantile estimator
   if (k < 0)
      k = 0 ;
   *low2p5 = work2[k] ;

   k = (int) ((1.0-ahi) * (nboot + 1)) - 1 ;
   if (k < 0)
      k = 0 ;
   *high2p5 = work2[nboot-1-k] ;

我们在这里只显示了 0.025(下限)和 0.975(上限)。其他几个界限是在 BOOT_CONF 的源代码中完成的。CPP。你可以随意使用任何你想要的分数。

SPX 的 BOUND_MEAN 程序及结果

文件绑定意味着。CPP 包含一个完整的程序,它扩展了第 198 页上的 PER_WHAT 程序。交易系统是完全一样的,所以请根据需要查看该部分。做了一个简化:BOUND_MEAN 实现中的优化标准总是持仓时的平均回报。PER_WHAT 中提供的其他培训选项被省略了,尽管如果需要的话,读者可以很容易地将它们放回去。另一个小变化是 PER_WHAT 只根据用户选择的一种返回类型来计算性能,而 BOUND_MEAN 同时计算三种主要的返回类型,以便于比较。

但是最大的变化是 BOUND_MEAN 计算了一个 t-score(第 217 页上的方程 6-3 和相关的 p-value(第 217 页上的方程 6-4 ),用于对真实均值为 0(或负)的零假设进行假设检验,而不是真实均值为正。它还使用第 220 页的等式 6-9 计算出真实平均值的 90%置信下限。最后,它使用三种不同的 bootstrap 算法计算 90%的置信下限:百分位数法、枢纽法(第 224 页)和 BC法(第 225 页)。所有这些结果都打印在一个紧凑的表格中。

*程序调用如下:

BOUND_MEAN max_lookback n_train n_test n_boot filename

让我们来分解这个命令:

  • max_lookback:训练时尝试的最大移动平均回看(参数优化)。

  • n_train:每个步行折叠的训练集中的小节数。它应该比max_lookback大得多才能得到好的参数估计。

  • n_test:每个前向折叠的测试集中的条形数。较小的值(甚至只有 1)使测试对市场的非平稳性更加稳健,但执行时间要长得多。

  • n_boot:引导复制次数。这应该是运行时允许的最大值。值 10,000 不是不合理的,应该被认为是严肃测试的最小值。

  • filename:要读取的市场文件的名称。它没有标题。文件中的每一行都代表一个条形,日期为 YYYYMMDD,至少有一个价格。日期后第一个数字之后的任何数字都将被忽略。例如,市场历史文件中的一行可能如下所示,并且只读取第一个价格(1075.48)。喜欢使用关闭打开/高/低/关闭文件的读者可以很容易地修改这段代码。

20170622 1075.48 1077.02 1073.44 1073.88

在进入源代码的关键部分之前,让我们看一下这个程序在应用于数十年的 SPX 时的输出。如图 6-1 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-1

SPX 中移动平均线突破的 BOUND_MEAN

我们有 23557 天的价格(!).训练该系统的最大移动平均回顾是 100 天;我们使用 1000 个训练案例,每次进行 100 天的 OOS 测试。对于基于棒线的回报率,我们乘以 25,200,得出回报率的大致年化百分比。

我们测试了三种主要的回报类型。未平仓交易未平仓头寸回报是未平仓头寸的棒线回报。完成回报是每笔完成交易(回合)的净回报。组合的回报是棒线回报,无论头寸是否开放,都被计算成 10 天的数据块。这些回报比开仓回报小得多,因为平均值中包含了所有的零(如果没有开仓,棒线的回报为零)。纯属巧合的是,基于 t 检验的检验的 p 值恰好基本上为 0.1,所以我们看到真实均值的 90%置信下限基本上为零(–0.0022)时不应感到惊讶。如果这还不清楚,请参阅第 222 页关于假设检验和置信区间下限之间等价性的讨论。还要注意的是,bootstrap 测试给出了接近零的下限,尽管通常的 pivot 方法很奇怪。尽管有其直观的吸引力,pivot 方法通常是三种常见的 bootstrap 算法中最不可靠的。

从这次示威中可以学到重要的一课。未平仓的棒线的年化平均回报率约为 9.91%,单独来看,这是一个相当可观的数字。但是,一个毫无价值的系统能够完全凭借运气取得至少这么好的结果的 t 检验概率是 0.1000,令人沮丧的是,这个概率非常小。此外,如果我们看看真实均值下限的 90%置信区间,未来回报将围绕这个区间,这个下限实际上是负的!当然,它几乎是负的,几乎为零,但这仍然不是我想交易的系统。所有三类回报的各种 bootstrap 界限同样缺乏启发性。回到画板上。

我们现在来看看 BOUND_MEAN 程序的一些代码片段。交易系统(opt_params()comp_return())在第 198 页有详细的讨论,这是关于 PER_WHAT 程序的。请根据需要参考该部分。我们现在关注 walkforward 代码。代码清单后面是讨论。

   train_start = 0 ;       // Starting index of training set
   nret_open = nret_complete = nret_grouped = 0 ;

   for (;;) {

      // Train

      crit = opt_params ( n_train , prices + train_start ,
                          max_lookback , &lookback , &thresh , &last_pos ) ;

      n = n_test ;  // Test this many cases
      if (n > nprices - train_start - n_train) // Don't go past the end of history
         n = nprices - train_start - n_train ;

      // Test with each of the three return types

      comp_return ( 0 , nprices , prices , train_start + n_train , n , lookback ,
                    thresh , last_pos , &n_returns , returns_grouped + nret_grouped ) ;
      nret_grouped += n_returns ;

      comp_return ( 1 , nprices , prices , train_start + n_train , n , lookback ,
                    thresh , last_pos , &n_returns , returns_open + nret_open ) ;
      nret_open += n_returns ;

      comp_return ( 2 , nprices , prices , train_start + n_train , n , lookback ,
              thresh , last_pos , &n_returns , returns_complete + nret_complete ) ;
      nret_complete += n_returns ;

      // Advance fold window; quit if done
      train_start += n ;
      if (train_start + n_train >= nprices)
         break ;
      }

我们将第一个训练集的开始初始化为市场历史的开始。对每种类型的退货(开仓、完成、分组)进行计数的三个计数器被归零。

walkforward 循环的第一步是调用opt_params()来找到最佳的回顾和阈值。该例程还返回训练集结束时的位置。其目的将在 PER_WHAT 一节中讨论。然后我们假设测试用例的数量将是调用者指定的数量,但是我们确保我们没有超出市场历史。

我们调用comp_return(),指定所有的棒线都包含在回报中,不管头寸是否开放。这将在稍后的组返回中聚集。对comp_return()的另外两个调用分别用于未平仓棒线和完整回报。

在所有三个 OOS 测试完成后,我们前进到下一个折叠,如果没有 OOS 测试用例剩余,我们就退出 walkforward 循环。

此时,分组的返回仍然是未分组的,只是单个棒线返回。我们现在将它们分组,使用任意分组的 10 个小节,读者可以很容易地更改它们,甚至可以制作一个用户参数。

   crunch = 10 ;   // Change this to whatever you wish
   n_returns = (nret_grouped + crunch - 1) / crunch ;   // This many returns after crunching

   for (i=0 ; i<n_returns ; i++) {                    // Each crunched return
      n = crunch ;                                          // Normally this many in group
      if (i*crunch+n > nret_grouped)            // May run short in last group
         n = nret_grouped - i*crunch ;            // This many in last group
      sum = 0.0 ;
      for (j=i*crunch ; j<i*crunch+n ; j++)      // Sum all in this gorup
        sum += returns_grouped[j] ;
      returns_grouped[i] = sum / n ;              // Compute mean per group
      }

   nret_grouped = n_returns ;

我们现在可以计算 t 值和相关的 p 值。开仓返回(所有变量以_open结尾)的代码如下:

   mean_open = 0.0 ;
   for (i=0 ; i<nret_open ; i++)
      mean_open += returns_open[i] ;
   mean_open /= (nret_open + 1.e-60) ;

   stddev_open = 0.0 ;
   for (i=0 ; i<nret_open ; i++) {
      diff = returns_open[i] - mean_open ;
      stddev_open += diff * diff ;
      }

   if (nret_open > 1) {
      stddev_open = sqrt ( stddev_open / (nret_open - 1) ) ;
      t_open = sqrt((double) nret_open) * mean_open / (stddev_open + 1.e-20) ;
      p_open = 1.0 - t_CDF ( nret_open-1 , t_open ) ;
      t_lower_open = mean_open - stddev_open / sqrt((double) nret_open) *
                                inverse_t_CDF ( nret_open-1 , 0.9 ) ;
      }
   else {
      stddev_open = t_open = 0.0 ;
      p_open = 1.0 ;
      t_lower_open = 0.0 ;
      }

在前面的代码中,我们使用通常的公式累计平均值和标准偏差。如果我们的回报少于两个,标准差是不确定的,所以我们使用合理的默认值。否则,我们使用方程 6-3 计算 t 值,并使用方程 6-4 计算相关的 p 值。请注意,在计算 t 值时,我们如何防止被零除。然后使用公式 6-9 计算 90%置信度下真实均值的下限。

通过调用第 228 页描述的boot_conf_pctile()子程序计算百分位和枢轴引导。此处显示了未平仓收益的代码。平凡的例程find_mean()只是将返回结果相加,然后除以它们的个数。计算出的下限在b1_lower_open中返回。通过为计算值提供虚拟变量,忽略所有其他界限。

在第 229 页,我们看到,从百分位数界限很容易获得枢轴法的置信界限。我们使用这个简单的公式来计算b2_lower_open,使用 pivot 方法的下限。我的评估和改进预测和分类一书详细阐述了这两种方法之间的关系。但是,因为我通常不推荐 pivot 方法,所以我不会在这里赘述。

   boot_conf_pctile ( nret_open , returns_open , find_mean , n_boot ,
                     &sum , &sum , &sum , &sum , &b1_lower_open , &high ,
                     xwork , work2 ) ;

   b2_lower_open = 2.0 * mean_open - high ;

最后,我们调用boot_conf_BCa()来使用普遍良好的 BC a 方法计算真实均值的下界。

   boot_conf_BCa ( nret_open , returns_open , find_mean , n_boot ,
                  &sum , &sum , &sum , &sum , &b3_lower_open , &high ,
                  xwork , work2 ) ;

当心自举比率

对于均值和其他表现良好的性能指标,bootstrap 几乎总是工作得很好。但对于分母变小时可能会剧烈膨胀的基于比率的衡量标准,bootstrap 通常会失败。这种方法的两个经典例子是夏普比率和利润系数。在这一节中,我们将介绍 BOOT_RATIO 程序(完整的源代码在 BOOT_RATIO 中。CPP),它生成随机交易,并探索这些交易的夏普比率和利润因子的 bootstrap 置信区间的行为。

在实验之前,我们讨论程序的测试原理。置信区间的本质,无论是封闭区间(上下界)还是开放区间(只有一个界),都是以特定的概率被违反。例如,假设我们想要计算一个性能统计的下限,并且我们想要确信该性能统计的真实值在 95%的时间内都等于或高于我们的下限。同样,当我们从一个随机样本中计算这个下限时,我们希望我们计算出的界限在 5%的时间里超过真实值。如果它经常超过真实值,我们就处于危险的境地,因为我们的置信区间没有我们想象的那么好。如果它超出真实值不到 5 %,情况就没那么糟,因为这只是意味着我们比自己认为的更经常是正确的。但这种情况在某种程度上仍然是糟糕的,因为这意味着我们的计算界限不必要的宽松,也许宽松到我们对自己的交易系统失去信心。可能我们计算的下限太低了,以至于我们放弃了潜在的交易系统,而事实上,如果下限计算正确,我们可能会对交易系统感到满意。

BOOT_RATIO 程序的调用如下:

BOOT_RATIO nsamples nboot ntries prob

让我们来分解这个命令:

  • nsamples:市场历史价格变化次数

  • nboot:引导复制次数

  • ntries:生成摘要的试验次数

  • 交易成功的概率

它产生随机的市场价格变化,每次都持续很长时间。每一个变化都被认为是一个概率为prob的胜利;否则就是亏了。给定这组回报,nboot bootstrap 样本用于使用百分点、pivot 和 BC a 方法计算利润系数的下限和上限。这些界限的计算违反概率为 0.025、0.05 和 0.1。

随机生成的一组输赢的真实盈利因子是prob / ( 1 – prob)。因此,在我们计算了这六个界限(下限和上限各三个概率)后,我们将每个与真实利润因子进行比较,并注意是否违反了界限。

这个过程重复ntries次,对于这六个界限中的每一个和这三个引导方法中的每一个,我们计数违例,以便我们可以将违例的实际数量与正确的数量进行比较。

在进行ntries试验时,我们跟踪随机收益总体的夏普比率,总共是它们的nsamplesntries。在所有利润因素试验完成后,我们重复夏普比率的整个过程。没有简单的方法来计算理论夏普比率,所以我们使用这个人口值。为了确保准确性,我们用相同的随机种子开始利润因子试验和夏普比率试验,确保产生完全相同的一组损益。这些试验完成后,打印结果。

图 6-2 显示了 1000 个返回集合的 BOOT_RATIO 结果,图 6-3 显示了 50 个返回集合的结果。被测系统毫无价值(prob =0.5)。采用了大量的引导复制和试验来确保稳定性。请注意,在这两张图中,平均夏普比率和真实夏普比率基本上为零,平均利润因子和真实利润因子基本上为一,正如预期的那样。对于只有 50 个回报的情况,平均利润因子略高于 1,因为分母有时可能非常小,导致一些极端利润因子夸大了平均值。图表分为三栏,分别对应prob =0.025,0.05,0.1。每个带括号对中左边的数字是下限的故障率百分比(100* prob),右边的数字是上限的故障率百分比。因此,我们希望这两个数字都等于该列的百分比。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-3

返回 50 次的 BOOT_RATIO 结果

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-2

BOOT_RATIO 结果有 1,000 个返回

请注意以下几点:

  • 问题在最左边的列中最明显,这是 2.5%的失败率(在每个界限中 97.5%的置信度)。我们希望所有这些带括号的数字都是 2.5。

  • 枢轴法是迄今为止最差的方法。对于 2.5%的预期失败率和 50 次交易的利润因子,下限永远不会低于真实利润因子。这意味着下界是如此之低,以至于毫无价值。与此同时,实际利润系数的上限几乎是预期的 4 倍,这是一种灾难性的情况。

  • 利润因子比夏普比率表现更差。

但利润因素并不意味着一切都完了。问题是,特别是在少数回报的情况下,极重的右尾,微小的分母会产生巨大的利润因子。我们所要做的就是利用利润因子的对数来驯服尾巴。图 6-4 显示了我们不引导利润因子而引导其日志的结果。差别很大,尤其是下界,这是我们的主要兴趣。BCa2.5%的上限确实有所恶化,这令人不安,但很少有人会关心上限。对于 5%和 10%的失败率,两个界限都有很大改善。教训是,如果我们正在引导一个具有重尾的发行版,我们应该以驯服尾部的方式进行转换。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-4

BOOT _ RATIO 50 次退货的对数利润因子结果

限制未来回报

在前面的章节中,我们讨论了寻找总体真实均值的界限(通常只是一个较低的界限),未来的回报将从这个界限中出现。这是一个相当简单的任务,可以通过相对简单和容易理解的计算来完成。我们现在处理一个更复杂的任务,但在实践中可能非常有用。我们并不关心未来收益的真实均值,而是想限制实际收益。

试图约束单个棒线回报几乎没有意义;相对于它们的平均值来说,它们会有很大的变化,因此界限将变得毫无价值。但我们很可能对完全交易的边际回报感兴趣。在现实生活中,我们绝对希望能够绑定分组平均收益。这样做的主要目的是帮助我们确认持续的绩效。例如,假设我们执行一个扩展的向前运行来产生多年的 OOS 回报。我们可以按月对这些回报进行分组,并计算月平均回报。使用这一部分的技巧,我们可以找到预期未来月收益的一个可能的下限。当我们交易这个系统时,我们跟踪它每月的实际表现。如果这个表现低于我们之前计算的下限,我们有理由怀疑我们的交易系统正在退化。

没有多少数学背景的读者可能会被这一节的内容吓到。但是,没必要恐怖地跳过。请放心,演示操作的关键部分的代码片段将在此过程中提供,一个完整的程序将它与实际的交易系统和市场数据放在一起,将结束演示。

该技术分为三个部分。我们将从寻找未来收益的近似但合理的下限的方法开始。然后,我们将探讨如何量化在这个下限的计算中不可避免的随机误差。最后,对于那些我们也想知道未来回报上限的罕见情况,我们将算法推广到这种情况。

从经验五分位数得出下限

在开始之前,我们必须绝对清楚这一节的材料和前面几节的材料之间的区别。在此之前,我们一直在界定(并且可能会继续)从中抽取回报的人群的平均值。这很有用,因为我们想合理地确定我们的总体回报的真实均值足够大,值得交易。毕竟,未来的回报将倾向于围绕这个均值。

但现在我们将尝试约束实际回报本身。最常见的是集体退货,如我们系统的月度退货。我们可能想知道未来几个月的月回报率的概率下限。偶尔,我们也可能对已完成交易的净回报感兴趣。这项任务比仅仅界定这些回报所来自的人口的平均值要困难得多。

我们从收集 OOS 的回归开始。对于这项任务,我们需要大量这样的返回来获得可靠的界限,通常最少一百个返回,最好是几百个甚至几千个。因此,如果我们处理的是月度回报,我们需要一个覆盖十年以上的扩展 OOS 检验。如果有必要,我们可以缩短返回周期,也许使用每周返回。但是更短的回报周期导致回报的更大差异,这反过来导致计算的下限太低,以至于它们没有什么价值。因此,我们陷入了一个艰难的妥协:从更短的时间间隔获得更多的回报会给我们更准确但更没用的边界。尽力就好。

我们的基本假设是,我们收集的 OOS 回归数据公平地代表了未来将从中抽取回归数据的回归人口。正如我们将在下一节中看到的,这并不是严格正确的,这意味着我们计算的下限会受到令人不安的随机误差的影响,我们将在后面量化这一点。但是现在,假设这是真的。

首先,一些直觉。假设我们的集合中有 100 份 OOS 回报(月度,已完成的交易,或者其他)。还假设这些回报中有 10%的价值为-8 或更低。那么我们最初的技术是基于这样的假设,未来的回报也有 10%的机会是-8 或更少。我们收集的回报越多,我们就越能相信这个关键假设的有效性,我们将在下一节量化这个想法。

我们需要一个定义。随机变量 X 的分布的 p 阶的分位数定义为值 x 使得 P { X<X}≤P和 P { X<X}≥P。当我们发现对应于特定概率的 t 分数时,我们在等式 6-6 中看到了类似的东西。在这种情况下,我们正在寻找一个连续分布的分位数,学生的 t 分布。但这里我们有一个离散分布,一组数字。因此,为了完全精确,我们需要覆盖值的两侧,即使这样, x 也可能不是唯一的;它可能是两个离散值之间的间隔。

用连续分布来考虑分位数是最容易的,因此为了不产生有害的实际影响,我们将从现在开始这样做。例如,如果我们知道有百分之 10(0.1)的概率回报将小于或等于-8,我们说这个分布的 0.1 分位数是-8。我们用于计算回报下限的算法计算 OOS 回报的分位数,假设该集合代表真实总体,并使用这些经验分位数来计算所需的任何界限。

我们可以更严谨一些。未来回报的下限是从 OOS 回报的 n 中计算出来的,如下所示:假设我们想要一个下限,其中我们可以有 1–p的置信度(失败的概率是某个小的 p 比如 0.05)。按升序对退货进行排序。计算 m = np ,如果 m 不是整数,则截断小数部分。这个公式提供了一个保守的(低于精确的)界限。如果我们碰巧想要一个无偏但保守性稍差的下界,让m=(n+1)p。那么排序后的样本中第 m 个最小的返回就是我们的近似下界。

计算出的下限的置信度

这是读者回顾“插曲:无偏到底是什么意思?”第 125 页。那一节指出,无偏并不一定意味着,嗯,无偏。事实上,几乎可以肯定的是,我们的 OOS 回报率在某种程度上是有偏差的。随机抽样误差可能导致我们的 OOS 集合低估了未来的回报,这意味着我们的集合是悲观的偏见。同样可能的是,它可能高估了未来的回报,使其过于乐观。无论是哪种情况,几乎可以肯定的是而不是是未来回报的良好代表。如果我们收集的回报真的是样本外的,我们说它是无偏的,只是因为它对乐观或悲观没有预设的偏见。任何一种情况都有可能。这种可能表现出的偏见类型的平衡使我们可以称之为无偏的。但是可以肯定的是,这种或那种方式是有偏见的,我们没有办法知道是哪一种。

这意味着我们计算的下限有一点(或者可能很多!)关了。如果我们的 OOS 集合因为取样的坏运气而悲观地有偏差,我们计算的下限将会太低。或者,如果我们的收集是乐观的,赢过表示,我们计算的下限将太高。自然地,当我们把我们的下限放在跟踪正在进行的表现上时,这会产生严重的后果。我们需要做的是量化我们下界可能的误差。这是本节的主题。

我们在前面的章节中看到,我们指定了一些小的失败率 p ,并将未来回报的下限计算为我们的 OOS 集合中第 m=np 个最小的回报。在这样做的时候,我们自鸣得意地假设未来的回报只有很小的概率 p 小于或等于我们计算的下限。

但是请记住,我们的 OOS 集是随机的,乐观或悲观。有两件事我们必须担心。最重要考虑是我们计算出的下限的真正失败率可能有多大。例如,假设我们让 p 为 0.1,这意味着我们想要一个下限,这样在未来只有 10%的时间回报会小于或等于这个下限。现在的问题是,“获得小于或等于这个下限的回报的实际概率可能有多高(大于我们指定的 p )换句话说,未来的回报可能会(令我们沮丧地)小于或等于这个下限,其概率高于我们期望的 0.1。我们想对这种情况做一个概率陈述。

最直接的概率陈述如下:我们为未来回报下限选择的最小 OOS 回报m实际上是回报分布的分位数q或更差的分位数的概率是多少,其中我们指定了一些比我们期望的p大得多的q?我们希望这种概率很小,因为这一事件是一个严重的问题,因为它意味着我们未来回报的计算下限太高,因此比我们认为的更有可能被违反。

例如,我们可以指定 p =0.1,这意味着我们愿意接受未来回报违反我们下限的 10%的可能性。但是我们也可以指定 q =0.15,一个我们认为不可接受的失效率;10%的未来回报违反我们的下限是可以的,但是 15%的失败就不行了。我们想要计算基于 0.1 的下限的真实故障率实际上是 0.15 或更差的概率。我们希望这种概率很小。

这个概率问题由不完全贝塔分布来回答。在一组 n 的情况下, m th 最小值超过 q 分位数的概率由 1-Iq(mnm+1)给出。STATS 中的子程序orderstat_tail()。CPP 计算这个概率,随着对这个主题的讨论的继续,我们会发现这个例程非常有用。

有时我们更喜欢以相反的顺序做事。不是首先指定一些悲观的 q > p 然后询问随机抽样误差给我们一个具有真实失效率的下限的概率 q ,我们首先指定这种欺骗的令人满意的低概率,然后计算对应于这个概率的 q 。例如,我们可能会指定,我们希望收集到 OOS 返回集的概率非常低(比如说,只有 0.001),该返回集提供了一个下限,其实际违反概率远远大于我们的预期。假设我们有 200 个 OOS 收益,我们设 p =0.1,意思是 m =20。因此,第 20 个最小的 OOS 回报是我们未来回报的下限。我们需要找到悲观的 q > p 这样 1—Iq(20,181)=0.001。STATS 中的子程序quantile_conf()。CPP 告诉我们 q =0.18。换句话说,只有 0.001 的微小概率,我们的下限,我们希望有 10%的时间被违反,实际上会有 18%或更多的时间被违反。从另一个角度来看,我们有 0.999 的近似确定性,我们假设的 p = 0.1 的下限实际上失败的概率不会超过 18%。那不是很好,但是另一方面,要求如此高的确定性是要求很多。

如果我们计算未来回报下限的目的只是对我们选择的交易系统的未来回报有多糟糕有一个概念,那么我们需要的就是刚才描述的“悲观”方法。假设与我们所探索的相反的情况是正确的。事实上,如果我们计算的下限太低而不是太高(真正的失败率小于我们选择的 p 而不是更大),唯一的暗示就是未来的损失(假设我们的交易系统持续稳定)不会像我们想象的那么严重。那很好,除非我们的下界差到排斥我们的交易系统。但我们应该对拒绝一个基于预期最差回报的系统犹豫不决,因为这些回报几乎总是负面的和令人沮丧的。如前所述,我们应该更加重视预期平均收益的界限。因此,如果我们只是收集未来最坏情况的信息,悲观的 q 方法就足够了。

然而,未来回报计算下限的一个重要用途是跟踪已投入使用的交易系统的持续表现。当我们设计最终的系统时,我们应该设定一个合理的失败概率 p (可能是 0.05 到 0.1 左右),并使用本节的技术计算未来回报的下限。如果在未来的某个时候,我们得到的回报低于这个下限,我们应该怀疑系统正在恶化。如果再次发生,我们应该认真考虑放弃或至少修改该系统。

当我们以这种方式使用我们的下限时,我们需要的不仅仅是一个悲观的 q > p ,一个我们计算的下限超过我们期望的失败率的真实分位数有多严重的指示。我们应该更加担心相反的错误:我们计算出的下限的真实失效率小于我们期望的失效率 p 。当我们计算的下限太低时,就会发生这种情况。在这种不幸的情况下,我们可能会观察到一个或多个收益略高于我们的下限,因此并不令人担忧,而事实上这些损失超过了与我们期望的故障率相对应的真实下限。因此,我们将犯下最严重的错误,忽略了对我们交易系统的合法恶化进行标记。

计算“乐观” q < p 的过程与我们在本节前面所做的几乎相同。我们可以使用orderstat_tail()来计算第 m 个最小的 OOS 回报(这是我们的下限)超过某个指定的乐观的 q < p 的概率,尽管现在我们必须从 1 中减去这个概率来得到这个不幸事件发生的概率。这是因为orderstat_tail()计算了计算出的界限高于指定分位数 q > p 的概率,这个问题在本节开始时已经解决。但现在我们担心的是相反的问题。我们想要计算出的界限低于指定的乐观分位数 q 的概率。如果我们要避免未能发现交易系统真正合法恶化的错误,这种概率必须很小。

正如悲观的 q 测试的情况一样,我们有一个替代方案来指定一个乐观的 q ,然后计算它的概率。相反,我们可以使用具有大概率quantile_conf()(比如 0.95 到 0.999 左右)来计算乐观的 q 。我们将在后面的高细节准理论部分探索所有这些可能性,然后是实践部分。

总结这一节,我们有 n OOS 收益,我们想计算未来收益的下限。我们选择一个较小的失败概率, p ,作为未来收益小于或等于我们计算的界限的概率。设 m = np 为保守界限,或m=(n+1)p为无偏界限。为了量化随机误差的影响,我们有一些悲观的 q > p ,这是由于我们的界限太大,以及相关的概率。我们也可以考虑一些乐观的 q < p ,这是由于我们的界限太小,以及一个相关的概率。我们必须找出这些量之间的关系。

未来回报的上限呢?

乍一想,有人可能会认为,想要计算未来回报的上限是不寻常的。毕竟,如果我们的回报好于预期,我们还在乎什么呢?我们主要关心的似乎是我们未来的回报会有多糟糕,所以我们知道会发生什么。我们甚至可能想要(实际上,我们应该想要!)跟踪现有系统的持续性能,如果我们开始获得低于预期下限的回报,则发出红色警报。

但仔细想想就会发现,如果我们在观察一个运行中的系统,不仅仅是过于糟糕的交易预示着可能的恶化。如果我们看到的好交易没有我们预期的那么多,我们也应该怀疑。请记住,界限有相关的失败率(我们指定的),在上限的情况下,我们所说的失败(超过上限)在现实中会被视为成功!

因此,我们倾向于使用一个大得多的“失败”率作为上限,并且如果系统仍然按目标运行,我们期望看到“失败”的程度。例如,我们可以设定失败率的上限为 p =0.4,从而期望 40%的未来交易的回报至少与计算的上限一样大。如果这一比例大幅下降到 40%以下,我们就应该怀疑了。

可以使用与下限完全相同的数学方法来计算上限以及相关的乐观和悲观值 q 。对于下限,我们使用第 m 个最小的 OOS 回报,对于上限,我们使用第 m 个最大的。概率也是如此。我们现在不再迂腐地陈述这些简单的转换,而是在下一节中用源代码来探索它们。

最大限度计划:概述

本节描述了一个“教程”程序,该程序没有实际用途,但详细演示了计算未来回报界限背后的思想。在下一节中,我们将介绍一个实用的程序,它用真实的市场数据执行真实的交易系统,并计算上几节中讨论的数量。本节的目的是巩固我们正在处理的概念,让读者熟悉计算量的真正含义。

程序调用如下:

CONFTEST nsamples fail_rate low_q high_q p_of_q

让我们来分解这个命令:

  • nsamples:每次审判的 OOS 病例数(至少 20 例)。在现实生活中,少于 100 个 OOS 病例是没有意义的,最好是至少几百个。否则,计算出的边界会有太多的随机变化,不切实际。

  • fail_rate:计算界限的期望失败率。这是之前讨论过的 p 。对于较低的界限,这通常会很小,可能是 0.01 到 0.1。对于上限,这通常会更大,可能是 0.2 到 0.5。CONFTEST 程序对两者都使用了fail_rate

  • low_q:低于预期的令人担忧的故障率(< fail_rate)。这是乐观的 q ,由于 OOS 集中的随机抽样误差,计算的下限太低。该程序计算出真实分位数如此糟糕或更糟糕的概率。

  • high_q:高于预期的令人担忧的故障率(> fail_rate)。这是悲观的 q ,由于 OOS 集中的随机抽样误差,计算出的下限太高。该程序计算出真实分位数如此糟糕或更糟糕的概率。

  • p_of_q:故障概率小;来获得极限。这是一个逆向公式,用户指定一个小的(通常 0.01 到 0.1)误差概率,程序计算相关的low_qhigh_q

该程序计算前面讨论的数量,然后生成大量具有已知分位数的随机“OOS 回归”集,并确认计算的数量是正确的。在研究代码之前,让我们先看一些例子,看看程序是做什么的。

假设用户指定nsamples =200,fail_rate =0.1。该程序计算 m =( n +1) p 来得到一个无偏的分位数估计。在这种情况下,我们看到第 20 个最小的 OOS 回报将被用作我们未来回报的下限,而第 20 个最大的 OOS 回报将是上限。没有理由对两个界限使用相同的失效率,有些读者可能想增加不同失效率的选项。这样做是为了方便。

我们对这对参数的期望是有(希望有!)未来回报有 10%的几率小于或等于我们计算的下限。同样,我们预计 10%的未来回报将等于或超过我们计算的上限。

唉,生活没那么简单。我们的边界所基于的 OOS 集本身就是一个随机样本,容易出错。如果我们能够挥动魔棒,保证我们的 OOS 样本能够完美地代表回归人口,我们的目标就能完美实现。换句话说,如果样本是完美的,我们计算的下限将是收益分布的精确的 0.1 分位数;较小的回报以 0.1 的概率出现。我们计算的上限将是收益分布的 0.9 分位数。但是样本并不是完美的,所以我们需要量化随机抽样误差的影响。

一个可能的错误是我们计算的下限太低。这一误差的结果是未知的真实“正常操作”故障率将低于我们想要的 0.1,这意味着我们可能无法在其早期阶段检测到恶化,此时低于标准的回报不会一直下降到我们过低的下限。为了量化这一点,我们可以指定一些与我们相关的假设分位数 q < p ,然后找到我们计算的下限实际上位于 q 分位数或更低(更低)的概率。

例如,假设我们指定low_q = q =0.07,这比我们期望的 0.1 的失效率要小得多,但可能不会小到我们错过早期恶化的机会会受到严重影响。该程序发现我们计算的下限小于或等于收益分布的 q =0.07 分位数的概率。如果我们计算出的下限恰好是 0.07 分位数,这意味着我们的界限只会被违反 7%,而不是我们想要的 10%。当未来回报有 10%的时间违反我们的下限时,表现会适度恶化,因为在正常操作下,我们预计只有 7%的时间违反。因此,我们会错过早期预警,尽管可能不会太多。该程序发现我们计算的下限小于或等于收益分布的分位数 q =.07 的概率,结果是 0.0692。同样,我们可以断言 1–0.0692 = 0.9308(大约 93%的置信度),我们计算的下限大于 0.07 分位数的回报。这是个不错的机会。

另一个可能的错误是我们计算的下限太大了。这一错误的结果是未知的真实“正常操作”故障率将大于我们想要的 0.1,这意味着我们将有超过 10%的时间获得等于或低于下限的回报。这可能会让我们得出结论,我们的交易系统正在恶化,而事实上它还好好的。我们可以指定一些与我们相关的假设分位数 q > p ,然后找到我们计算的下限实际上大于 q 分位数的概率。

例如,假设我们指定high_q = q =0.12,这比我们期望的 0.1 的失效率要高一些,但可能不会大到错误地得出退化结论的可能性会非常大。如果我们计算的下限恰好是 0.12 分位数,这意味着我们的界限将被违反 12%的时间,而不是我们想要的 10%的时间,不是非常严重。程序发现我们计算的下限大于收益分布的 q =.12 分位数的概率,结果是 0.1638。同样,我们可以断言 1–0.1638 = 0.8362(大约 84%的置信度),我们计算的下限小于或等于 0.12 分位数的回报。这不是很好,但也相当不错。

我们也可以从相反的方向处理这些概率陈述,指定具有坏的真分位数的概率,然后计算对应于该概率的乐观和悲观的 q 值。例如,我们可以指定p_of_q =0.05。然后程序会计算出乐观的 q 为 0.0673,悲观的 q 为 0.1363。回想一下,我们指定 p =0.1,这意味着我们想要 10%的故障率。这些数字表明,有 5%的可能性,真实故障率为 6.73%或更低,还有 5%的可能性,真实故障率大于 13.63%。

同样的想法也适用于上限,只是方向相反。在这种情况下,失败是未来的回报等于或超过上限。乐观的情况是上界太大,悲观的情况是上界太小,至于下界正好相反。所有的计算都是以同样的方式进行的,这一点在代码中可以看到。

在这些概率都由用户提供的参数计算出来之后,就要检验它们的准确性。这是通过生成大量测试集来实现的,每个测试集都包含nsamples模拟的 OOS 收益,这些收益来自一个分位数从理论上预先已知的分布。对于每个测试集,使用 m =( n +1) p 找到下限和上限。然后,将这些计算出的下限和上限与乐观和悲观的 q 值进行比较,这些值既包括用户提供的low_qhigh_q,也包括基于用户提供的p_of_q的值。对计算出的界限超出乐观或悲观限制的次数进行计数。对于每一种可能的情况,计数除以尝试次数给出了观察到的发生概率。这个不断更新的观察概率与程序计算的理论正确值一起打印到屏幕上,用户可以确认操作是正确的。

最简单的程序:代码

我们现在研究完整程序 CONFTEST.CPP 的基本代码片段。

   nsamps = atoi ( argv[1] ) ;
   lower_fail_rate = atof ( argv[2] ) ;                 // Our desired lower bound's failure rate
   lower_bound_low_q = atof ( argv[3] ) ;         // Test 1 optimistic q
   lower_bound_high_q = atof ( argv[4] ) ;       // Test 1 pessimistic q
   p_of_q = atof ( argv[5] ) ;                             // Test 2: Want this chance of bad q

接下来的几行是前面章节中讨论的基本计算。我们使用m=(n+1)p得到一个无偏的分位数估计,然后减去 1,因为 C++ 索引的原点是零。这给出了排序后的 OOS 回报数组的下限的索引。如果一个粗心的用户指定了一个微小的失败率,确保我们没有负下标。STATS 中的子程序orderstat_tail()。CPP 计算样本中第 m 个最小项目超过分布指定分位数的概率。因此,lower_bound_high_theory是与悲观的 q 相关的概率,lower_bound_low_theory是与乐观的 q 相关的概率。前者是我们计算的下限令人不安地大于与lower_bound_high_q相关联的分位数的概率,该分位数大于lower_fail_rate,从而导致过度的失败率。后者是我们计算的下限令人不安地小于与lower_bound_low_q相关联的分位数的概率,该分位数低于lower_fail_rate,导致错误的低失败率。

   lower_bound_index = (int) (lower_fail_rate * (nsamps + 1) ) - 1 ;
   if (lower_bound_index < 0)
      lower_bound_index = 0 ;

   lower_bound_high_theory =
                  orderstat_tail ( nsamps , lower_bound_high_q , lower_bound_index +1 ) ;
   lower_bound_low_theory =
                  1.0 - orderstat_tail ( nsamps , lower_bound_low_q , lower_bound_index +1 ) ;

   p_of_q_high_q = quantile_conf ( nsamps , lower_bound_index+1 , p_of_q ) ;
   p_of_q_low_q = quantile_conf ( nsamps , lower_bound_index+1 , 1.0 - p_of_q ) ;

当我们计算lower_bound_low_theory时,我们必须从 1.0 中减去概率,因为orderstat_tail()计算的是下界超过指定分位数的概率,而我们想要的是下界小于或等于指定分位数的概率。

在前面的代码中,p_of_q_high_q与我们在计算lower_bound_high_theory时所做的相反。我们指定概率(p_of_q)并计算相关的悲观 q ,而不是指定悲观 q 然后计算其相关的概率。这是通过 STATS.CPP 中的子例程quantile_conf()完成的。我们类似地计算p_of_q_low_q,记住,因为我们看到的是低于下限的概率*,而不是之前显示的,我们必须从 1.0 中减去期望的概率。*

一旦我们有了这些量,我们就可以计算上界的类似值。下界是第 m 个最小的回报,上界是第 m 个最大的回报。为了方便起见,本程序将失败率的上限设定为等于失败率的下限,并相应地反映悲观和乐观的 q 。没有必要具有这种对称性,如果需要,读者应该可以自由地使上限参数不同于下限参数。但是请注意,关系对于上限来说是相反的:悲观的 q 比用户的失败率小*,而对于下限来说,它比的失败率大。同样的关系也适用于乐观的 q。*

   upper_bound_index = nsamps-1-lower_bound_index ;
   upper_fail_rate = lower_fail_rate ;  // Could be different, but c hoose symmetric here
   upper_bound_low_q = 1.0 - lower_bound_high_q ;   // Note reverse symmetry
   upper_bound_high_q = 1.0 - lower_bound_low_q ;   // Which is for convenience
   upper_bound_low_theory = lower_bound_high_theory ;  // but not required
   upper_bound_high_theory = lower_bound_low_theory ;

我们现在准备运行程序的测试部分,以验证刚刚完成的计算是正确的。我们首先将各种故障计数器归零。

   lower_bound_fail_above_count = lower_bound_fail_below_count = 0 ;
   lower_bound_low_q_count = lower_bound_high_q_count = 0 ;
   lower_p_of_q_low_count = lower_p_of_q_high_count = 0 ;
   upper_bound_fail_above_count = upper_bound_fail_below_count = 0 ;
   upper_bound_low_q_count = upper_bound_high_q_count = 0 ;
   upper_p_of_q_low_count = upper_p_of_q_high_count = 0 ;

无限循环生成样本 OOS 返回。最容易使用的分布就是均匀分布,因为这种分布有一个特殊的性质,即它的分位数函数是恒等式:任何概率的分位数就是那个概率。这避免了花费大量计算机时间寻找分位数的需要。比例因子f避免了每次我们向用户报告正在进行的结果时的除法运算。我们对数据进行排序,这样我们可以很容易地找到样本的第 m 个最小和最大值。

   for (itry=1 ; ; itry++) {
      f = 1.0 / itry ;

      for (i=0 ; i<nsamps ; i++)
         x[i] = unifrand () ;

      qsortd ( 0 , nsamps-1 , x ) ;

我们从下限测试开始,一次解释一个。在每个测试中,记住不等式右边的量不仅是一个概率,而且因为分布是均匀的,它也是与该概率相关的分位数。因此,尽管乍一看,我们似乎是在将下限与概率进行比较,这毫无意义,但我们实际上是在将下限与分位数进行比较。

前两个测试并不十分有趣。

      lower_bound = x[lower_bound_index] ;  // Our lower bound

      if (lower_bound > lower_fail_rate)
         ++lower_bound_fail_above_count ;

      if (lower_bound < lower_fail_rate)
         ++lower_bound_fail_below_count ;

刚刚显示的两个测试将计算的下限与用户期望失败率的理论上正确的分位数进行比较,这是正确的下限。因为我们计算的下限是正确的(实践中未知,但在本测试中已知)下限的无偏估计,我们预计计算的下限将接近理论上正确的下限,过冲和欠冲大致相等。因此,我们期望这两个不等式中的每一个在几乎一半的时间里都是正确的。这些不是特别有用的测试,但它们确实是一个简单的健全检查。

接下来的两个测试让我们验证与乐观和悲观的 q ( lower_bound_low_theorylower_bound_high_theory)相关的概率是正确的。

      if (lower_bound <= lower_bound_low_q)  // Is our lower bound disturbingly low?
         ++lower_bound_low_q_count ;

      if (lower_bound >= lower_bound_high_q) // Is our lower bound disturbingly high?
         ++lower_bound_high_q_count ;

这些测试是使用用户提供的lower_bound_low_qlower_bound_high_q完成的。请再次记住,这些数量是概率,但因为我们模拟的 OOS 回报遵循均匀分布,它们也是与这些概率相关的分位数。如果一切正确,这两个测试应该分别以概率lower_bound_low_theorylower_bound_high_theory为真。

现在,我们执行完全相同的测试,除了不是将下限与用户提供的乐观和悲观 q 分位数进行比较,而是将下限与用户指定概率p_of_q的计算值进行比较。我们期望这两个测试都以概率p_of_q为真。

      if (lower_bound <= p_of_q_low_q)  // Ditto, but lim its gotten via p of q
         ++lower_p_of_q_low_count ;

      if (lower_bound >= p_of_q_high_q) // Rather than us er-specified
         ++lower_p_of_q_high_count ;

下一个测试块重复前面的测试,但是这次是关于计算的上限。与下限测试一样,在每种情况下,不等式右侧的量既是概率又是相关联的分位数,因为我们的测试分布是均匀的。概率方向在上限处反转,因为下限在阈值之外意味着它小于阈值,而上限在阈值之外意味着它高于阈值。因此,我们必须从 1.0 中减去所有概率,以获得相反方向的概率。这是之前为upper_bound_low_qupper_bound_high_q完成的。其他阈值没有这样做,因此必须在这里完成。

      upper_bound = x[upper_bound_index] ;     // For upper bound test

      if (upper_bound > 1.0-upper_fail_rate)       // This should fail with about 0.5 prob
         ++upper_bound_fail_above_count ;        // Because upper_bound is unbiased

      if (upper_bound < 1.0-upper_fail_rate)       // Ditto for this
         ++upper_bound_fail_below_count ;

      if (upper_bound <= upper_bound_low_q)  // Is our upper bound disturbingly low?
         ++upper_bound_low_q_count ;

      if (upper_bound >= upper_bound_high_q) // Is our upper bound disturbingly high?
         ++upper_bound_high_q_count ;

      if (upper_bound <= 1.0-p_of_q_high_q)
         ++upper_p_of_q_low_count ;

      if (upper_bound >= 1.0-p_of_q_low_q)
         ++upper_p_of_q_high_count ;

到目前为止,我们定期打印结果。那些打印语句很长,这里省略了;请参见文件 CONFTEST。如果你想的话。下一页显示了该程序的输出示例。

使用第 249 页给出的例子中的参数,我们首先看到这些参数的回声和计算出的基本量。如图 6-5 所示。用户指定 200 个样本,失败率 0.1,乐观q0.07,悲观q0.12。前者的相关概率经计算为 0.0692,后者的相关概率为 0.1638。用户还指定“q 的概率”为 0.05,这给出了乐观的 q 为 0.0673,悲观的 q 为 0.1363。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-5

竞赛参数和基本计算

在运行了几百万次试验后,我们得到了如图 6-6 所示的结果。我们预计“高于失败”和“低于失败”的比率约为 0.5,这些结果与此非常接近。为什么会有轻微的偏差?对于非常大的样本,这种偏差会很快消失,但是对于仅仅 200 个案例,即使我们使用“无偏”公式,在计算 m 时的截断行为也会引入轻微的偏差。有一些插值方法可以通过观察下一个更极端的情况并按照截断向那个方向移动来很大程度上校正这种偏差。但是这些方法在这个应用中不值得麻烦,特别是因为轻微的偏差是在保守的方向上。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-6

竞赛结果

注意获得的概率与计算的理论概率有多接近。例如,我们看到获得了 0.0691,而预期是 0.0692。对于那些p_of_q =0.05 的测试,我们得到 0.499 到 0.501 的比率。

提供和研究这个测试程序主要是为了强化限制未来收益的概念。然而,读者可以用它来探究悲观和乐观的 q 值对不同样本量和失败率的影响。

BND RET 计划

文件 BND_RET。CPP 包含一个程序的源代码,该程序演示了前面章节中描述的返回绑定方法的实际应用。它读取与 BOUND_MEAN 程序(第 232 页)格式相同的市场文件,并执行 TRN_BIAS 程序(第 123 页)中使用的原始移动平均交叉系统。如果需要,请查看这些参考资料。在这里,我们严格地关注未来回报的界限的计算。

我们从代码片段和解释开始。数学与已经展示的 CONFTEST 程序完全相同,但是为了从不同的方向处理问题,我选择对一些变量进行不同的标记。标签包含 high_qlow_q 的变量在下限和上限有相反的关系。为了那些可能对此感到困惑的读者,我使用短语 opt_qpes_q 分别为乐观值和悲观值重命名了变量。所有的计算和数学都是完全一样的;只是名字变了。希望通过从两个角度来看这些算法,读者能更好地理解这个过程。

通常,用户可以指定如下所示的关键测试参数。但是为了在本节末尾进行演示,下面是将在演示中使用的值,这些值暂时硬编码到程序中:

   max_lookback = 100 ;        // Max lookback for long-term moving average
   n_train = 1000 ;                  // Number of training cases for optimizing trading system
   n_test = 63 ;                       // Group bar returns to produce quarterly returns
   lower_fail_rate = 0.1 ;        // Desired failure rate for lower bound (a typical value)
   upper_fail_rate = 0.4 ;        // Desired failure rate for upper bound (a ty pical value)
   p_of_q = 0.05 ;                   // Desired probability of bad bound limits

前三个参数在 TrnBias 程序报告中有描述。最后三个参数与限制未来回报有关。数字 63 的出现是因为一个季度通常有 63 个交易日,这意味着这项研究将涉及限制季度回报。

walkforward 代码很简单。在这里,下面是一个简短的描述:

   train_start = 0 ;  // Starting index of training set
   n_returns = 0 ;  // Will count returns (after grouping)
   total = 0.0 ;        // Sums returns for user's edification

   for (;;) {

      IS = opt_params ( n_train , max_lookback , prices + train_start ,
                                    &short_lookback , &long_lookback ) ;
      IS *= 25200 ;  // Approximately annualize

      n = n_test ;     // Test this many cases
      if (n > nprices - train_start - n_train) // Don't go past the end of history
         n = nprices - train_start - n_train ;

      OOS = test_system ( n , prices + train_start + n_train - long_lookback ,
                                         short_lookback , long_lookback ) ;
      OOS *= 25200 ;  // Approximately annualize

      returns[n_returns++] = OOS ;
      total += OOS ;

      // Advance fold window; quit if done
      train_start += n ;
      if (train_start + n_train >= nprices)
         break ;
      }

   printf ( "\n\nAll returns are approximately annualized by multiplying by 25200" ) ;
   printf ( "\nmean OOS = %.3lf with %d returns", total / n_returns, n_returns ) ;

在任何时候,train_start都是当前折叠的训练集中第一个事例的索引。回报是以 63 根棒线为一组进行计算的,而n_returns会计算在交易过程中创建了多少这样的分组回报。total的回报也是累积的,纯粹是为了向用户报告。

walkforward 循环的第一步是调用opt_params()来寻找最优的短期和长期移动平均线回看。它的样本内表现(每根棒线的平均回报率)乘以 25200,大致得出日棒线的年回报率。

通常,OOS 测试周期将由用户指定,在本演示中为 63。然而,最后一个文件夹可能不会恰好有这么多的测试用例,所以我们将它缩小到剩下的所有用例。

test_system()的地址看起来很神秘,需要花点心思才能理解。第一个 OOS 测试用例在train_start + n_train返回,这是紧随训练集之后的价格。移动到这个第一 OOS 价格的交易决定必须基于这个 OOS 价格之前的最近价格。我们将查看long_lookback的历史价格来做出决定,所以我们必须从 OOS 头寸中减去这个数量,以获得决定所基于的第一种情况的指针。如果这不清楚,画一个小小的价格时间线,标出训练集和测试集的位置,以及长期移动平均回看。这样就清楚了。test_system()子程序返回n测试用例中每根棒线的平均回报。该数量按年计算,保存在returns数组中,并累计。

我们从 OOS 回报的排序数组中计算下界和上界。它们分别是第 m 最小的和第 m 最大的。

   qsortd ( 0 , n_returns-1 , returns ) ;

   lower_bound_m = (int) (lower_fail_rate * (n_returns + 1) ) ;
   if (lower_bound_m < 1)
      lower_bound_m = 1 ;

   lower_bound = returns[lower_bound_m-1] ;

   upper_bound_m = (int) (upper_fail_rate * (n_returns + 1) ) ;
   if (upper_bound_m < 1)
      upper_bound_m = 1 ;

   upper_bound = returns[n_returns-upper_bound_m] ;

我们可以让用户提供乐观和悲观的q值,但是这个程序任意决定将它们放置在用户指定的故障率上下 10%的地方。您可以随意更改这些偏移量。

   lower_bound_opt_q = 0.9 * lower_fail_rate ;  // Arbitrary choice; could be user input
   lower_bound_pes_q = 1.1 * lower_fail_rate ;

   upper_bound_opt_q = 0.9 * upper_fail_rate ;
   upper_bound_pes_q = 1.1 * upper_fail_rate ;

现在,我们使用这些预先计算的 q 值来计算让我们评估我们计算的边界的准确性的量。

   lower_bound_opt_prob = 1.0 - orderstat_tail ( n_returns , lower_bound_opt_q ,
                                                                              lower_bound_m ) ;
   lower_bound_pes_prob = orderstat_tail ( n_returns , lower_bound_pes_q ,
                                                                      lower_bound_m ) ;

   upper_bound_opt_prob = 1.0 - orders tat_tail ( n_returns , upper_bound_opt_q ,
                                                                               upper_bound_m ) ;
   upper_bound_pes_prob = orderstat_tail ( n_returns , upper_bound_pes_q ,
                                                                      upper_bound_m ) ;

最后,我们使用“逆”过程:我们使用用户指定的概率p_of_q来找到乐观和悲观的 q 值。

   lower_bound_p_of_q_opt_q = quantile_c onf ( n_returns , lower_bound_m ,
                                                                              1.0 - p_of_q ) ;

   lower_bound_p_of_q_pes_q = quantile_conf ( n_returns , lower_bound_m , p_of_q ) ;

   upper_bound_p_of_q_opt_q = quantile_c onf ( n_returns , upper_bound_m ,
                                                                               1.0 - p_of_q ) ;

   upper_bound_p_of_q_pes_q = quantile_conf ( n_returns , upper_bound_m , p_of_q ) ;

图 6-7 显示了使用第 257 页显示的参数,应用于 OEX 指数十年的程序输出样本。我尽量说得详细一些,以便尽可能清楚地表达所有数字的含义。同时,我避免了“小于或等于”和“小于”等不必要的区别。为了准确起见,我小心翼翼地在数学演示中做了具体说明。但在实践中,我们可以将收益视为本质上连续的,因此这种区分毫无意义,只会增加复杂性。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-7

OEX 移动平均交叉的 BND_RET 输出

请注意,年化回报率为 1.021%;这是一个非常糟糕的交易系统!我们预计未来 10%的季度回报比年化 38.942%的损失更严重,所以如果我们有几个这样糟糕的季度,我们应该高度怀疑。我们预计未来 40%的季度回报率至少为 9.043%的年化回报率,因此如果我们不能定期达到这一水平,我们应该感到怀疑。输出中的其余值是不言自明的,表明符合我们指定的 10%和 40%界限,但不是很好。这是因为我们只有 124 人返回,少得危险。

边界水位下降

让我们回顾一下到目前为止我们所看到的业绩界限的类型,所有这些类型都是基于对样本外回报的分析:

  • 如果我们的 OOS 回报不包含任何极值,并具有合理的钟形曲线分布形状,我们可以使用学生的 t 分布来限制未来回报的均值。

  • 如果我们不想做学生的-t 分布所需的假设,我们可以使用 bootstrap,特别是 BC a 方法,来约束未来收益的均值。这可能是我们工具箱中最重要的一种绑定技术。

  • 我们可以使用 bootstrap 来限制未来收益分布的收益因子的对数。

  • 在相当谨慎的情况下,我们可以使用 bootstrap 来限制未来收益分布的夏普比率。

  • 由于对回报分布的性质没有限制性假设,我们可以通过对历史回报进行排序并查看第 m 个最小或最大值来近似界定单个未来回报。如果我们绑定的回报是分组回报,如月度或季度结果,这尤其有用,因为我们可以使用这些界限来跟踪持续的表现并检测恶化。然而,与此列表中的先前界限不同,这些不是可靠的单个数字。它们会受到随机变化的影响,我们必须量化这些随机变化,以揭示我们可以信任它们的程度。

当然,市场交易者非常感兴趣的一个性能指标是他们未来可能遇到的下降。至少在理论上,我们可以使用一个 bootstrap 来限制特定时间段内的均值下降,随机观察到的未来下降将以该值为中心。这很容易做到:只需从 OOS 收益集中抽取大量的 bootstrap 样本,并使用随机抽样程序计算每个样本的平均下降值。百分位数法(或其更高级的版本,BCT3T5】法)为未来预期的平均下降提供了置信界限。例如,假设我们发现在 10%的 bootstrap 样本中,平均下降为 34%或更多。那么我们可以断言,未来平均支出低于 34%的可能性为 90%。

但是这个数字真的没有什么价值。除非合理概率下的计算值非常大或非常小,否则我们不太关心平均下降是多少。我们真正想要的是对我们将经历的实际下降有一个基于概率的界限。例如,如果我们能够计算出明年我们有 35%的机会经历超过 70%的缩减,我们会发现这一信息非常有用!

坏消息是,我们做不到这一点,至少不能达到我们希望的确定程度。我们遇到了前一节中困扰我们的相同情况,我们计算了基于概率的个人未来收益的界限。我们发现界限本身会受到随机误差的影响,所以我们必须用额外的概率陈述来限定我们的断言。这就是我们对未来提款的限制。这既不快速也不容易。或者说特别准确。但这将是一个非常有用的数字,我们将继续研究这个问题,在我们计算的时候一定要交叉手指。

直觉出错了

在进入正确界定未来提款这一相对复杂的主题之前,我们需要明确界定均值提款和界定实际提款之间的区别。前者是我们未来可以预期的平均下降。后者是我们实际经历的个人下降。后者将倾向于围绕前者,但个人提款很容易比平均水平差得多(当然,也不会差得太多)。出于显而易见的原因,我们主要关心的是我们的下一次削减会有多糟糕,而不是平均削减会有多糟糕。

这个问题提供了一个机会来展示直觉是如何轻易地将我们引入歧途的例子。考虑以下有缺陷的推理:

  1. 我们的回报是样本外的,因此是无偏的。

  2. 因此,我们的回报是我们未来预期回报的一个公平的代表。

  3. 提款取决于订单;一长串连续的亏损将会产生巨大的损失,而交替的盈利和亏损只会产生微小的损失。

  4. 未来的回报将类似于我们目前的 OOS 样本。只会出现两种不同。首先,在输赢的表象中会有一些随机性,有可能我们会比我们的 OOS 样本幸运地多赢几场,或者被诅咒多输几场。第二,输赢出现的顺序会不一样。这是影响未来削减的两个因素。

  5. 我们可以用计算机模拟这两种随机效应。我们从我们的回报中随机抽取替换样本,并评估其下降情况。然后反复做,几千次。我们获得的提款分布代表了未来可能的提款分布。例如,假设我们发现 5%的引导试验下降了 60%或更多。然后我们断言,未来我们有 5%的机会遭受 60%或更多的下降。

这个可靠的推理的致命缺陷在于第二步。请回顾“插曲:无偏到底是什么意思?”第 125 页。问题是统计意义上的无偏并不意味着大多数人理解的实际意义上的无偏。事实上,我们的 OOS 样本几乎可以肯定有偏见的。这过于悲观了。或者乐观。我们不知道,但无论是哪种情况,它都是对未来回报的公平表示。我们称之为不偏不倚,只是因为过度的乐观和悲观是平衡的,两者都不偏不倚。

步骤 5 中的计算机模拟没有考虑到这样一个事实,即我们的 OOS 回报本身是一个随机样本,因此乐观或悲观,也许非常乐观。这是该算法没有考虑的一个巨大的变化源。因此,极端提款的可能性比计算机模拟所暗示的要大得多。当我们在第 267 页讨论提款计划时,我们会看到,对于灾难性提款,这种算法会低估其概率超过 10 倍。即使是适度的提款,其概率也可能低 2 倍。这是最糟糕的错误,因为它是反保守的。高估大规模削减的可能性会令人不安,但低估这种可能性可能是灾难性的。

自举下降界限

首先是坏消息:计算未来提款的概率界限非常慢,通常需要一亿次缓慢计算的迭代。对于一个已建立的交易系统来说,一个这样的计算通常可以在几秒钟到最多一分钟内完成,这是一个可管理的时间。但是,如果你想在训练算法中使用下降界限来优化交易系统的参数,你可能很容易就要花费几个小时甚至几天的计算机时间。这可能是一个交易杀手。我们的一个例外是,第 264 页步骤 5 中显示的速度非常快的算法可以用于训练算法,前提是满足两个基本且通常合理的条件。这将结合第 267 页提出的削减计划进行更详细的讨论。

现在又有一个坏消息:这些计算的结果可能不那么准确。像原始利润因子和夏普比率一样,基于提款的统计数据并不是非常适合自助。尽管如此,我们通常可以得到比什么都没有好得多的结果。即将展示的算法应该在每个市场交易者的工具箱中占有一席之地。

让我们简要回顾一下决定用一组观察到的 OOS 回报进行的计算与未来提款之间关系的三个因素。要知道,我们有一个未知的回报分布,我们的历史 OOS 数据和未来交易就是从这个分布中提取的。这是我们关心的三个因素:

  1. 我们的计算所基于的 OOS 回报集是来自可能回报总体的随机样本。

  2. 未来一段时间的缩减取决于从该人群中获得的收益和损失的大小和相对数量。

  3. 这种未来的减少取决于输赢出现的顺序。

第 264 页第 5 步显示的算法考虑了因素 2 和 3,但忽略了因素 1。我们必须注意这一点。

从概念上讲,解决方案很简单:我们只需将 page 264 step 5 算法嵌入到一个外部引导程序中,该引导程序处理因子 1。外部算法将使用百分位数自举(或者可能是 BC a 方法,这可能不值得额外的努力)来计算下降界限的置信界限。这是一个完整的双自助算法,在用户指定的较大(可能为 0.9-0.99)压降置信区间DD_conf和用户指定的较大(可能为 0.6-0.9)压降置信区间Bound_conf计算压降边界。

For ‘outer’ replications
         Draw an ‘outer’ bootstrap sample from the OOS returns
         For ‘inner’ replications
              Draw an ‘inner’ bootstrap sample from the outer bootstrap sample
              DD_inner [ inner ] = drawdown of this inner sample
         Sort DD_inner ascending
         m = DD_conf * inner
         DD_outer [ outer ] = DD_inner [ m ]
Sort DD_outer ascending
m = Bound_conf * outer
Bound = DD_outer [ m ]

我们应该清楚用户指定的两个置信水平DD_confBound_conf的含义。前者是我们未来提款不超过计算值的概率。例如,我们可能想要计算我们可以DD_conf确信永远不会被超过的下降。例如,我们可以指定DD_conf =0.9,然后从算法中得到 65%的下降。那么我们可以 90%确定个人未来提款不会超过 65%。

可惜没那么简单。计算出的界限,比如刚刚引用的 65%,本身就是一个随机量,因为我们的 OOS 样本本身就是一个随机样本。因此,我们需要计算基于概率的提款界限。在本例中,我们可能指定Bound_conf =0.7,在这种情况下,算法将计算一个更大的界限,该界限有 70%的机会等于或超过实际的DD_conf =0.9 界限。在这个例子中,我们可能会发现最终的界限是 69 %,而不是更保守的 65%。换句话说,对于这个例子,有 70%的可能性,我们有 90%的信心的实际(但未知)提款界限不超过 69%。

这可能需要一段时间来理解。这是一个界限上的界限。有一些真实但未知的提款界限,有可能DD_conf是未来提款的上限。更严格地说,也许更清楚地说,有一个用户指定的概率DD_conf,未来的提款不会超过这个未知的上限。如果我们可以绝对肯定我们的 OOS 样本精确地复制了可能的未来回报的分布,我们可以使用第 264 页的第 5 步算法来计算这个界限,并且有理由感到高兴。

不幸的是,我们的 OOS 集不能复制可能的回报分布。更糟糕的是,随机抽样误差对界限计算有不对称的影响;乐观和悲观的 OOS 样本对提款界限的影响是不均衡的,所以我们不能说最终一切都会平衡。乐观的 OOS 样本比悲观的样本对我们更不利。

出于这个原因,我们必须计算一个比第 264 页第 5 步算法计算的压降上限的压降上限。我们指定一个概率,这个更大的界限至少与对应于指定的DD_conf的真实但未知的上限一样大。我们通常不必过分自信,除非我们看到的是灾难性的价值。但是假设我们确实想要进入毁灭的区域,也许设置DD_conf =0.999。相关的下降是一个重要的数字,因为如果我们看到在这个非常高的信心水平下下降了 12%,我们会欣喜若狂,而如果我们看到这是 98%,我们就应该颤抖。毕竟 99.9%是大概率,近乎确定,但绝对不确定。失败仍然可能发生。既然这是一个如此重要的数字,我们应该对它的计算值格外自信。因此,我们倾向于将Bound_conf =0.9,或者当DD_conf很大时甚至更大。相反,如果我们只是在寻找常规的下降,也许设置DD_conf =0.9,那么大多数人会舒服地设置Bound_conf =0.7 左右。这给了我们 70%的机会,我们的计算界限等于或超过未知的真实界限,这将被超过只有 10%的时间。

削减计划

文件缩编。CPP 包含一个程序的源代码,该程序允许用户试验各种假设交易系统的提款界限的计算。它演示了如何实现第 266 页所示的下降边界算法,让它同时计算几个边界。它还显示了第 264 页第 5 步算法低估了许多常见条件下灾难性提款的概率,并展示了该算法(比“正确”算法快几个数量级)相当准确的条件。

程序调用如下:

DRAWDOWN Nchanges Ntrades WinProb BoundConf BootstrapReps QuantileReps TestReps

让我们来分解这个命令:

  • Nchanges:价格变动次数

  • Ntrades:交易笔数,小于等于Nchanges

  • WinProb:中奖概率,一般在 0.5 左右

  • BoundConf:正确 DD 界限的置信度(通常为. 5 –. 999)

  • BootstrapReps:引导程序重复次数

  • QuantileReps:寻找下降分位数的 bootstrap 重复次数

  • TestReps:本研究的试验代表人数

提款程序生成Nchanges价格变化,它代表了(对数)OOS 回报,这是边界计算的基础。这可能包含一个比您想要考虑提款的时间段更长的时间段。例如,你可能有 10 年的 OOS 数据,但想考虑一年或甚至一个季度的提款。因此,您可以指定一个相等或较小的数量Ntrades,它跨越了所需的时间段。

价格变化遵循正态分布,并且它们将以概率WinProb为正,如果我们想停留在现实系统的领域中,我们通常会将它设置为 0.5 或稍高于 0.5 的某个值。

用户不能设置DD_conf,但是四个有用的值被硬编码到程序中并同时计算。灾难性提款为 0.999,严重提款为 0.99,相当糟糕的提款为 0.95,偶尔可以预期的提款为 0.9。多个DD_conf值可以在与单个值基本相同的时间内计算出来,因此在一次运行中一起计算效率最高。

用户指定Bound_conf,该值用于DD_conf的两个最大值(0.9 和 0.95)。然而,1.0 – (1.0 – Bound_conf) / 2.0用于两个最小的值。这种高于用户指定值的增加是考虑到这样一个事实,即对于较小的DD_conf值,我们通常希望增加计算边界的置信度。这是最严重的下降发生的地方,所以我们最好对自己有信心。

BootstrapReps是第 264 页步骤 5 算法中使用的复制次数,也是第 266 页“正确”算法中使用的外部复制次数。

QuantileReps是第 266 页“正确”算法中使用的内部重复次数。

这两种算法用于计算未来提款的上限。如果有读者感兴趣的话,我们还计算了平均回报率的下限,但却被错误地当成了未来回报率的下限。这为限制未来均值和未来值之间的差异提供了额外的证明。我不会进一步讨论这个测试,但是一些读者可能对研究源代码的这个方面感兴趣。

在我们计算了八个提款界限(四个DD_conf值用于不正确和正确的方法)后,大量的交易回报从与用于生成界限计算所依赖的 OOS 数据相同的分布中生成。该程序计算八个界限中的每一个被违反的频率。如果界限计算是正确的,违反率应该等于 1 减去DD_conf的相应值。如果违反率超过了相应的DD_conf,我们就有算法低估了下降超过界限的概率的极其严重的错误。如果违反率小于DD_conf,我们就有了算法高估界限的不太严重的错误。这仍然是一个问题,因为我们太保守了,也许不公平地拒绝了一个交易系统。但是,不公平地拒绝一个交易系统,比让一个系统交易真钱,然后发现太晚,它的严重亏损的真实概率比我们想象的要糟糕得多。

生成假设的 OOS 回报、计算提款界限以及观察这些界限的实际表现的整个过程被重复TestReps次,结果被平均。这些平均性能,以及获得的故障概率与正确故障概率的比率,被打印到屏幕上和一个名为 dropowder . log 的文件中。

在检查说明这个算法的关键代码片段之前,让我们花一段时间给有引导经验并且可能质疑 266 页算法背后的基本原理的更倾向于数学的读者。一个中心思想是,尽管内部循环被称为自举,但它实际上不是。它只是看起来像一个,如果不太严格的话,称它为自举并不是什么大罪。然而,这里实际上只有一个引导程序在工作,即外部循环。该 bootstrap 使用百分位数方法来估计特定统计数据的置信区间。该统计量是用户期望的分位数,对应于DD_conf。对于每个外环自举样本,该统计量是通过内环中的重复采样来估计的。当然,这使得该统计量的计算独立于外环样本生成的顺序;它只取决于经验分布。因此,底线是内循环只是简单地计算从外循环 bootstrap 样本的经验分布得出的样本统计的估计值。如果你不理解这一段,不要担心;你不需要这样做。

首先,我们检查为不正确和正确的下降边界算法生成引导样本数据的代码。除了make_changes,所有的调用参数都是自解释的。在复制循环中第一次调用它时,它将被设置为 True ,这将导致一组价格变化,表示我们的 OOS 退货日志被生成并保存。对于剩余的复制,make_changes为 false,这将保留最初生成的样本。无论如何,从保存的更改中收集一个随机样本。

void get_trades (
   int n_changes ,          // Number of price changes (available history)
   int n_trades ,              // Number of these changes defining drawdown period
   double win_prob ,       // Probability 0-1 of a winning trade
   int make_changes ,    // Draw a new random sample from which bootstraps are drawn?
   double *changes ,      // Work area for storing n_changes changes
   double *trades            // n_trades are returned here
   )
{
   int i, k, itrade ;

   if (make_changes) {   // Generate the sample?
      for (i=0 ; i<n_changes ; i++) {
         changes[i] = normal () ;
         if (unifrand() < win_prob)
            changes[i] = fabs ( changes[i] ) ;
         else
            changes[i] = -fabs ( changes[i] ) ;
         }
      }

   // Get the trades from a standard bootstrap
   for (itrade=0 ; itrade<n_trades ; itrade++) {
      k = (int) (unifrand() * n_changes) ;
      if (k >= n_changes)
         k = n_changes - 1 ;
      trades[itrade] = changes[k] ;
      }
}

为了弄清楚如何计算压降,下面是该例程的代码。一些方法将提款作为最大权益的百分比来报告。但是,这需要对对报告价值有重大影响的初始权益进行详细说明。一种更好的方法是将提取作为一个绝对数字来计算,这消除了初始权益的模糊性,也使提取的影响在整个时间间隔内保持一致。这对于可能出现负资产的交易场景非常理想,例如杠杆期货交易。此外,如果交易是权益变化的记录,这种方法给出的结果与提取百分比单调相关,转换很容易实现,如第 280 页所示。

double drawdown (
   int n ,            // Number of trades
   double *trades     // They are here
   )
{
   int icase ;
   double cumulative, max_price, loss, dd ;

   cumulative = max_price = trades[0] ;
   dd = 0.0 ;

   for (icase=1 ; icase<n ; icase++) {
      cumulative += trades[icase] ;
      if (cumulative > max_price)
         max_price = cumulative ;
      else {
         loss = max_price - cumulative ;
         if (loss > dd)
             dd = loss ;
         }
      } // For all cases

   return dd ;
}

这个例程将权益累计为收益的运行总和,并跟踪最大权益。在处理每个回报时,将当前权益与最大值进行比较,到目前为止最大的差异是提取。

正确的边界算法要求,对于每个引导样本,我们做大量的样本来估计期望的DD_conf分位数。但是这个过程中的采样和排序非常耗时,所以这个例程同时计算四个不同的分位数,基本上没有额外的开销。

void drawdown_quantiles (
   int n_changes ,                // Number of price changes (available history)
   int n_trades ,                    // Number of trades
   double *b_changes ,        // n_changes changes bootstrap sample supplied here
   int nboot ,                         // Number of bootstraps used to compute quantiles
   double *bootsample ,       // Work area n_trades long
   double *work ,                  // Work area nboot long
   double *q001 ,                  // Computed quantiles
   double *q01 ,
   double *q05 ,
   double *q10
   )
{
   int i, k, iboot ;

   for (iboot=0 ; iboot<nboot ; iboot++) {
      for (i=0 ; i<n_trades ; i++) {
         k = (int) (unifrand() * n_changes) ;
         if (k >= n_changes)
            k = n_changes - 1 ;
         bootsample[i] = b_changes[k] ;
         }
      work[iboot] = drawdown ( n_trades , bootsample ) ;
     }

   qsortd ( 0 , nboot-1 , work ) ;

   k = (int) (0.999 * (nboot+1) ) - 1 ;
   if (k < 0)
      k = 0 ;
   *q001 = work[k] ;

   k = (int) (0.99 * (nboot+1) ) - 1 ;
   if (k < 0)
      k = 0 ;
   *q01 = work[k] ;

   k = (int) (0.95 * (nboot+1) ) - 1 ;
   if (k < 0)
      k = 0 ;
   *q05 = work[k] ;

   k = (int) (0.90 * (nboot+1) ) - 1 ;
   if (k < 0)
      k = 0 ;
   *q10 = work[k] ;
}

这段代码进行了很多次引导采样(尽管如前所述,它并不是真正的引导)。这些样本取自外循环引导样本,使所有的n_changes样本都可用。实际上,因为这个例程计算的统计数据是易受随机误差影响的估计值,所以这里的nboot非常大是很重要的。我通常使用 10,000,更大的值不会不合理。

对于这些样本中的每一个,它计算并保存指定n_trades尺寸区间的压降。所有采样完成后,它对保存的提款进行排序,并使用无偏分位数公式来估计四个所需的分位数。

注意这里不需要if(k<0)检查,因为它们总是假的。但是这个检查是一个很好的习惯,因为通常情况下k会等于-1。

对于这两个测试,我们都有一个简单的例程来寻找分位数。它假设数据是升序排序的。

static double find_quantile ( int n , double *data , double frac )
{
   int k ;

   k = (int) (frac * (n+1) ) - 1 ;
   if (k < 0)
      k = 0 ;
   return data[k] ;
}

第 264 页的第 5 步算法,我在这里称之为“不正确”的方法(尽管在某些情况下它的结果是可以接受的)如下:

for (iboot=0 ; iboot<bootstrap_reps ; iboot++) {
   make_changes = (iboot == 0)  ?  1 : 0 ; // Generate sample on first pass only
   get_trades ( n_changes , n_trades , win_prob , make_changes , changes , trades ) ;
   incorrect_drawdowns[iboot] = drawdown ( n_trades , trades ) ;
   } // End of incorrect method bootstrap loop

qsortd ( 0 , bootstrap_reps-1 , incorrect_drawdowns ) ;

incorrect_dd_001 = find_quantile ( bootstrap_reps , incorrect_drawdowns , 0.999 ) ;
incorrect_dd_01 =  find_quantile ( bootstrap_reps , incorrect_drawdowns , 0.99 ) ;
incorrect_dd_05 =  find_quantile ( bootstrap_reps , incorrect_drawdowns , 0.95 ) ;
incorrect_dd_10 =  find_quantile ( bootstrap_reps , incorrect_drawdowns , 0.9 ) ;

外部循环抽取许多引导样本。第一次,get_trades()make_changes为真时被调用,以便在自举采样之前生成一组模拟的 OOS 返回。对于通过该循环的后续传递,从原始集合进行采样。对于每个样品,计算并保存压降。

所有复制完成后,提款按升序排序。为每个期望的分位数调用find_quantile()例程。

正确的套路稍微复杂一点。对于检验统计量(一个指定的分位数),我们必须区分自举样本大小(n_changes)和样本大小(n_trades)。实际的 bootstrap(外部循环)是从完整的 OOS 返回集合中取样,因为这是我们假定的总体。但是我们的测试统计是在指定的时间段内经历的提款分布的分位数,该时间段可能比整个 OOS 集包含的长度短。

for (iboot=0 ; iboot<bootstrap_reps ; iboot++) {
   make_changes = (iboot == 0)  ?  1 : 0 ; // Generate sample on first pass only
   get_trades ( n_changes , n_changes , win_prob , make_changes , changes , trades ) ;
   drawdown_quantiles (
               n_changes , n_trades , trades , quantile_reps , bootsample , work ,
               &correct_q001[iboot] , &correct_q01[iboot] ,
               &correct_q05[iboot],&correct_q10[iboot] ) ;
   } // End of incorrect method bootstrap loop

qsortd ( 0 , bootstrap_reps-1 , correct_q001 ) ;
qsortd ( 0 , bootstrap_reps-1 , correct_q01 ) ;
qsortd ( 0 , bootstrap_reps-1 , correct_q05 ) ;
qsortd ( 0 , bootstrap_reps-1 , correct_q10 ) ;
correct_q001_bound = find_quantile (
                                      bootstrap_reps , correct_q001 , 1.0 - (1.0 - bound_conf) / 2.0 ) ;
correct_q01_bound = find_quantile (
                                    bootstrap_reps , correct_q01 , 1.0 - (1.0 - bound_conf) / 2.0 ) ;
correct_q05_bound = find_quantile ( boots trap_reps , correct_q05 , bound_conf ) ;
correct_q10_bound = find_quantile ( boots trap_reps , correct_q10 , bound_conf ) ;

在我们收集了四个指定级别的自举分位数后,我们使用简单的百分位数算法来寻找分位数的置信界限。对于两个较大的分位数(0.1 和 0.05),我们选择用户指定的置信水平,通常适度大于 0.5。但是对于两个更极端的分位数(0.01 和 0.001),我们将置信水平推得更远,在任意但合理的假设下,当我们处理更极端(严重!)下降,我们最好更确定我们的计算边界。注意,我们可以在这里使用更高级的 BC a bootstrap,但是增加的复杂性可能不值得。请随意尝试。

该测试程序的最后一步是生成“未来”回报,并将它们的提取与之前计算的界限进行比较。一个好的界限计算方法将提供其实际失效率接近其期望失效率的界限。以下是该代码的一些片段:

for (ipop=0 ; ipop<POP_MULT ; ipop++) {

   for (i=0 ; i<n_trades ; i++) {
      trades[i] = normal () ;
      if (unifrand() < win_prob)
         trades[i] = fabs ( trades[i] ) ;
      else
         trades[i] = -fabs ( trades[i] ) ;
      }

   crit = drawdown ( n_trades , trades ) ;

   if (crit > incorrect_drawdown_001)
      ++count_incorrect_drawdown_001 ;

   if (crit > correct_q001_bound)
      ++count_correct_001 ;

   ...Test other bounds similarly...

   } // For ipop

我们生成大量的试验回报集,每个都包含n_trades交易回报。自然,这个交易集是以与用于计算边界的交易集相同的方式生成的。

对于每个交易集,我们计算亏损,并将其与计算的界限进行比较,计算违反界限的次数(实际亏损超过计算的界限)。在我们完成了大量计算-测试-绑定试验后,我们将失败计数除以试验次数,并打印每个绑定的失败率和正确率,因为我们知道如果绑定计算正确,这些失败率将相等。

缩编计划的试验

我用下降程序进行了一系列实验;读者可以自由地进行自己的实验。在所有这些测试中,获胜的概率被设置为 0.6,这是真实交易系统中盈亏平衡的典型概率。有 5,000 个引导复制;10,000 个样本用于计算分位数界限;这个过程重复了 2000 次。这些数字足以提供可靠的结果。

使用了三种不同的配置。首先,我使用了 OOS 和提款期的 63 份回报。这相当于使用一个季度的每日数据来限制下一季度的提款。然后,我将其扩展到 252 个回报,对应于使用一年的 OOS 回报来限制下一年的提款。最后,我使用了 2,520 个回报,提取期为 252 个回报。这相当于使用 10 年的 OOS 数据来限制下一年的提款。

Prob     OOS     DD     Incorrect     0.5     0.6     0.8

0.001     63     63       13.65       4.49    3.42    1.64
0.01      63     63        4.29       1.74    1.37    0.71
0.05      63     63        2.16       2.15    1.65    0.85
0.10      63     63        1.66       1.66    1.31    0.72

0.001    252    252        5.84       1.81    1.35    0.59
0.01     252    252        2.55       1.02    0.80    0.41
0.05     252    252        1.62       1.62    1.26    0.64
0.10     252    252        1.36       1.37    1.10    0.61

0.001   2520    252        1.54       0.79    0.68    0.45
0.01    2520    252        1.16       0.76    0.68    0.51
0.05    2520    252        1.06       1.06    0.95    0.72
0.10    2520    252        1.04       1.03    0.94    0.75

在上表中,每个条目都是违反提款限制的实际比率超过假定比率的因子。理想情况下,它们应该是相等的;大于 1.0 的值比小于 1.0 的值更糟糕,因为大于 1.0 的比率意味着下降比它应该的更频繁地违反假定的界限(并且你认为它会!).

第一列是我们想要计算其相应界限的界限失效率。第二列是可用于计算边界的 OOS 回报的数量。第三列是定义即将到来的提款期的返回次数。第四列是“不正确的”第 264 页步骤 5 算法的过度故障率。剩下的三列是使用置信度为 0.5、0.6 和 0.8 的“正确”算法的超额失败率。(但是请注意这些置信度是如何扩展到两个最小概率的,如 269 页所解释的。)应注意以下结果:

  • 错误方法的质量极大地依赖于 OOS 样本的大小。这是有道理的,因为较大的 OOS 样本更能准确地代表潜在的总体回报。小 OOS 样本更容易受到随机变化的影响,这使得不正确的方法变得不正确。

  • 不正确方法的质量很大程度上取决于指定的故障率。对于适度的失败率,如 0.10(即将到来的下降超过计算界限的可能性为 10%),不正确的方法表现相当好,尽管即使在每个测试中,它仍然低估了真实的失败率,这是一个危险的属性。

  • 当 OOS 样本很小(63) 时,我们看到的是罕见的灾难性事件(p=0.001),不正确的方法低估了灾难性水位下降 13.65 倍的可能性,这是一个巨大的问题。但这是一个困难的情况,事实证明,即使在置信水平为 0.8 的情况下,正确的方法也会将这一概率低估 1.64 倍。

  • 如果我们使用置信水平为 0.8 的正确方法(如第 269 页所述,扩展到小概率),那么除了这种小样本和小概率的极端组合,计算出的界限总是保守的(它们高估了违反率)。然而,他们并没有做到极致。最糟糕的情况是比率为 0.41,考虑到我们在回报中获得的信心,这不是一个严重的惩罚。这个权衡对我来说是显而易见的。

选择器 _DD 程序

在第 179 页,我们看到了 CHOOSER 程序,它根据多重选择标准的不断发展的表现,选择要购买并持有一天的股票。这个程序用于演示嵌套的前向行走。现在我们用同样的交易系统来展示如何计算未来亏损的置信界限。这是在 CHOOSER_DD.CPP 中的程序中实现的,对交易系统感兴趣的读者可以参考从 179 页开始的部分。在这里,我们将重点关注该计划的削减方面。

回想一下,该交易系统的样本外收益在指数为OOS2_start的数组OOS2中,但不包括OOS2_end。因此,我们有了n OOS 案例,如下面代码的第一行所示。我们做了大量的 bootstrap 复制,如果我们想要好的精确度,至少要做几千次。对于每个 bootstrap 样本,我们调用drawdown_quantiles()来计算我们感兴趣的四个预定义分位数。

重要的是要注意,每个引导样本的大小都是完全 OOS 集的大小,因为这是我们从中取样的假定总体。另一方面,我们指定n_trades为提款期的交易次数,它可能小于n。在程序中,这是 252,一年的日收益,但读者可以很容易地改变它。这个量定义了我们要限制的统计量。

n = OOS2_end - OOS2_start ;

for (iboot=0 ; iboot<bootstrap_reps ; iboot++) {
   for (i=0 ; i<n ; i++) {             // Collect a bootstrap sample from the entire OOS set
      k = (int) (unifrand() * n) ;
      if (k >= n)
         k = n - 1 ;
      bootsample[i] = OOS2[k+OOS2_start] ;
      }

   drawdown_quantiles ( n , n_trades , bootsample , quantile_reps , quantile_sample ,
                                        work ,  &q001[iboot] , &q01[iboot] ,&q05[iboot] ,&q10[iboot] ) ;
   } // End of bootstrap loop

drawdown_quantiles()程序与我们已经在第 272 页看到的程序相同,由第 271 页显示的drawdown()计算的压降也相同,只有一个重要的例外。我们这样修改最后一行:

   return 100.0 * (1.0 - exp ( -dd )) ; // Convert log change to percent

回想一下,所有的 OOS 收益都是价格变化率的对数(对数价格的差异)。还记得基本的数学原理,产品的对数是被相乘的产品的对数之和。因此,计算得出的下降值是最高权益与最低权益之比的对数。上一行代码中的简单公式计算了权益损失百分比,这是表示资金减少的最常见方式。例如,假设我们从权益 1 开始,达到权益 3 的峰值,随后是权益 2 的低谷。我们将有dd=log(3)–log(2)。最后一行代码将返回100*(1–exp(log(2)–log(3))=100*(1–2/3)= 33.3%,这是大多数用户所期望的。

处理完所有的bootstrap_reps样本后,我们对四个统计数据集合进行升序排序,这样我们就可以使用第 274 页所示的find_quantile()程序轻松找到任何指定的分位数。此处显示了 0.001 界限的代码;其他三个界限的代码类似:

   qsortd ( 0 , bootstrap_reps-1 , q001 ) ;
   fprintf ( fpReport, "\n           0.5        0.6        0.7        0.8        0.9        0.95" ) ;
   fprintf ( fpReport, "\n0.001  %8.3lf   %8.3lf   %8.3lf   %8.3lf   %8.3lf   %8.3lf",
             find_quantile ( bootstrap_reps , q001 , 0.5 ),
             find_quantile ( bootstrap_reps , q001 , 0.6 ),
             find_quantile ( bootstrap_reps , q001 , 0.7 ),
             find_quantile ( bootstrap_reps , q001 , 0.8 ),
             find_quantile ( bootstrap_reps , q001 , 0.9 ),
             find_quantile ( bootstrap_reps , q001 , 0.95 ) ) ;

理解计算边界的意义很重要。这些指的是预先指定的特定时间间隔(??)的界限(??),以及仅在该时间间隔(??)内的权益变化(??)。先前的权益被忽略,即使提款可能是正在进行的现有提款的继续。此外,这不是我们看到如此极端的下降的可能性。它仅适用于单个指定的时间段。通常,我们会让这一年成为即将到来的一年。

作为示范,我在第 179 页使用的相同数据上运行 CHOOSER_DD 程序。输出如图 6-8 所示。每一行对应一个特定的未来时间段(如下一年,忽略该时间段之前的权益)内的提款将超过表格值的概率。这些列对应于所示界限至少等于未知正确界限的置信度。请注意,我们为极大地增加了对我们的界限的信心付出了令人惊讶的低代价。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-8

CHOOSER_DD 程序的输出****

七、置换检验

置换检验概述

我们从置换检验背后的概念的概述开始。必然地,许多理论细节被省略;更深入的处理见我的书《C++ 中的数据挖掘算法 。假设我们正在训练或测试某个系统,它的性能取决于数据呈现给它的顺序。以下是一些例子:

  1. 我们有一个完整定义的交易系统,我们想在样本外衡量它的表现。在其市场价格历史中,价格变化的顺序非常重要。

  2. 我们已经提出了一个市场交易系统,我们必须优化它的一个或多个参数,以最大化它的性能。在其市场价格历史中,价格变化的顺序非常重要。

  3. 我们有一个模型,定期检查指标,并使用这些变量的值来预测市场波动的近期变化。我们想训练(优化)这个模型,或者在 OOS 数据上测试它。然后,我们将测量该模型的样本内(如果训练)或样本外(如果测试)误差。预测变量未来波动率相对于指标顺序的顺序是(当然!非常重要的。

尽管在每个例子中如何使用置换检验的具体细节有些不同,但是基本思想是相同的。我们按照正确的顺序使用原始数据执行任何我们想要的任务(训练或测试交易系统或预测模型)。然后,我们随机排列数据,重复我们的训练或测试活动,并记录结果。然后我们一次又一次地置换,很多次(数百次甚至数千次)。我们将从原始数据获得的性能图与从置换结果获得的性能图的分布进行比较,从而可以得出结论。

我们如何进行这种比较?我们正在测试一些性能指标,无论是交易系统的净回报、预测模型的平均误差,还是任何其他适合我们运营的性能指标。我们的操作可能有用,也可能没用:我们的交易系统可能能合法地利用市场模式赚钱,也可能不能。我们的预测模型及其决策所依据的指标可能有也可能没有真正的预测能力。但是有一件事我们通常可以确定:如果我们改变我们操作所基于的数据,任何合法的能力都会消失,因为预测模式被破坏了。如果我们随机排列市场历史中的价格变化,市场将变得不可预测,因此任何交易系统都将受到阻碍。如果我们随机改变预测模型的指标和目标变量之间的配对,该模型将没有任何可信的关系可以学习或利用。

这就引出了我们使用置换检验的方法。假设我们用九种不同的排列重复训练或测试。包括原始的、未置换的数据,我们有十个性能测量。如果我们对这些进行排序,原始性能可以占据十个可能的有序位置中的任何一个,从最好到最差,或者介于两者之间的任何位置。如果我们的操作真的没有价值(交易系统没有能力检测盈利的市场模式或者模型没有预测能力),那么原始订单就没有优势。因此,原始性能具有占据任何位置的相等概率。相反,如果我们的操作有合法的权力,我们会期望它的原始性能会达到或接近最佳。因此,我们的原始表现在分类表现中的位置提供了关于我们操作能力的有用信息。

我们可以更严谨一些。继续假设我们已经进行了九次排列。还假设我们发现,令我们非常高兴的是,原始的未置换数据在十个值中表现最好。这当然是好消息,非常令人鼓舞。有证据表明,当数据没有被置换时,我们的操作是在数据中寻找有用的模式。但是这个发现有多大意义呢?我们能说的是,如果我们的操作真的毫无价值,那么我们有 0.1%的概率完全靠运气获得这个结果。换句话说,我们获得了 0.1 的 p 值。如果这个结论和术语不十分清楚,请回顾从 210 页开始的假设检验材料。

如果我们的原创表演是十个表演者中第二好的呢?在零假设下,我们的操作是没有价值的,有 0.1 的概率,它在第二个槽着陆,也有 0.1 的概率,它会做得更好,在顶部槽着陆。因此,存在 0.2 的概率(p 值),即一个无价值的操作将获得我们观察到的性能,或者更好。

一般来说,假设我们执行 m 个随机排列,还假设这些排列的 k 的性能等于或超过原始数据的性能。然后,在我们的操作没有价值的零假设下,有可能( k +1)/( m +1)我们会幸运地获得这个结果或更好的结果。

如果我们想在我们的实验设计中一丝不苟,我们会在进行置换检验之前选择一个 p 值。特别是,我们会选择一个小概率(通常为 0.01 到 0.1),我们发现这是一个可接受的可能性,即错误地得出结论,我们的操作具有合法的能力,而实际上并没有。我们将选择一个大的 m (超过 1000 并不罕见或过多),使得 m +1 乘以我们的 p 值是一个整数,并求解 k 。然后执行置换测试,并得出结论:当且仅当 k 或更少的置换值等于或超过原始值时,我们的操作才是有价值的。如果我们的行动真的毫无价值,我们就有可能错误地认为它是有价值的。

测试完全指定的交易系统

假设我们开发了一个交易系统,我们想在开发过程中获得的一组市场历史上测试它的性能。这将给我们一个公正的性能数字。我们已经探讨了在这个样本外时间周期中获得的收益的一些重要用途。如果没有一个回报是极端的,它们的分布形状大致是钟形曲线,我们可以交叉手指,使用 216 页描述的参数测试。如果我们想更保守一点,我们可以使用 222 页描述的自举测试。但是我们很快就会看到,置换检验提供了一条潜在的有价值的信息,而这是刚刚提到的两种测试都没有提供的。

此外,排列检验没有限制参数检验效用的分布假设,而且它们比 bootstrap 检验更能抵抗分布问题。因此,置换检验是一个装备良好的工具箱的重要组成部分。

当我们置换市场价格变化来执行这个测试时,我们必须只置换OOS 时间周期中的变化。随着价格变化推动交易决策,很容易更早开始排列。例如,假设我们回顾 100 根棒线来做交易决定。我们测试的数据将从 OOS 测试周期开始前的 100 根棒线开始,这样我们就可以在 OOS 周期的第一根棒线上立即做出交易决定。但是这 100 根早期钢筋必须而不是包括在排列中。为什么呢?因为他们的回报将不包括在原始的、未经许可的业绩数字中。如果这些早期的棒线在某些方面不同寻常,比如有很强的趋势,那该怎么办?当这些不寻常的棒被置换到 OOS 段中时,它们会影响相对于不包括它们的影响的原始结果的结果。所以,绝不能让他们侵入 OOS 试验区。

测试培训过程

也许置换检验最重要的用途是评估优化交易系统的过程。交易系统失败主要有两种不同的方式。最明显的失败模式是系统不能检测和利用市场价格的预测模式;就是弱或者不智。很明显,置换测试将很容易检测到这种情况,因为您的系统在非置换数据和置换数据上的性能将会很差。您的系统的性能不会在置换竞争中脱颖而出。

然而,这不是我们最感兴趣的情况,因为我们几乎肯定不会走到这一步。在我们花费宝贵的计算机资源之前,交易系统的弱点就会显现出来;我们很快就会看到令人沮丧的表现。

置换测试有价值的问题是弱点的反义词:你的系统在检测预测模式方面太强大了。这种情况通常使用的术语是过度拟合。当您的系统有太多可优化的参数时,它会倾向于将随机噪声视为预测模式,并学习这些模式以及可能存在的任何合法模式。但是因为噪音不会重复(根据定义),当系统被用于交易真实货币时,这些学习到的模式将是无用的,甚至是破坏性的。我经常看到有人开发系统,回顾几个移动平均线的可优化距离,波动的可优化距离,数量变化的可优化阈值。这种系统在训练期间产生惊人的表现,但也产生完全随机的样本外交易。

这就是置换检验的用处。过度配置的交易系统不仅在原始数据上表现良好,在置换数据上也是如此。这是因为过度拟合的系统非常强大,它甚至可以在置换数据上学习“预测”模式。因此,所有的样本内性能,无论是置换的还是未置换的,都将是优秀的,原始性能不会从置换的竞争对手中脱颖而出。所以,你需要做的就是对许多组(至少 100 组)置换数据重复训练过程,并按照前面描述的那样计算 p 值,( k +1)/( m +1)。这可能需要大量的计算机时间,但几乎总是值得的。在我多年与交易系统开发人员一起工作的个人经验中,我发现这种技术是我工具箱中最有价值的工具之一。除非你得到一个小的(0.05 或更小)p 值,你应该怀疑你的系统规格和优化过程。

测试交易系统工厂

在许多或大多数开发情况下,我们有一个交易系统的想法,但我们的想法没有完全具体化;它还有一个或多个方面,比如可优化的参数,没有具体说明。作为一个简单的例子,我们可能有一个移动平均交叉系统,它有两个可优化的参数,长期和短期回顾。系统定义,以及优化其参数的严格定义的方法,并通过系统的 OOS 测试进行验证,构成了我们可以称之为模型工厂的东西。换句话说,在优化之前,我们没有实际的交易模型;这只是一个想法以及将想法转化为具体事物的方法。我们最终得到的实际交易系统将取决于它所依据的市场数据。我们现在的目标是评估模型工厂的质量,而不是评估一个完全定义的交易系统的质量。如果我们能够得出结论,我们的模型工厂在生产良好的交易系统方面可能是有效的,那么当我们使用最新的数据从模型工厂创建一个交易系统时,我们可以确信我们的系统会有令人尊敬的性能。当然,这就是我们在前面的章节中从许多不同的角度探讨过的 walkforward 测试背后的整个思想。但是测试完整系统与测试我们的培训过程与测试我们的模型工厂之间的区别尤其与置换检验相关。这就是这里强调这种区别的原因。

当我们将置换测试与前向测试结合起来时,我们必须小心置换了什么,就像我们测试一个完全指定的系统一样。特别地,考虑这样一个事实,当我们向前移动原始的未置换系统时,第一个折叠中的训练数据将永远不会出现在任何 OOS 区域中。由于这部分历史数据可能包含不寻常的价格变化,如大趋势,我们必须确保它永远不会出现在 OOS 地区的置换运行。因此,第一训练折叠必须从排列中省略

我们是否也排列了 OOS 排列中省略的第一个训练折叠?我从来没有看到任何令人信服的支持或反对这一点的论据,我的直觉是,这没有什么区别。然而,我自己的实践也是置换第一个训练折叠,当然是单独地。这可能会给交易决策带来更多的变化。例如,可能是原始数据导致在第一次 OOS 折叠中大量的多头头寸。如果市场整体有强烈的向上倾向,这将夸大置换的表现。但是如果排列第一个训练折叠经常减少多头头寸的数量,这将给出更多种类的交易结果,这是我们置换检验的最终目标。另一方面,我不认为这是一个压倒性的论点,所以如果你选择避免更换第一个训练折叠,我不认为你会犯下严重的罪行。

另一个决定是关于是否以及如何排列前向行走折叠。有两种选择。在第一次训练后,你可以对所有的市场变化做一个简单的排列,然后对这个排列的数据进行前推。或者,您可以对每个折叠进行单独、孤立的排列。您甚至可以将第二种选择分成几个子选择,将每个折叠中的 is 和 OOS 数据集中到一个置换组中,或者将每个折叠的 IS 和 OOS 集分成单独的置换组。

这些替代方案有什么区别?老实说,还没有足够的研究为这一选择提供严格的指导。似乎主导因素涉及市场行为的平稳性。如果你想假设市场的特征(特别是趋势和波动性)是不断变化的,你想让你的测试方法适应这些不断变化的条件,那么你可能会想分别置换每一个折叠以保持局部行为。就我个人而言,我更喜欢关注普遍的市场模式,而不是试图跟踪感知的变化,容易受到拉锯的影响。出于这个原因,我自己的习惯是,在第一次折叠的训练集作为一个单一的大集团后,置换所有的市场变化。但我声称在这件事上没有特别的知识或专长。我只能说,这是我觉得最有意义的,也是我在自己的工作中所做的。不同意也没关系。

无论您选择如何排列,对于原始的、未排列的数据,您都将有一个 OOS 性能图,对于每个排列,也有一个相似的性能图。与其他测试一样,您所要做的就是计算有多少置换后的性能等于或超过了原始数据。使用 p-value =(k+1)/(m+1)公式,该公式给出了你最初的 OOS 表现可能与你从一个真正无价值的模型工厂中侥幸获得的一样好或者更好的概率。除非这个 p 值很小(0.05,甚至 0.01 或更小),否则你应该怀疑你工厂的质量,因此不信任它生产的任何交易系统。

预测模型的置换检验

到目前为止,一切都与交易系统有关。但是金融市场交易者可能使用预测模型来做一些事情,例如预测波动性即将发生的变化。通常情况下,除了市场价格历史之外,还会涉及其他变量,如经济指标或其他数量的同期预测。这些通常被称为预测值,因为它们是模型用来进行预测的量。我们还有一个“真实”变量,通常称为目标变量。这是我们试图预测的量,为了训练预测模型,我们需要知道对应于每组预测值的目标的真实值。在波动率的例子中,目标是波动率的近期未来变化。

在讨论交易系统时,我们确定了三种情况:1)在样本外数据上测试一个完全指定的系统;2)测试我们的训练过程,特别注意检测过度拟合;以及 3)测试我们的模型工厂。预测模型的置换检验以一种显而易见的方式属于相同的三个类别,所以在此讨论中我们将不区分它们。相反,我们将关注置换的特殊方面。

理解在将目标与预测集配对的环境中,对于绝大多数模型来说,训练数据出现的顺序是不相关的。只有预测集的与目标的配对影响训练。我们希望它们是并发的:我们将目标在给定时间的正确值与预测值的当前值配对。我们通过破坏这种配对来进行置换,随机重新排列目标,使它们与不同的预测集配对。当我们这样做时,有两个至关重要的问题,这两个问题将很快得到更详细的描述。

  1. 指标集不得相互置换,只能相对于目标进行置换。这保持了集合内的相关性,这对正确的测试至关重要。

  2. 两者中,一个或多个预测值目标值不能有任何序列相关。一个或另一个中的序列相关性是好的,甚至是常见的,但它不能同时存在于两者中。

对于第一个问题,考虑这个玩具的例子。假设我们有两个预测指标:S&P 100 指数的近期趋势和标准普尔 500 指数的近期趋势。这两个量用于预测 S&P 100 指数下周相对于刚刚结束的一周的波动性。每周五交易结束时,我们计算这两个最近的趋势以及刚刚结束的一周的波动性。当我们根据历史数据训练我们的预测模型时,我们也知道即将到来的一周的波动性,因此我们从即将到来的一周的波动性中减去前一周的波动性以获得变化,这就是我们的目标变量。当我们将训练好的模型投入使用时,我们将预测波动率即将发生的变化。

排列这些数据的正确方法是随机重新排列目标,使目标与来自不同周的成对预测因子相关联,从而破坏任何可能的预测关系。如果我们也改变预测因子呢?如果我们这样做,我们经常会得到无意义的预测对。我们可能最终得到一个预测对,其中 S&P 100 指数有一个强劲的上升趋势,而标准普尔 500 有一个强劲的下降趋势。在现实生活中,这种配对即使不是不可能,也是极不可能的。置换检验背后的一个关键思想是,我们必须在模型没有价值的零假设下,以相等的概率创建可能在现实生活中发生的排列。如果我们产生了无意义或极不可能的排列,这个方法就失败了。

对于第二个问题,考虑一个或多个预测值可能具有序列相关性(给定时间的变量值与其在附近时间的值相关)。事实上,这是极其普遍的,几乎是普遍的。例如,假设预测值是前 20 根棒线的趋势。当我们前进一根棒线时,我们仍然有之前 20 根棒线中的 19 根进入计算,所以趋势不太可能改变太多。

如果我们不小心,目标变量也可能具有序列相关性。例如,在波动率的例子中,我将目标定义为波动率的变化,而不是实际波动率。如果以波动率为目标,会发现显著的序列相关性,因为波动率通常变化缓慢;下周的波动率会接近本周的波动率。但是波动性的变化不太可能具有序列相关性。当然,它可能仍然存在,但肯定会大大减少,如果不是完全消除。

如果我们有重叠的时间段,即使波动性的变化也会有严重的序列相关性。例如,假设在一周的每一天,一周五天,我们计算未来五天的波动性变化,并将其与前五天进行比较。每次我们提前窗口,大多数日子将是相同的,所以连续的波动性变化值将是高度相关的。

关键的一点是,仅在一个或多个预测变量中,或仅在目标中的序列相关性是无害的。这是因为我们可以将排列视为置换任何一个不是序列相关的,并避免破坏另一个序列的相关性。但是如果两者是连续相关的,排列将会破坏这个属性,我们将会处于处理现实生活中不可能发生的配对的情况,这是一个大罪。再次回忆一下,置换检验的一个关键原则是,如果我们的模型没有价值,我们的排列在现实生活中必须有相等的概率。

值得注意的是,这种序列相关性限制并不是置换检验所独有的。几乎所有的标准统计检验都有这个限制。一些观察依赖于其他观察的事实有效地减少了数据的自由度,使得测试表现得好像有比实际更少的观察。这导致拒绝零假设的可能性增加,这是最糟糕的错误。

置换测试算法

大多数读者现在应该相当清楚置换检验,通常称为蒙特卡洛置换检验 (MCPT),是如何进行的。然而,我们现在将通过明确地陈述算法来确保非正式陈述的清晰性。在下面的伪代码中,nreps是评估总数,包括原始的未置换试验。每次试验都会得到一个performance数字,数值越大意味着性能越好。如果我们正在测试一个完全指定的交易系统或预测模型,这是在样本集外获得的性能。如果我们正在测试我们的训练过程,这是最终(最佳)的样本内性能。如果我们测试一个模型工厂,这是通过汇集所有 OOS 折叠获得的性能。为了与 C++ 兼容,零原点用于所有数组寻址。

for irep from 0 through nreps-1
      if (irep > 0)
            shuffle

      compute performance

      if (irep == 0)
            original_performance = performance
            count = 1
      else
           if (performance >= original_performance)
                 count = count + 1

p-value = count / nreps

我们首先计算未溢出数据的性能,并将该性能保存在original_performance中。我们还初始化我们的计数器,计算的性能等于或超过原始性能的次数。从那时起,我们混洗并评估混洗数据的性能,按照指示递增计数器。p 值是使用已经见过几次的公式计算的,( k +1)/( m +1),其中 k 是置换值等于或超过原始值的次数,而 m 是置换的次数。在本章的最后,我们将探索几个演示这个算法的程序。

扩展选择偏差的算法

在第 124 页,我们开始了对选择偏差的详细讨论。如有必要,请查看所有材料。这里我们展示了蒙特卡罗置换检验)是如何扩展到处理选择偏差的。为了将这个主题放在上下文中,这里有一个常见的场景。我们有几个相互竞争的交易系统,比如说两个或者几百个。也许它们是由不同的开发者提交给我们考虑的,或者也许它们都是相同的基本模型,但是具有不同的试验参数集。无论如何,我们从竞争者中挑选最好的。这个算法将回答两个问题。

  1. 一个不太重要但仍然有趣的问题是关于单个的竞争者。对于每个竞争对手(忽略其他竞争对手),如果该竞争对手实际上毫无价值,我们获得的绩效至少与我们观察到的一样好的概率是多少?这与上一节中显示的基本算法所回答的问题完全相同,针对每个竞争对手分别回答。

  2. 真正重要的问题是关于最好的(表现最好的)竞争者。假设所有的竞争者都毫无价值。如果我们测试了大量的样本,很可能至少有一个是幸运的,完全是随机的。因此,我们不能仅仅确定哪一个是表现最好的,然后使用可能被称为其 solo p 值的东西,即如果它毫无价值,它也会表现得和它完全靠运气一样好的概率。这是上一节中的算法计算出的 p 值。由于我们选择了最好的系统,这样的测试会受到很大的影响。当然,它会在单人测试中表现出色!所以,我们必须回答一个不同的问题:如果所有的竞争者都是毫无价值的,那么他们中最优秀的至少表现得和我们观察到的一样好的可能性有多大?我们可以称之为无偏 p 值,因为它考虑了选择最佳竞争对手所导致的偏差。

这里显示了回答这两个问题的算法。

for irep from 0 through nreps-1

      if (irep > 0)
            shuffle

      for each competitor
            compute performance of this competitor
            if (irep == 0)
                  original_performance[competitor] = performance
                  solo_count[competitor] = 1 ;
                  unbiased_count[competitor] = 1 ;
            else
                  if (performance >= original_performance[competitor])
                        solo_count[competitor] = solo_count[competitor] + 1

      if (irep > 0)
            best_performance = MAX ( performance of all competitors )
            for each competitor
                  if (best_performance >= original_performance[competitor)
                        unbiased_count[competitor] = unbiased_count[competitor] + 1

for all competitors
      solo_pval[competitor] = solo_count[competitor] / nreps
      unbiased_pval[competitor] = unbiased_count[competitor] / nreps

读者应检查该算法,并确认对于每个竞争对手,此处计算的solo_pval与上一节中算法为任何竞争对手计算的完全相同。

注意,这个算法为每个竞争者计算一个unbiased_pval。对于每个排列,它会找到表现最好的,并将其与每个竞争者的分数进行比较,相应地增加相应的计数器。对于原始表现最好的竞争对手,这是一个完美的比较,最好的,因此这是最佳表现者的正确 p 值。对于所有其他竞争对手,这个 p 值是保守的;这是真实 p 值的上限。因此,任何有小unbiased_pval竞争者都值得认真考虑。

分割交易系统的总收益

假设你刚刚训练了一个市场交易系统,优化了它的参数,以最大化一个性能指标。在第 286 页,我们看到了如何使用蒙特卡罗置换检验来收集关于模型是太弱(无法找到预测模式)还是太强(将噪声误认为真实模式而过度拟合)的信息。我们还看到了使用置换测试来评估使用 OOS 数据的完全指定的模型的方法,以及评估交易系统工厂质量的方法。现在我们来看一个更有趣的方法,用置换检验来收集交易系统质量的信息。这种方法不像以前的测试那样严格,它的结果通常应该有所保留。但是它的发展揭示了如何从一个交易系统中获得看似良好的表现,并且该技术也提供了一个未来可能表现的指标。

假设我们刚刚训练了一个交易系统,通过调整它的参数来最大化一个性能指标。我们可以将其样本内总回报大致分为三个部分。

  1. 我们的模型(希望如此!)已经学会了合法的技能来检测市场历史中的预测模式,从而做出明智的交易决策。这种表现很可能会持续到未来。

  2. 我们的模型还将一些噪声模式误认为是合理的,从而学会了对根据定义不会重复的模式的反应。这种被称为训练偏置的绩效组成部分不会持续到未来。

  3. 如果市场有一个整体的长期趋势(就像大多数股票市场一样,长期趋势是向上的),大多数训练算法都会倾向于利用趋势的头寸。特别是,它将有利于上升趋势市场的多头头寸和下降趋势市场的空头头寸。只要趋势持续,这种趋势的表现成分将持续到未来。

这最后一个部分值得更多的讨论,特别是因为它是一些交易系统开发者争论的主题。假设你在两个股票市场上分别训练了一个交易系统(优化了它的参数)。市场 A 在它的训练集历史上有一个强劲的上升趋势,而市场 B 在它开始的价格水平上结束了它的历史。你发现你的市场 A 交易系统的最佳参数提供了大量的多头交易,而在市场 B 上训练的系统的最佳参数给出了相同数量的多头和空头交易。不需要夏洛克·福尔摩斯就可以推断出,在市场 A 上开发的系统中大量多头交易的原因可能与市场 A 享受稳定收益的事实有关,而另一个系统中的多头/空头平衡是由于市场 B 没有明显趋势的事实。

一个重要的哲学问题是:我们应该让市场的潜在长期趋势对我们正在设计的系统的多空交易平衡产生如此大的影响吗?根据我自己的经验,我发现大多数交易系统开发人员甚至没有考虑过这个问题。我倾向于同意这种哲学。如果一个市场有明显的长期趋势,我们不如随波逐流,而不是逆流而上。

另一方面,思考另一种选择无疑是值得的。毕竟,谁能说长期趋势会持续下去,如果趋势逆转,一个严重失衡的系统会发生什么?这是反对让趋势强劲的市场强烈影响我们的交易平衡的一个理由。

看待这个问题还有更深层次的方式。例如,假设我们有一个强劲上涨的市场,我们开发了一个只做多的日线系统,它占这个市场所有交易日的一半。考虑这样一个事实,如果我们每天抛硬币,当硬币正面朝上时,我们做多,平均来说,我们也能从趋势中赚很多钱。因此,人们可以很容易地认为,一个交易系统的“智能”应该用它击败一个拥有相同数量多头和空头头寸的假设随机交易系统的程度来衡量。

这一切都归结为一个简单但令人担忧的问题。如果你的系统从一个趋势中赚了很多钱,但却赢不了抛硬币,它真的有用吗?一个学派认为,如果它与一个有利可图的掷硬币系统联系在一起,它就没有智能。另一个学派认为,能够利用长期趋势是聪明的表现。然后,角落里的智者指出,如果趋势逆转,第二个论点就站不住脚了,而第一个论点更有可能成立。然而,另一个声音从暗处传来,指出长期趋势通常会持续很长时间。争论还在继续。

不管你的观点如何,进一步探讨这个问题是值得的。像往常一样,在本书中,我们将收益视为变化的日志。让 MarketChange 成为我们训练集中市场历史范围的总变化。根据我们对变化的定义,这是最终价格与最初价格之比的对数。设 n 为单个价格变动收益的个数(比价格个数少 1)。然后我们可以定义TrendPerReturn=market change/n

一些开发人员在优化过程中从每个条形的返回中减去这个量,以消除趋势对计算性能的影响。(当然,当计算指标或任何涉及交易决策的东西时,人们会使用原始价格。这种修正仅用于计算业绩指标,如回报率、利润系数或夏普比率。)这个选项可以应用于本书中作为例子的任何交易系统,实际上是任何人可以想象的任何交易系统。然而,除了这一简短的提及,我们不会进一步探讨这一想法。这时,我们对趋势有了不同的用法。

如果一个随机交易系统的多头和空头头寸数量与我们训练过的系统相同,那么这个系统的预期总回报是多少?对于我们持有多头头寸期间的每一个价格变动回报,平均来说,趋势将通过 TrendPerReturn 增加我们的回报。相反,我们每持有一个空头头寸,我们的回报就会减少趋势回报率。因此,净效应将是这些位置量的差异。

为了与本节开始时提出的术语保持一致,我们定义了系统总回报的趋势成分,如方程 7-1 所示。

)

(7-1)

因为我们可以从市场价格历史中计算出 TrendPerReturn ,并且因为我们从训练过的系统中知道头寸计数,所以可以显式计算出系统总回报的趋势成分。

回想一下,本节材料的基本前提是,我们训练的交易系统的总回报是三个部分的总和:合法的技能,利用趋势的多空失衡,以及训练偏差(学习随机噪音,就像它是真实的模式一样)。这在等式 7-2 中表示。

)

(7-2)

假设我们随机改变市场,重新训练系统。趋势回报将保持不变,因为我们只是混淆了价格变化的顺序,我们仍然有相同数量的个人回报。但是多头和空头头寸的数量可能会改变,所以我们必须使用等式 7-1 来计算这次置换运行总回报的趋势部分。因为排列是随机的,我们破坏了可预测的模式,所以技能成分为零。任何超过趋势分量的总回报都是训练偏置。换句话说,我们可以使用公式 7-3 计算这个置换运行的训练偏置

)

(7-3)

单个这样的测试包含了太多的随机性,无法对你提议的交易系统及其训练算法中固有的训练偏差提供有用的估计。但是,如果我们进行数百甚至数千次排列,并对等式 7-3 计算出的值进行平均,我们就可以得出一个对训练偏置的大致合理的估计。

这让我们可以计算两个非常有用的性能数字。首先,我们可以通过从系统的总回报中减去训练偏差来计算未来回报的无偏估计。这个数字包括总回报的趋势部分,如果我们坚持利用长期趋势是好的这一理念,这是合适的。这在方程 7-4 中表达。

)

(7-4)

如果我们还对交易系统智能的更严格的定义感兴趣,我们的系统在多大程度上可以胜过拥有相同多空交易数量的随机系统,我们可以使用等式 7-5 来估计它的技能

)

(7-5)

我们将在 310 页探索一个演示这种技术的程序。

本质置换算法和代码

在展示演示本章所讨论的技术的完整程序之前,我们将关注几个关键的置换算法,它们将是这一系列测试的基本工具。

简单置换

我们从基本的置换算法开始。这是正确排列向量的标准方法,以这样一种方式来做,每一种可能的排列都是同样可能的。它需要一个在 0.0 <= unifrand() < 1.0 范围内均匀分布的随机数源。重要的是要确保随机生成器永远不会精确地返回 1.0;如果您不能确定这一点,您必须采取适当的措施来确保不会生成越界下标。在下面的代码中,随机数j必须严格小于i

   i = n ;                 // Number remaining to be shuffled
   while (i > 1) {     // While at least 2 left to shuffle
      j = (int) (unifrand () * i) ;
      --i ;
      itemp = indices[i] ;           // Swap elements i and j
      indices[i] = indices[j] ;
      indices[j] = itemp ;
      }

在这段代码中,我们将i初始化为向量中元素的数量,并且在每次通过while()测试时,它将是剩余的要被洗牌的数量。我们随机选择一个指数j,它同样有可能指向任何有待洗牌的元素。递减i,使其指向数组中最后一个需要洗牌的元素,并交换元素ji。请注意,j==i可能不会发生交换。我们从数组的末尾向后工作到前面,只有当我们不再有任何东西可以交换时才停止。

置换简单的市场价格

当我们改变市场价格时,我们跳到了一个稍微高一点的难度。显然我们不能随便交换价格。想象一下,如果我们改变几十年的股票价格,其市场历史从 20 点开始,到 800 点结束,会发生什么。因此,我们必须将价格历史解构为变化,排列这些变化,然后重建排列后的价格历史。此外,我们不能改变简单的价格差异,因为大价格时期的差异大于小价格时期的差异。所以,我们用比率来计算变化。相当于,我们取价格的记录,并改变记录中的变化。

另一个复杂因素是,我们必须准确地保持价格历史中的趋势,以便正确处理头寸失衡。这很容易做到;我们只是保持起始价格不变。由于重建的价格序列应用了相同的变化,只是顺序不同,我们最终以相同的价格结束。只改变了内部的起伏。

第一步是将价格历史解构为变化。下面的简单代码假设所提供的价格实际上是原始价格的对数。我们必须提供changes长的工作区nc。注意,changes的最后一个元素没有使用。

void prepare_permute (
   int nc ,                        // Number of cases
   double *data ,            // Input of nc log prices
   double *changes        // Work area; returns computed changes
   )
{
   int icase ;

   for (icase=1 ; icase<nc ; icase++)
      changes[icase-1] = data[icase] - data[icase-1] ;
}

该准备代码只需要做一次。从那时起,任何时候我们想要置换(日志)价格历史,我们调用下面的例程:

void do_permute (
   int nc ,                            // Number of cases
   double *data ,                // Returns nc shuffled prices
   double *changes           // Work area; computed changes from prepare_permute
   )
{
   int i, j, icase ;
   double dtemp ;

   // Shuffle the changes. We do not include the first case in the shuffling,
   // as it is the starting price, so there are only nc-1 changes.

   i = nc-1 ;                           // Number remaining to be shuffled
   while (i > 1) {                    // While at least 2 left to shuffle
      j = (int) (unifrand() * i) ;
      if (j >= i)                        // Must not happen, be safe
         j = i - 1 ;
      --i ;
      dtemp = changes[i] ;
      changes[i] = changes[j] ;
      changes[j] = dtemp ;
      } // Shuffle the changes

   // Now rebuild the prices, using the shuffled changes

   for (icase=1 ; icase<nc ; icase++)
      data[icase] = data[icase-1] + changes[icase-1] ;
}

回想一下,prepare_permute()没有使用changes中的最后一个元素,所以我们有nc–1的变化要洗牌。我们假设调用者没有改变data中的第一个元素,我们从那里重新构建。

用偏移量置换多个市场

正如前面指出的,如果我们的交易系统涉及多个市场,我们必须以同样的方式排列它们,这样市场间的相关性才能保持完整。否则,我们可能会以现实世界中无意义的市场变化而告终,一些市场强劲上涨,而与之高度相关的其他市场则大幅下跌。这种现实世界一致性的缺乏将是毁灭性的,因为蒙特卡洛置换检验的一个关键原则是,如果零假设为真,所有排列的可能性必须相等。

为了做到这一点,我们必须确保每个市场在每个日期都有一个价格;必须删除一个或多个市场没有价格的任何日期。在实践中,如果我们坚持交易广泛的市场,我们通常会丢失很少或没有日期,因为它们都在正常交易日交易。如果市场因假日休市,什么都不会交易,如果市场正常营业,一切都会交易。尽管如此,我们必须确保任何日期都没有丢失数据,这将使同时排列变得不可能。实现这一点的快速算法如下:

Initialize each market's current index to 0
Initialize the grand (compressed) index to 0
Loop
      Find the latest (largest) date at each market's current index across all markets
      Advance all markets' current index until the date reaches or passes this date
      If all markets have the same current date:
            Keep this date by copying market records to the grand index spot
            Advance each market's current index as well as the grand index

在下面的代码中,我们有以下内容:

  • market_n[]:对于每个市场,存在的价格数量

  • market_price[][]:每个市场(第一指数)的价格(第二指数)

  • market_date[][]:每个市场(第一指数)的每个价格(第二指数)的日期

  • market_index[]:对于每个市场,当前正在检查的记录的索引

  • grand_index:当前记录在压缩数据中的索引

for (i=0 ; i<n_markets ; i++)         // Source markets all start at the first price
   market_index[i] = 0 ;
grand_index = 0 ;                        // Compressed data starts at first record

for (;;) {

   // Find max date at current index of each market

   max_date = 0 ;
   for (i=0 ; i<n_markets ; i++) {
      date = market_date[i][market_index[i]] ;
      if (date > max_date)
         max_date = date ;
      }

   // Advance all markets until they reach or pass max date
   // Keep track of whether they all equal max_date

   all_same_date = 1 ;                                    // Flags if all markets are at the same date

   for (i=0 ; i<n_markets ; i++) {
      while (market_index[i] < market_n[i]) {    // Must not over-run a market!
         date = market_date[i][market_index[i]] ;
         if (date >= max_date)
            break ;
         ++market_index[i] ;
         }

      if (date != max_date)                               // Did some market jump over max?
         all_same_date = 0 ;

      if (market_index[i] >= market_n[i])           // If even one market runs out
         break ;                                                   // We are done
      }

   if (i < n_markets)                                         // If even one market runs out
         break ;                                                   // We are done

   // If we have a complete set for this date, grab it

   if (all_same_date) {
      for (i=0 ; i<n_markets ; i++) {
         market_date[i][grand_index] = max_date ;  // Redundant, but clear
         market_price[i][grand_index] = market_price[i][market_index[i]] ;
         ++market_index[i] ;
         }
      ++grand_index ;
      }
   }

n_cases = grand_index ;

我们现在准备考虑多重市场的排列。通常情况下,我们希望分别排列市场历史的不同部分。如果我们要置换一个单一的市场,只需抵消置换例程调用参数中的价格就可以轻松完成。但是,当我们有一个完整的市场阵列时,我们不能这样做,所以我们必须明确指定一个偏移距离。

这是排列将如何完成。我们有从价格指数 0 到价格指数 1 的nc个案例。案例offset是将改变的第一个案例,并且offset必须是正的,因为offset–1 处的案例是“基础”案例并且保持不变。检查的最后一个案例是在nc–1,但它也将保持不变。因此,混洗后的数组以原始价格开始和结束。只有内部价格会改变。

如果数据集在单独的部分中排列,这些部分不能重叠。offset–1 处的“基准”情况包含在不能重叠的区域中。例如,我们可以用offset =1 和nc =5 来置换。案例 1 到 3 将会改变,而最终案例(0 和 4)保持不变。随后的置换必须在offset =5 或更大时开始。两种置换操作都不会改变情况 4。

下面是必须首先调用的准备例程,并且只有在完成多个排列时才调用一次:

void prepare_permute (
   int nc ,                       // Number of cases total (not just starting at offset)
   int nmkt ,                   // Number of markets
   int offset ,                  // Index of first case to be permuted (>0)
   double **data ,          // Input of nmkt by nc price matrix
   double **changes      // Work area; returns computed changes
   )
{
   int icase, imarket ;

   for (imarket=0 ; imarket<nmkt ; imarket++) {
      for (icase=offset ; icase<nc ; icase++)
         changes[imarket][icase] = data[imarket][icase] - data[imarket][icase-1] ;
      }
}

这种排列只是上一节中显示的单一市场方法的简单概括。

void do_permute (
   int nc ,                        // Number of cases total (not just starting at offset)
   int nmkt ,                    // Number of markets
   int offset ,                   // Index of first case to be permuted (>0)\
   double **data ,           // Returns nmkt by nc shuffled price matrix
   double **changes       // Work area; computed changes from prepare_permute
   )
{
   int i, j, icase, imarket ;
   double dtemp ;

   // Shuffle the changes, permuting each market the same to preserve correlations

   i = nc-offset ;              // Number remaining to be shuffled
   while (i > 1) {              // While at least 2 left to shuffle
      j = (int) (unifrand() * i) ;
      if (j >= i)                  // Should not happen, but be safe
         j = i - 1 ;
      --i ;

      for (imarket=0 ; imarket<nmkt ; imarket++) {
         dtemp = changes[imarket][i+offset] ;
         changes[imarket][i+offset] = changes[imarket][j+offset] ;
         changes[imarket][j+offset] = dtemp ;
         }
      } // Shuffle the changes
   // Now rebuild the prices, using the shuffled changes

   for (imarket=0 ; imarket<nmkt ; imarket++) {
      for (icase=offset ; icase<nc ; icase++)
         data[imarket][icase] = data[imarket][icase-1] + changes[imarket][icase] ;
      }
}

置换价格栏

替换价格条比替换一组简单的价格要复杂得多。有四个主要问题需要考虑,也许还有一些其他在某些情况下可能相关的次要问题。这些很重要:

  • 我们决不能让开盘价或收盘价超出了该价格的高低点所限定的范围。即使我们的交易系统无视高低,违反这个基本的帐篷也是恶业。

  • 如果我们的交易系统检查棒线的高低点,我们不能破坏这些量的统计分布,无论是它们与开盘价和收盘价的关系还是它们的价差。这些量在排列后必须具有与以前相同的统计特性。

  • 当我们从开盘价到收盘价时,我们不能破坏价格变化的统计分布。排列后开-闭变化的分布必须与排列前相同。

  • 我们不能破坏棒线间隙的统计分布,即一根棒线收盘和下一根棒线开盘之间的价格变化。这比你可能意识到的要重要得多,如果你不小心,很容易出错。

满足前三个条件很容易。我们只是根据开盘价来定义最高价、最低价和收盘价。如果我们(像往常一样)处理价格日志,对于每根棒线,我们计算并保存最高价减去开盘价,最低价减去开盘价,收盘价减去开盘价。然后,当我们有一个新的开盘价时,我们把这些差价加到上面,分别得到新的最高价、最低价和收盘价。只要我们把这些三个一组的差异放在一起(不要把一个棒线的高差异和另一个棒线的低差异互换),显然第一个条件就满足了。并且只要我们的排列算法不改变开放的统计分布,应该清楚第二和第三条件得到满足。第四个条件是活动扳手。

这种直观的排列方式是非常不正确的。假设我们用排列单一价格阵列的相同方式排列开盘价:计算开盘价到开盘价的变化,排列这些变化,重建开盘价阵列,并使用刚才讨论的“三个差异”方法来完成每根棒线。正如已经指出的,该算法满足前三个条件。

但问题是。记住,大多数时候,一家酒吧的开店时间与前一家酒吧的收盘时间非常接近,价格通常完全相同。但是,在这种不正确的排列算法下,经常会发生我们将两个常见事件不幸组合在一起的情况:我们在排列的开盘价到开盘价的变化上有较大的涨幅,第一根棒线在价格上有较大的开盘价到收盘价的跌幅。结果是一个巨大的,完全不切实际的差距,从封闭到开放的变化。

例如,我们可能有一个酒吧,100 点开门,98 点关门,这不是不现实的。下一个酒吧应该打开非常接近 98。但与此同时,下一个置换开放可能是 102,也不是不现实的。结果是从 98 移动到 102,只是从一个棒线的收盘移动到下一个棒线的开盘。这种情况在现实生活中发生的几率几乎为零。当然,相反的情况也可能发生:我们有一个从开盘价到收盘价有大幅度上升的棒线,而从开盘价到下一个棒线的变化是大幅度下降。由此引发的问题不仅仅是理论上的;他们将彻底摧毁许多交易系统的置换检验。真正的市场不会这样。

这个问题的解决方案很简单,虽然有点乱。我们将(相对较大的)条内变化和(大部分很小的)条间变化分成两个独立的序列,并分别进行置换。当我们重建置换序列时,我们分两步得到每个新的条形。首先,我们使用置换的棒线间变化从一个棒线的收盘移动到下一个棒线的开盘。然后,我们使用置换的棒线内变化从开盘价移动到收盘价,在此过程中选择最高价和最低价。

在即将出现的代码中,要理解置换例程将被调用到第一个可能做出交易决策的棒线。如果有回望,我们假设已经考虑到了这一点。

为排列做准备的代码很简单。像往常一样,我们假设所有的价格实际上都是对数价格。如果它们是真实价格,我们必须使用比率而不是差异;否则算法是一样的。

第一个条是“基础”条,它完全不变。随后的棒线将从收盘时产生。正如我们在检查代码时将会看到的,最后一个小节的结束也将保持不变。对于每根棒线,rel_open是前一个收盘价和当前开盘价之间的差距。当前棒线的最高价、最低价和收盘价都与棒线的开盘价有关。

void prepare_permute (
   int nc ,                       // Number of bars
   double *open ,           // Input of nc log prices
   double *high ,
   double *low ,
   double *close ,
   double *rel_open ,    // Work area; returns computed changes
   double *rel_high ,
   double *rel_low ,
   double *rel_close
   )
{
   int icase ;

   for (icase=1 ; icase<nc ; icase++) {
      rel_open[icase-1] = open[icase] - close[icase-1] ;
      rel_high[icase-1] = high[icase] - open[icase] ;
      rel_low[icase-1] = low[icase] - open[icase] ;
      rel_close[icase-1] = close[icase] - open[icase] ;
      }
}

置换例程有一个参数preserve_OO,需要特别说明。本书中绝大多数的交易系统都是基于单一的价格序列,交易是在下一根棒线收盘时进行的(可能会持续到下一根棒线收盘)。这有时会给出稍微乐观的结果,更不用说它带有一丝不切实际和在现实生活中得不到的味道。更保守的方法是在交易决定后,在开盘价处开仓。如果我们按照第 294 页开始的描述划分交易系统的总回报,并且我们想非常清楚我们如何定义测试期间的总趋势,我们必须通过从最早可能的决策后的第一次开仓到最后一次开仓的变化来定义趋势,并且我们需要这种变化对于所有排列都是相同的。(这可能过于谨慎,但很容易做到,所以我们不妨。)为了使这种差异在所有排列中保持不变,我们必须不允许第一个从关闭到打开的变化或最后一个从打开到关闭的变化参与排列。将 preserve_OO 设置为任何非零数字都可以做到这一点。考虑到这一点,这里是排列代码。首先,我们洗牌关闭到开放的变化。

void do_permute (
   int nc ,                             // Number of cases
   int preserve_OO ,           // Preserve next open-to-open (vs first open to last close)
   double *open ,                 // Returns nc shuffled log prices
   double *high ,
   double *low ,
   double *close ,
   double *rel_open ,          // Work area; input of computed changes
   double *rel_high ,
   double *rel_low ,
   double *rel_close
   )
{
   int i, j, icase ;
   double dtemp ;

   if (preserve_OO)
      preserve_OO = 1 ;

   i = nc-1-preserve_OO ;   // Number remaining to be shuffled
   while (i > 1) {                   // While at least 2 left to shuffle
      j = (int) (unifrand() * i) ;
      if (j >= i)                        // Should not happen, but be safe
         j = i - 1 ;
      --i ;
      dtemp = rel_open[i+preserve_OO] ;
      rel_open[i+preserve_OO] = rel_open[j+preserve_OO] ;
      rel_open[j+preserve_OO] = dtemp ;
      } // Shuffle the close-to-open changes

在前面的代码中,我们注意到了preserve_OO的效果。如果它是输入零,我们洗牌所有的nc–1 关闭到打开酒吧间的变化。但是如果是 1,我们就少了一个要洗牌的变化,我们用 1 抵消所有的洗牌。这保留了第一个棒线间接近开盘价的变化,意味着第二个棒线的开盘价,即第一个可能的“下一个棒线”交易的开盘价,对于所有排列保持不变。

接下来,我们洗牌酒吧内的变化。我们必须完全相同地洗牌,以保持开盘和收盘的高低界限。这里preserve_OO的效果略有不同。它不是保留第一次从关闭到打开的更改,而是保留最后一次从打开到关闭的更改。因为最后的收盘总是被保留,允许最后一根棒线的开盘价到收盘价的差异改变会改变最后一根棒线的开盘价。

   i = nc-1-preserve_OO ; // Number remaining to be shuffled
   while (i > 1) {        // While at least 2 left to shuffle
      j = (int) (unifrand() * i) ;
      if (j >= i)         // Should never happen, but be safe
         j = i - 1 ;
      --i ;
      dtemp = rel_high[i] ;
      rel_high[i] = rel_high[j] ;
      rel_high[j] = dtemp ;
      dtemp = rel_low[i] ;
      rel_low[i] = rel_low[j] ;
      rel_low[j] = dtemp ;
      dtemp = rel_close[i] ;
      rel_close[i] = rel_close[j] ;
      rel_close[j] = dtemp ;
      } // Shuffle the open-to-close changes

使用混乱的变化重建价格历史是微不足道的。

   for (icase=1 ; icase<nc ; icase++) {
      open[icase] = close[icase-1] + rel_open[icase-1] ;
      high[icase] = open[icase] + rel_high[icase-1] ;
      low[icase] = open[icase] + rel_low[icase-1] ;
      close[icase] = open[icase] + rel_close[icase-1] ;
      }
}

示例:P 值和分区

文件 TRN MCPT。CPP 包含一个计算训练 p 值(第 286 和 291 页)和总回报分区(第 294 页)的示例,用于在 OEX 上训练的原始移动平均交叉系统。该程序通过以下命令执行:

MCPT_TRN MaxLookback Nreps FileName

让我们来分解这个命令:

  • MaxLookback:最大移动平均回看

  • Nreps:MCPT 复制次数(数百或数千)

  • FileName:市场文件名称(YYYYMMDD 价格)

下图 7-1 和 7-2 是该程序在 S & P 100 和 S & P 500 索引下执行时的输出。令人着迷的是获得了极其不同的结果。有关计算数量的详细说明,请参考前面引用的页面。程序代码的概述从下一页开始。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-2

使用 SPX 的 MCPT_TRN 程序的输出

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-1

OEX MCPT-TRN 计划的产出

移动平均线交叉系统和我们在前面的例子中看到的一样。它计算短期和长期移动平均线(回调是可优化的),当短期移动平均线高于长期移动平均线时,它做多,当相反时,它做空。我们在这里集中讨论性能数据的计算。

首先,我们计算总趋势,然后除以单个回报的数量,得到单个回报的趋势。记住,可以做出有效交易决定的第一个价格是“基价”,置换从基价到下一个棒线的变化开始。从这一点开始,我们确保所有可能的单个交易回报都服从排列,我们还保证在可能的交易之前没有任何变化可以被排列到混合中,这可能会改变总趋势。然后我们调用第 299 页列出的准备例程来计算和保存价格变化。

trend_per_return=(prices[nprices-1]-prices[max_lookback-1]) / (nprices-max_lookback) ;
prepare_permute ( nprices-max_lookback+1 , prices+max_lookback-1 , changes ) ;

在 MCP 循环中,除了第一遍以外,我们对所有遍进行置换。我们将需要优化系统的多头和空头回报的数量来计算趋势分量。对于第一个非许可试验,保存所有“原始”结果。

for (irep=0 ; irep<nreps ; irep++) {
   if (irep)   // Shuffle
      do_permute ( nprices-max_lookback+1 , prices+max_lookback-1 , changes ) ;

   opt_return = opt_params ( nprices , max_lookback , prices ,
                                             &short_lookback , &long_lookback , &nshort , &nlong ) ;
   trend_component = (nlong - nshort) * trend_per_return ;  // Equation 7-1 on page 297

   if (irep == 0) {            // This is the original, unpermuted trial
      original = opt_return ;
      original_trend_component = trend_component ;
      original_nshort = nshort ;
      original_nlong = nlong ;
      count = 1 ;   // Algorithm on Page 291
      mean_training_bias = 0.0 ;
      }

   else {           // This is a permuted trial
      training_bias = opt_return - trend_component ;        // Equation 7-3 on page 297
      mean_training_bias += training_bias ;                      // Average across permutations
      if (opt_return >= original)                                           // Algorithm on Page 291
         ++count ;
      }
   }     // For all replications

mean_training_bias /= (nreps - 1) ;                                  // First trial was unpermuted
unbiased_return = original - mean_training_bias ;          // Equation 7-4 on page 297
skill = unbiased_return - original_trend_component ;      // Equation 7-5 on page 297

示例:使用下一根棒线返回的训练

文件 MCPT _ 酒吧。CPP 包含一个演示程序,它执行与前面示例相同的 p 值计算和总回报分区。但是,价格数据不是使用单一的价格序列,而是日棒线(尽管它可以是任何长度的棒线)。此外,它使用更保守的方法来计算回报。每个交易决策的回报是从下一根棒线开始到下一根棒线开始的(对数)价格变化。最后,这是一个不同的交易系统,一个简单的均值回归策略,而不是移动平均线交叉。使用以下命令调用该程序:

MCPT_BARS MaxLookback Nreps FileName

让我们来分解这个命令:

  • MaxLookback:最大移动平均回看

  • Nreps:MCPT 复制次数(数百或数千)

  • FileName:市场文件名称(YYYYMMDD Open High Low Close)

图 7-3 显示了 S & P 100 指数该程序的输出,图 7-4 显示了 S & P 500 的输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-4

SPX 的 MCPT _ 巴尔斯程序的输出

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-3

OEX MCPT 巴尔斯程序的输出

和前面的例子一样,我们看到这两个市场的表现有很大的不同。一点也不奇怪,在任何市场,像 MA XOVER 这样的原始趋势跟踪系统的表现与均值回复系统非常不同。但令人惊讶的是,在这两个成分看似相似的市场中,它们的表现有着惊人的不同。事实上,SPX 的 p 值几乎是 1.0,这是一个惊人的值。显然,这个市场是的——均值回归!这肯定会与这个市场的趋势跟踪 p 值 0.001 相符,这是 1000 次复制的最低可能值,也是一个同样惊人的值。但是哇。我是说,哇。另外唯一需要考虑的是,本例中使用的 SPX 市场比 OEX 市场(1982 年)早几十年(1962 年),因此更早的数据可能会发挥作用。绘制每个市场中每个系统的权益曲线最能说明问题。如果你捷足先登,给我发电子邮件。

因为这个交易系统使用稍微不同的方法来计算回报,所以有必要研究一下系统本身和相关的 MCPT 代码。我们从交易系统开始。它计算出一个简单的长期趋势,即当前收盘减去用户指定的固定数量的早期收盘。这通常是一个很大的数字,一千或几千条。它还查看当前的价格下跌,即前一根棒线的(对数)价格减去当前棒线的价格。如果长期趋势高于可优化的阈值,并且价格下跌也高于其自身的可优化阈值,则对下一根棒线采取多头仓位。这个系统背后的哲学是,在一个上升趋势的市场中,价格的突然大幅下跌是一个暂时的异常,将会在下一根棒线被修正。以下是该子例程的调用约定:

double opt_params (        // Returns total log profit starting at lookback
   int ncases ,                   // Number of log prices
   int lookback ,                 // Lookback for long-term rise
   double *open ,               // Log of open prices
   double *close ,              // Log of close prices
   double *opt_rise ,         // Returns optimal long-term rise threshold
   double *opt_drop ,        // Returns optimal short-term drop threshold
   int *nlong                      // Number of long returns
   )

我们将使用best_perf来跟踪最佳总回报。最外面的一对循环为长期上涨趋势和即时价格下跌尝试各种各样的阈值。

   best_perf = -1.e60 ;                              // Will be best performance across all trials
   for (irise=1 ; irise<=50 ; irise++) {          // Trial long-term rise
      rise_thresh = irise * 0.005 ;
      for (idrop=1 ; idrop<=50 ; idrop++) {   // Trial short-term drop
         drop_thresh = idrop * .0005 ;

给定这一对尝试阈值,我们通过有效的市场历史,累计总回报。我们还计算了多头头寸的数量,因为我们需要用它来计算趋势分量。我们从回望距离开始累积,因为我们需要这么多历史来计算长期趋势。我们必须在数据集结束前停止两根棒线,因为保守计算的交易回报是从做出决策后棒线的开盘价到下一根棒线开盘价的(对数)价格变化。

         total_return = 0.0 ;    // Cumulate total return for this trial
         nl = 0 ;                       // Will count long positions
         for (i=lookback ; i<ncases-2 ; i++) {      // Compute performance across history

            rise = close[i] - close[i-lookback] ;     // Long-term trend
            drop = close[i-1] - close[i] ;                // Immediate price drop

            if (rise >= rise_thresh  &&  drop >= drop_thresh) {
               ret = open[i+2] - open[i+1] ;            // Conservative return
               ++nl ;
               }
            else
               ret = 0.0 ;

            total_return += ret ;
            } // For i, summing performance for this trial

剩下的就是记录最佳参数及其相关结果的琐碎簿记任务。

         if (total_return > best_perf) {  // Did this trial param set break a record?
            best_perf = total_return ;
            *opt_rise = rise_thresh ;
            *opt_drop = drop_thresh ;
            *nlong = nl ;
            }

         } // For idrop
      } // For irise

   return best_perf ;
}

置换检验的一般操作与前一节中的操作相同。然而,因为我们是用后面两根棒线的开盘价来计算回报的,所以偏移会有一点不同。本系统中lookback的定义也与先前系统中的max_lookback略有不同,因此也引入了一些差异。考虑每次回报的趋势和准备程序。第一个交易决定可以在指标为lookback的棒线处做出,所以我们调用prepare_permute()来抵消所有四个价格数组。此栏将保持不变;排列从下一根棒线开始,这也是交易回报开始的地方。总共有npriceslookback条可用于排列程序。第一笔交易可以在第一根棒线lookback +1 开始,在最后一根棒线nprices–1 开始时结束。

   trend_per_return = (open[nprices-1] - open[lookback+1]) / (nprices - lookback - 2) ;

   prepare_permute ( nprices-lookback , open+lookback , high+lookback ,
                    low+lookback , close+lookback , rel_open , rel_high , rel_low , rel_c lose ) ;

所有剩余的计算都与我们在上一节中看到的相同,所以在这里重复它们没有意义。当然,完整的源代码可以在 MCPT 酒吧网站上找到

示例:置换多个市场

在第 179 页,我们检查了 CHOOSER.CPP 中的程序代码。在那一节,我们重点讨论了如何使用嵌套的 walkforward 在选择偏差的情况下获得样本外返回。那时排列被忽略了。现在我们回到那个程序,这次集中在置换检验上,它评估 OOS 结果至少和那些由于随机的好运气而获得的结果一样好的概率。请注意,这是第 292 页所示的而不是选择偏差置换算法。本书中没有给出该算法的例子,因为它是简单算法的直接扩展,并且在流程图中有很好的记录。在我的书《C++ 中的数据挖掘算法》中可以找到这个算法的大量源代码示例。本节的真正目的是提供一个同时置换多个市场的例子,以评估一个多市场交易系统,以及演示在包含选择的 walkforward 情况下,置换应该如何被分割成段。

从第 301 页开始,我们详细讨论了多市场置换的程序,回顾一下这一节也无妨。为了方便起见,这里是prepare_permute()的调用列表;do_permute()的情况相同:

void prepare_permute (
   int nc ,                        // Number of cases total (not just starting at offset)
   int nmkt ,                    // Number of markets
   int offset ,                   // Index of first case to be permuted (>0)
   double **data ,          // Input of nmkt by nc price matrix
   double **changes      // Work area; returns computed changes
   )

我们已经看到了一个例子,使用简单的 walkforward 情况将市场历史分割成排列组。我们的动机是,最初的训练折叠没有出现在原始的、未经许可的运行中的任何 OOS 折叠中。因此,我们必须确保置换试验也是如此,以防初始阶段包含的数据在趋势、波动性或其他一些重要属性方面不寻常。我们不能让任何不寻常的数据泄露到一个置换的 OOS 折叠。

当我们在进行嵌套的前向遍历时,情况就更复杂了,就像在 CHOOSER 程序中一样。现在我们有两个 OOS 褶皱要处理。这是我们必须考虑的两个量:

  • IS_n:虽然在 CHOOSER 中的 walkforward 嵌套外层没有发生实际的训练,但这是在程序中起到“训练集”作用的事例数。在置换的情况下,特别重要的是,在最初的非置换试验中,这些病例不会出现在 OOS 折叠结果的任何一个水平上。因此,绝不能允许这些情况发生在未来的 OOS 褶皱中,并以不寻常的变化潜在地污染它们。

  • 这是沃克弗德 OOS 褶皱内层的病例数。外 OOS 褶皱,那些我们最终感兴趣的褶皱,因为它们完全是 OOS,在IS_n+OOS1_n事件之后开始。从IS_n到(但不包括)IS_n+OOS1_n的第一个内走式 OOS 褶皱中的病例,不得置换到外褶皱中,因为它们不在未置换试验中。

有了这些想法,我们将市场历史分成三个独立的部分,并分别排列。总的来说,置换第一个“训练”折叠是否明智(或缺乏)是一个公开的问题。我选择在这里这样做,主要是为了教学的目的,虽然我不知道任何利弊。我自己的观点,没有任何事实支持,是平均而言,无论怎样都没有区别。

以下代码的第一行准备置换第一个“训练”折叠,这可能是不必要的。第二行处理第一个内 OOS 褶皱,最后一行处理外 OOS 褶皱区,这是我们最感兴趣的区域。对于置换,用相同的参数调用do_permute()例程。所有其他操作与我们之前看到的完全相同。

prepare_permute( IS_n, n_markets, 1 , market_close , permute_work ) ;
prepare_permute( IS_n+OOS1_n, n_markets , IS_n, market_close , permute_work ) ;
prepare_permute( n_cases, n_markets , IS_n+OOS1_n, market_close , permute_work);

我们现在重现这个程序的输出,这个程序在置换检验被讨论之前就已经出现了。计算出的 p 值的含义现在应该很清楚了。

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

观察到三个单独绩效标准的 p 值仅具有中等显著性,其中总回报为 0.076,为最佳。但是对于最终的算法来说,它不仅使用嵌套的 walkforward 来测试市场选择,还测试性能标准选择,p 值 0.027 是非常令人印象深刻的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值